Skip to content

Commit 68f410d

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
feat(profile): change your display name (0.19.0, qc 0.14.0) (#351)
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: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f911100 commit 68f410d

9 files changed

Lines changed: 100 additions & 18 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.18.0",
4+
"version": "0.19.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"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.",
55
"type": "module",
66
"bin": {

‎src/app/api/profile/update/route.js‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { NextResponse } from 'next/server';
22
import { createSupabaseServerClientWithToken } from '@/lib/supabase.js';
33
import { bearerToken } from '@/lib/auth/bearer.js';
4-
import { cleanEmoji, cleanPronouns, cleanWebsite } from '@/lib/profile/fields.js';
4+
import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite } from '@/lib/profile/fields.js';
55

66
/**
7-
* POST /api/profile/update { bio?, website?, emoji?, pronouns? }
7+
* POST /api/profile/update { displayName?, bio?, website?, emoji?, pronouns? }
88
* Updates the caller's public profile. A field left out is unchanged; an
9-
* empty string clears it. Emoji, Pronouns and Web are OpenProfile 0.4's
9+
* empty string clears it (except displayName, which keeps the current name). Emoji, Pronouns and Web are OpenProfile 0.4's
1010
* default fields (logicsrc.com/openprofile).
1111
*/
1212
export async function POST(request) {
@@ -33,13 +33,19 @@ export async function POST(request) {
3333
}
3434

3535
const { bio, website, emoji, pronouns } = body ?? {};
36+
const displayName = body?.displayName ?? body?.display_name;
3637
const updateData = { updated_at: new Date().toISOString() };
3738

3839
if (bio !== undefined) {
3940
if (bio !== null && typeof bio !== 'string') return NextResponse.json({ error: 'Bio must be a string' }, { status: 400 });
4041
if (bio && bio.length > 500) return NextResponse.json({ error: 'Bio must be 500 characters or less' }, { status: 400 });
4142
updateData.bio = bio?.trim() || null;
4243
}
44+
if (displayName !== undefined) {
45+
const result = cleanDisplayName(displayName);
46+
if ('error' in result) return NextResponse.json({ error: result.error, field: 'displayName' }, { status: 400 });
47+
if (result.value) updateData.display_name = result.value;
48+
}
4349
for (const [field, value, clean] of [
4450
['website', website, cleanWebsite],
4551
['emoji', emoji, cleanEmoji],

‎src/app/api/profile/update/route.test.js‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,31 @@ describe('profile update', () => {
8585
expect('bio' in data).toBe(false);
8686
});
8787

88+
it('saves a display name, trimmed, with control and bidi characters stripped', async () => {
89+
const { POST } = await import('./route.js');
90+
const response = await POST(profileRequest('Bearer t', { displayName: ' Daniel\u202E C.\u0007B ' }));
91+
const body = await response.json();
92+
expect(response.status).toBe(200);
93+
expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ display_name: 'Daniel C. B' }));
94+
expect(body.user.displayName).toBe('Daniel C. B');
95+
});
96+
97+
it('accepts display_name as well as displayName', async () => {
98+
const { POST } = await import('./route.js');
99+
await POST(profileRequest('Bearer t', { display_name: 'DB' }));
100+
expect(mocks.update.mock.calls[0][0].display_name).toBe('DB');
101+
});
102+
103+
it('keeps the current display name when it is sent empty, and never touches it when left out', async () => {
104+
const { POST } = await import('./route.js');
105+
const response = await POST(profileRequest('Bearer t', { displayName: ' \u200B ' }));
106+
expect(response.status).toBe(200);
107+
expect('display_name' in mocks.update.mock.calls[0][0]).toBe(false);
108+
expect((await response.json()).user.displayName).toBe('Alice');
109+
await POST(profileRequest('Bearer t', { bio: 'hi' }));
110+
expect('display_name' in mocks.update.mock.calls[1][0]).toBe(false);
111+
});
112+
88113
it.each([
89114
[{ emoji: 'abc' }, 'Pick a single emoji'],
90115
[{ emoji: '🔭🚀' }, 'Pick a single emoji'],
@@ -94,7 +119,10 @@ describe('profile update', () => {
94119
[{ website: 'not a url' }, 'Please enter a valid website URL'],
95120
[{ bio: false }, 'Bio must be a string'],
96121
[{ bio: 'x'.repeat(501) }, 'Bio must be 500 characters or less'],
97-
[{ website: 0 }, 'Website must be text']
122+
[{ website: 0 }, 'Website must be text'],
123+
[{ displayName: 'x'.repeat(51) }, 'Display name can be at most 50 characters'],
124+
[{ displayName: '<b>DB</b>' }, 'Display name contains characters that are not allowed'],
125+
[{ displayName: 42 }, 'Display name must be text']
98126
])('rejects %j', async (payload, error) => {
99127
const { POST } = await import('./route.js');
100128
const response = await POST(profileRequest('Bearer t', payload));

‎src/cli/api.js‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,16 @@ export class QcClient {
123123
return this.keyCheck();
124124
}
125125

126-
/** Your public profile (what qrypt.chat/u/<you> shows): emoji, pronouns, website, bio. */
126+
/** Your public profile (what qrypt.chat/u/<you> shows): display name, emoji, pronouns, website, bio. */
127127
async profile() {
128128
const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? '')}`);
129129
return user;
130130
}
131131

132132
/**
133-
* Change profile fields: { emoji?, pronouns?, website?, bio? }. A field left
134-
* out is unchanged; an empty string clears it.
133+
* Change profile fields: { displayName?, emoji?, pronouns?, website?, bio? }.
134+
* A field left out is unchanged; an empty string clears it (an empty
135+
* displayName keeps the current name).
135136
*/
136137
async updateProfile(fields) {
137138
const { user } = await this.request('/api/profile/update', { method: 'POST', body: fields });

‎src/cli/commands.js‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Usage:
3434
qc whoami who this terminal is signed in as
3535
qc keys [publish] whether this terminal's keys are your account's; publish makes them the
3636
account's again (when another device published keys qc does not hold)
37-
qc profile [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT]
37+
qc profile [--name "Ada L"] [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT]
3838
show or set your public profile (an empty value clears it);
3939
--openprofile prints it as OpenProfile.md
4040
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' } = {}) {
245245
if (flags.openprofile) return out((await c.openProfile()).trimEnd());
246246
const fields = {};
247247
for (const k of ['emoji', 'pronouns', 'website', 'bio']) if (flags[k] !== undefined) fields[k] = flags[k] === true ? '' : String(flags[k]);
248+
if (flags.name !== undefined && flags.name !== true) fields.displayName = String(flags.name);
248249
const p = Object.keys(fields).length ? await c.updateProfile(fields) : await c.profile();
249-
if (flags.json) return json({ username: p.username, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null });
250+
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 });
250251
out(who(p));
251252
if (p.website) out(`${mark('link')}${p.website}`);
252253
if (p.bio) out(p.bio);
253-
if (!Object.keys(fields).length) out(`\n${info('Set: qc profile --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)')}`);
254+
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)')}`);
254255
return;
255256
}
256257
case 'chats':

‎src/lib/components/settings/ProfileSettings.jsx‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,20 @@ import OpenEmoji from '@/lib/components/OpenEmoji.jsx';
99
* Settings > Profile: the public profile at /u/<username>, also served as
1010
* OpenProfile.md. Emoji, Pronouns and Website are OpenProfile 0.4's defaults.
1111
*/
12+
13+
const toForm = (p) => ({ displayName: p.display_name ?? p.displayName ?? '', emoji: p.emoji ?? '', pronouns: p.pronouns ?? '', website: p.website ?? '', bio: p.bio ?? '' });
14+
15+
/** Show a new display name in the navbar now, and after a reload. */
16+
function rememberDisplayName(displayName) {
17+
const user = useAuthStore.getState().user;
18+
if (!user || !displayName || user.displayName === displayName) return;
19+
const next = { ...user, displayName, display_name: displayName };
20+
useAuthStore.setState({ user: next });
21+
try { localStorage.setItem('qrypt_user', JSON.stringify(next)); } catch {}
22+
}
1223
export default function ProfileSettings() {
1324
const user = useAuthStore((s) => s.user);
14-
const [form, setForm] = useState({ emoji: '', pronouns: '', website: '', bio: '' });
25+
const [form, setForm] = useState(toForm({}));
1526
const [loaded, setLoaded] = useState(false);
1627
const [picking, setPicking] = useState(false);
1728
const emojiBtnRef = useRef(null);
@@ -23,8 +34,7 @@ export default function ProfileSettings() {
2334
fetch(`/api/users/by-username/${encodeURIComponent(user.username)}`)
2435
.then((r) => (r.ok ? r.json() : null))
2536
.then((d) => {
26-
const p = d?.user ?? {};
27-
setForm({ emoji: p.emoji ?? '', pronouns: p.pronouns ?? '', website: p.website ?? '', bio: p.bio ?? '' });
37+
setForm(toForm(d?.user ?? {}));
2838
})
2939
.catch(() => {})
3040
.finally(() => setLoaded(true));
@@ -44,7 +54,8 @@ export default function ProfileSettings() {
4454
});
4555
const d = await res.json().catch(() => ({}));
4656
if (!res.ok) throw new Error(d.error || 'Could not save your profile');
47-
setForm({ emoji: d.user.emoji ?? '', pronouns: d.user.pronouns ?? '', website: d.user.website ?? '', bio: d.user.bio ?? '' });
57+
setForm(toForm(d.user));
58+
rememberDisplayName(d.user.displayName);
4859
setMessage({ ok: true, text: 'Profile saved.' });
4960
} catch (err) {
5061
setMessage({ ok: false, text: err.message });
@@ -57,6 +68,12 @@ export default function ProfileSettings() {
5768

5869
return (
5970
<form onSubmit={save} className="profile-settings">
71+
<div className="ps-row">
72+
<label htmlFor="ps-display-name">Display name</label>
73+
<input id="ps-display-name" className="ps-input" value={form.displayName} onChange={set('displayName')} maxLength={50} placeholder={user.username} autoComplete="name" disabled={!loaded} />
74+
<span className="ps-hint">The name people see in chats. Your username @{user.username} stays the same.</span>
75+
</div>
76+
6077
<div className="ps-row">
6178
<label htmlFor="ps-emoji">Emoji</label>
6279
<div className="ps-emoji">
@@ -95,7 +112,7 @@ export default function ProfileSettings() {
95112

96113
<p className="ps-hint">
97114
Public at <a href={`/u/${encodeURIComponent(user.username)}`}>qrypt.chat/u/{user.username}</a>, and as{' '}
98-
<a href={`/u/${encodeURIComponent(user.username)}/openprofile.md`}>OpenProfile.md</a>. Leave a field empty to hide it.
115+
<a href={`/u/${encodeURIComponent(user.username)}/openprofile.md`}>OpenProfile.md</a>. Leave a field empty to hide it (an empty display name keeps the current one).
99116
</p>
100117

101118
{message && <div className={`ps-alert ${message.ok ? 'ps-ok' : 'ps-err'}`}>{message.text}</div>}

‎src/lib/profile/fields.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ export function cleanEmoji(input) {
2828
return { value: parts[0].segment };
2929
}
3030

31+
/**
32+
* The display name shown in chats, on /u/<username> and in OpenProfile.md.
33+
* Control and bidi-override characters are stripped and whitespace collapsed.
34+
* `users.display_name` is NOT NULL, so empty means "keep the current name".
35+
* @returns {{ value: string | null } | { error: string }}
36+
*/
37+
export function cleanDisplayName(input) {
38+
if (input === null || input === undefined) return { value: null };
39+
if (typeof input !== 'string') return { error: 'Display name must be text' };
40+
const text = input.replace(/[\p{Cc}\u200B\u200E\u200F\u202A-\u202E\u2060\u2066-\u2069\uFEFF]/gu, ' ').trim().replace(/\s+/g, ' ');
41+
if (!text) return { value: null };
42+
if ([...text].length > 50) return { error: 'Display name can be at most 50 characters' };
43+
if (/[<>]/.test(text)) return { error: 'Display name contains characters that are not allowed' };
44+
return { value: text };
45+
}
46+
3147
/**
3248
* Pronouns as the person writes them (she/her, they/them, any), or empty to clear.
3349
* @returns {{ value: string | null } | { error: string }}

‎src/lib/profile/fields.test.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import { emoji, parseOpenProfile, pronouns, web, accounts } from '@profullstack/openprofile';
3-
import { cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenProfile } from './fields.js';
3+
import { cleanDisplayName, cleanEmoji, cleanPronouns, cleanWebsite, PUBLIC_PROFILE_COLUMNS, toOpenProfile } from './fields.js';
44

55
describe('profile fields', () => {
66
it('emoji: exactly one emoji grapheme, empty clears', () => {
@@ -14,6 +14,19 @@ describe('profile fields', () => {
1414
expect(cleanEmoji('<script>')).toHaveProperty('error');
1515
});
1616

17+
it('display name: trimmed, collapsed, 1-50 characters, no control characters, empty keeps current', () => {
18+
expect(cleanDisplayName(' Daniel B ')).toEqual({ value: 'Daniel B' });
19+
expect(cleanDisplayName('Zoë 🔭')).toEqual({ value: 'Zoë 🔭' });
20+
expect(cleanDisplayName('a\u0000b\nc')).toEqual({ value: 'a b c' });
21+
expect(cleanDisplayName('\u202Eevil')).toEqual({ value: 'evil' });
22+
expect(cleanDisplayName('é'.repeat(50))).toEqual({ value: 'é'.repeat(50) });
23+
expect(cleanDisplayName('')).toEqual({ value: null });
24+
expect(cleanDisplayName(null)).toEqual({ value: null });
25+
expect(cleanDisplayName('x'.repeat(51))).toHaveProperty('error');
26+
expect(cleanDisplayName('<script>')).toHaveProperty('error');
27+
expect(cleanDisplayName(1)).toHaveProperty('error');
28+
});
29+
1730
it('pronouns: as written, trimmed, bounded, no markup', () => {
1831
expect(cleanPronouns(' she/they ')).toEqual({ value: 'she/they' });
1932
expect(cleanPronouns('ask me')).toEqual({ value: 'ask me' });

0 commit comments

Comments
 (0)