From c9b5020bb432fb233545ec1b75ac7a798474bf5c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 00:00:29 +0000 Subject: [PATCH] feat(profile): change your display name (0.19.0, qc 0.14.0) Settings > Profile gets a Display name field, POST /api/profile/update accepts displayName (or display_name): trimmed, whitespace collapsed, control and bidi-override characters stripped, 1-50 characters, no <>. Empty keeps the current name (users.display_name is NOT NULL). The navbar picks up the new name without a reload. qc profile --name "..." sets it. RLS: "Users can update own profile" covers the row and enforce_users_immutable_columns does not list display_name. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/package.json | 2 +- src/app/api/profile/update/route.js | 12 ++++++-- src/app/api/profile/update/route.test.js | 30 ++++++++++++++++++- src/cli/api.js | 7 +++-- src/cli/commands.js | 7 +++-- .../components/settings/ProfileSettings.jsx | 27 +++++++++++++---- src/lib/profile/fields.js | 16 ++++++++++ src/lib/profile/fields.test.js | 15 +++++++++- 9 files changed, 100 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 629594e0..b34ba3fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.18.0", + "version": "0.19.0", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index c2003114..d4e1b192 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.13.0", + "version": "0.14.0", "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.", "type": "module", "bin": { diff --git a/src/app/api/profile/update/route.js b/src/app/api/profile/update/route.js index 75e29091..be517799 100644 --- a/src/app/api/profile/update/route.js +++ b/src/app/api/profile/update/route.js @@ -1,12 +1,12 @@ import { NextResponse } from 'next/server'; import { createSupabaseServerClientWithToken } from '@/lib/supabase.js'; import { bearerToken } from '@/lib/auth/bearer.js'; -import { cleanEmoji, cleanPronouns, cleanWebsite } from '@/lib/profile/fields.js'; +import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite } from '@/lib/profile/fields.js'; /** - * POST /api/profile/update { bio?, website?, emoji?, pronouns? } + * POST /api/profile/update { displayName?, bio?, website?, emoji?, pronouns? } * Updates the caller's public profile. A field left out is unchanged; an - * empty string clears it. Emoji, Pronouns and Web are OpenProfile 0.4's + * empty string clears it (except displayName, which keeps the current name). Emoji, Pronouns and Web are OpenProfile 0.4's * default fields (logicsrc.com/openprofile). */ export async function POST(request) { @@ -33,6 +33,7 @@ export async function POST(request) { } const { bio, website, emoji, pronouns } = body ?? {}; + const displayName = body?.displayName ?? body?.display_name; const updateData = { updated_at: new Date().toISOString() }; if (bio !== undefined) { @@ -40,6 +41,11 @@ export async function POST(request) { if (bio && bio.length > 500) return NextResponse.json({ error: 'Bio must be 500 characters or less' }, { status: 400 }); updateData.bio = bio?.trim() || null; } + if (displayName !== undefined) { + const result = cleanDisplayName(displayName); + if ('error' in result) return NextResponse.json({ error: result.error, field: 'displayName' }, { status: 400 }); + if (result.value) updateData.display_name = result.value; + } for (const [field, value, clean] of [ ['website', website, cleanWebsite], ['emoji', emoji, cleanEmoji], diff --git a/src/app/api/profile/update/route.test.js b/src/app/api/profile/update/route.test.js index 3ba91cb7..de889357 100644 --- a/src/app/api/profile/update/route.test.js +++ b/src/app/api/profile/update/route.test.js @@ -85,6 +85,31 @@ describe('profile update', () => { expect('bio' in data).toBe(false); }); + it('saves a display name, trimmed, with control and bidi characters stripped', async () => { + const { POST } = await import('./route.js'); + const response = await POST(profileRequest('Bearer t', { displayName: ' Daniel\u202E C.\u0007B ' })); + const body = await response.json(); + expect(response.status).toBe(200); + expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ display_name: 'Daniel C. B' })); + expect(body.user.displayName).toBe('Daniel C. B'); + }); + + it('accepts display_name as well as displayName', async () => { + const { POST } = await import('./route.js'); + await POST(profileRequest('Bearer t', { display_name: 'DB' })); + expect(mocks.update.mock.calls[0][0].display_name).toBe('DB'); + }); + + it('keeps the current display name when it is sent empty, and never touches it when left out', async () => { + const { POST } = await import('./route.js'); + const response = await POST(profileRequest('Bearer t', { displayName: ' \u200B ' })); + expect(response.status).toBe(200); + expect('display_name' in mocks.update.mock.calls[0][0]).toBe(false); + expect((await response.json()).user.displayName).toBe('Alice'); + await POST(profileRequest('Bearer t', { bio: 'hi' })); + expect('display_name' in mocks.update.mock.calls[1][0]).toBe(false); + }); + it.each([ [{ emoji: 'abc' }, 'Pick a single emoji'], [{ emoji: '🔭🚀' }, 'Pick a single emoji'], @@ -94,7 +119,10 @@ describe('profile update', () => { [{ website: 'not a url' }, 'Please enter a valid website URL'], [{ bio: false }, 'Bio must be a string'], [{ bio: 'x'.repeat(501) }, 'Bio must be 500 characters or less'], - [{ website: 0 }, 'Website must be text'] + [{ website: 0 }, 'Website must be text'], + [{ displayName: 'x'.repeat(51) }, 'Display name can be at most 50 characters'], + [{ displayName: 'DB' }, 'Display name contains characters that are not allowed'], + [{ displayName: 42 }, 'Display name must be text'] ])('rejects %j', async (payload, error) => { const { POST } = await import('./route.js'); const response = await POST(profileRequest('Bearer t', payload)); diff --git a/src/cli/api.js b/src/cli/api.js index 40d38e95..55c193b2 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -123,15 +123,16 @@ export class QcClient { return this.keyCheck(); } - /** Your public profile (what qrypt.chat/u/ shows): emoji, pronouns, website, bio. */ + /** Your public profile (what qrypt.chat/u/ shows): display name, emoji, pronouns, website, bio. */ async profile() { const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? '')}`); return user; } /** - * Change profile fields: { emoji?, pronouns?, website?, bio? }. A field left - * out is unchanged; an empty string clears it. + * Change profile fields: { displayName?, emoji?, pronouns?, website?, bio? }. + * A field left out is unchanged; an empty string clears it (an empty + * displayName keeps the current name). */ async updateProfile(fields) { const { user } = await this.request('/api/profile/update', { method: 'POST', body: fields }); diff --git a/src/cli/commands.js b/src/cli/commands.js index 0f302741..1cc7b333 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -34,7 +34,7 @@ Usage: qc whoami who this terminal is signed in as qc keys [publish] whether this terminal's keys are your account's; publish makes them the account's again (when another device published keys qc does not hold) - qc profile [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT] + qc profile [--name "Ada L"] [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT] show or set your public profile (an empty value clears it); --openprofile prints it as OpenProfile.md qc chats [--archived] list your chats (archived ones only with --archived); qc list is the same @@ -245,12 +245,13 @@ export async function main(argv, { version = '0.0.0' } = {}) { if (flags.openprofile) return out((await c.openProfile()).trimEnd()); const fields = {}; for (const k of ['emoji', 'pronouns', 'website', 'bio']) if (flags[k] !== undefined) fields[k] = flags[k] === true ? '' : String(flags[k]); + if (flags.name !== undefined && flags.name !== true) fields.displayName = String(flags.name); const p = Object.keys(fields).length ? await c.updateProfile(fields) : await c.profile(); - if (flags.json) return json({ username: p.username, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null }); + if (flags.json) return json({ username: p.username, displayName: p.displayName ?? p.display_name ?? null, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null }); out(who(p)); if (p.website) out(`${mark('link')}${p.website}`); if (p.bio) out(p.bio); - if (!Object.keys(fields).length) out(`\n${info('Set: qc profile --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)')}`); + if (!Object.keys(fields).length) out(`\n${info('Set: qc profile --name "Ada L" --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)')}`); return; } case 'chats': diff --git a/src/lib/components/settings/ProfileSettings.jsx b/src/lib/components/settings/ProfileSettings.jsx index 29341ed1..ecda98a4 100644 --- a/src/lib/components/settings/ProfileSettings.jsx +++ b/src/lib/components/settings/ProfileSettings.jsx @@ -9,9 +9,20 @@ import OpenEmoji from '@/lib/components/OpenEmoji.jsx'; * Settings > Profile: the public profile at /u/, also served as * OpenProfile.md. Emoji, Pronouns and Website are OpenProfile 0.4's defaults. */ + +const toForm = (p) => ({ displayName: p.display_name ?? p.displayName ?? '', emoji: p.emoji ?? '', pronouns: p.pronouns ?? '', website: p.website ?? '', bio: p.bio ?? '' }); + +/** Show a new display name in the navbar now, and after a reload. */ +function rememberDisplayName(displayName) { + const user = useAuthStore.getState().user; + if (!user || !displayName || user.displayName === displayName) return; + const next = { ...user, displayName, display_name: displayName }; + useAuthStore.setState({ user: next }); + try { localStorage.setItem('qrypt_user', JSON.stringify(next)); } catch {} +} export default function ProfileSettings() { const user = useAuthStore((s) => s.user); - const [form, setForm] = useState({ emoji: '', pronouns: '', website: '', bio: '' }); + const [form, setForm] = useState(toForm({})); const [loaded, setLoaded] = useState(false); const [picking, setPicking] = useState(false); const emojiBtnRef = useRef(null); @@ -23,8 +34,7 @@ export default function ProfileSettings() { fetch(`/api/users/by-username/${encodeURIComponent(user.username)}`) .then((r) => (r.ok ? r.json() : null)) .then((d) => { - const p = d?.user ?? {}; - setForm({ emoji: p.emoji ?? '', pronouns: p.pronouns ?? '', website: p.website ?? '', bio: p.bio ?? '' }); + setForm(toForm(d?.user ?? {})); }) .catch(() => {}) .finally(() => setLoaded(true)); @@ -44,7 +54,8 @@ export default function ProfileSettings() { }); const d = await res.json().catch(() => ({})); if (!res.ok) throw new Error(d.error || 'Could not save your profile'); - setForm({ emoji: d.user.emoji ?? '', pronouns: d.user.pronouns ?? '', website: d.user.website ?? '', bio: d.user.bio ?? '' }); + setForm(toForm(d.user)); + rememberDisplayName(d.user.displayName); setMessage({ ok: true, text: 'Profile saved.' }); } catch (err) { setMessage({ ok: false, text: err.message }); @@ -57,6 +68,12 @@ export default function ProfileSettings() { return (
+
+ + + The name people see in chats. Your username @{user.username} stays the same. +
+
@@ -95,7 +112,7 @@ export default function ProfileSettings() {

Public at qrypt.chat/u/{user.username}, and as{' '} - OpenProfile.md. Leave a field empty to hide it. + OpenProfile.md. Leave a field empty to hide it (an empty display name keeps the current one).

{message &&
{message.text}
} diff --git a/src/lib/profile/fields.js b/src/lib/profile/fields.js index 68e87e97..1c358937 100644 --- a/src/lib/profile/fields.js +++ b/src/lib/profile/fields.js @@ -28,6 +28,22 @@ export function cleanEmoji(input) { return { value: parts[0].segment }; } +/** + * The display name shown in chats, on /u/ and in OpenProfile.md. + * Control and bidi-override characters are stripped and whitespace collapsed. + * `users.display_name` is NOT NULL, so empty means "keep the current name". + * @returns {{ value: string | null } | { error: string }} + */ +export function cleanDisplayName(input) { + if (input === null || input === undefined) return { value: null }; + if (typeof input !== 'string') return { error: 'Display name must be text' }; + const text = input.replace(/[\p{Cc}\u200B\u200E\u200F\u202A-\u202E\u2060\u2066-\u2069\uFEFF]/gu, ' ').trim().replace(/\s+/g, ' '); + if (!text) return { value: null }; + if ([...text].length > 50) return { error: 'Display name can be at most 50 characters' }; + if (/[<>]/.test(text)) return { error: 'Display name contains characters that are not allowed' }; + return { value: text }; +} + /** * Pronouns as the person writes them (she/her, they/them, any), or empty to clear. * @returns {{ value: string | null } | { error: string }} diff --git a/src/lib/profile/fields.test.js b/src/lib/profile/fields.test.js index 7908ba87..bc34c111 100644 --- a/src/lib/profile/fields.test.js +++ b/src/lib/profile/fields.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { emoji, parseOpenProfile, pronouns, web, accounts } from '@profullstack/openprofile'; -import { cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenProfile } from './fields.js'; +import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenProfile } from './fields.js'; describe('profile fields', () => { it('emoji: exactly one emoji grapheme, empty clears', () => { @@ -14,6 +14,19 @@ describe('profile fields', () => { expect(cleanEmoji('