Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.18.0",
"version": "0.19.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
12 changes: 9 additions & 3 deletions src/app/api/profile/update/route.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -33,13 +33,19 @@ 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) {
if (bio !== null && typeof bio !== 'string') return NextResponse.json({ error: 'Bio must be a string' }, { status: 400 });
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],
Expand Down
30 changes: 29 additions & 1 deletion src/app/api/profile/update/route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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: '<b>DB</b>' }, '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));
Expand Down
7 changes: 4 additions & 3 deletions src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,16 @@ export class QcClient {
return this.keyCheck();
}

/** Your public profile (what qrypt.chat/u/<you> shows): emoji, pronouns, website, bio. */
/** Your public profile (what qrypt.chat/u/<you> 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 });
Expand Down
7 changes: 4 additions & 3 deletions src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand Down
27 changes: 22 additions & 5 deletions src/lib/components/settings/ProfileSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,20 @@ import OpenEmoji from '@/lib/components/OpenEmoji.jsx';
* Settings > Profile: the public profile at /u/<username>, 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);
Expand All @@ -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));
Expand All @@ -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 });
Expand All @@ -57,6 +68,12 @@ export default function ProfileSettings() {

return (
<form onSubmit={save} className="profile-settings">
<div className="ps-row">
<label htmlFor="ps-display-name">Display name</label>
<input id="ps-display-name" className="ps-input" value={form.displayName} onChange={set('displayName')} maxLength={50} placeholder={user.username} autoComplete="name" disabled={!loaded} />
<span className="ps-hint">The name people see in chats. Your username @{user.username} stays the same.</span>
</div>

<div className="ps-row">
<label htmlFor="ps-emoji">Emoji</label>
<div className="ps-emoji">
Expand Down Expand Up @@ -95,7 +112,7 @@ export default function ProfileSettings() {

<p className="ps-hint">
Public at <a href={`/u/${encodeURIComponent(user.username)}`}>qrypt.chat/u/{user.username}</a>, and as{' '}
<a href={`/u/${encodeURIComponent(user.username)}/openprofile.md`}>OpenProfile.md</a>. Leave a field empty to hide it.
<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).
</p>

{message && <div className={`ps-alert ${message.ok ? 'ps-ok' : 'ps-err'}`}>{message.text}</div>}
Expand Down
16 changes: 16 additions & 0 deletions src/lib/profile/fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ export function cleanEmoji(input) {
return { value: parts[0].segment };
}

/**
* The display name shown in chats, on /u/<username> 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 }}
Expand Down
15 changes: 14 additions & 1 deletion src/lib/profile/fields.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -14,6 +14,19 @@ describe('profile fields', () => {
expect(cleanEmoji('<script>')).toHaveProperty('error');
});

it('display name: trimmed, collapsed, 1-50 characters, no control characters, empty keeps current', () => {
expect(cleanDisplayName(' Daniel B ')).toEqual({ value: 'Daniel B' });
expect(cleanDisplayName('Zoë 🔭')).toEqual({ value: 'Zoë 🔭' });
expect(cleanDisplayName('a\u0000b\nc')).toEqual({ value: 'a b c' });
expect(cleanDisplayName('\u202Eevil')).toEqual({ value: 'evil' });
expect(cleanDisplayName('é'.repeat(50))).toEqual({ value: 'é'.repeat(50) });
expect(cleanDisplayName('')).toEqual({ value: null });
expect(cleanDisplayName(null)).toEqual({ value: null });
expect(cleanDisplayName('x'.repeat(51))).toHaveProperty('error');
expect(cleanDisplayName('<script>')).toHaveProperty('error');
expect(cleanDisplayName(1)).toHaveProperty('error');
});

it('pronouns: as written, trimmed, bounded, no markup', () => {
expect(cleanPronouns(' she/they ')).toEqual({ value: 'she/they' });
expect(cleanPronouns('ask me')).toEqual({ value: 'ask me' });
Expand Down
Loading