From 722ddd31b6f6749ed279974f58d0ebe2388b7669 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 11:46:20 +0000 Subject: [PATCH] Profiles: emoji, pronouns and website, served as OpenProfile.md (0.6.0, qc 0.5.0) OpenProfile 0.4 default fields on qrypt.chat profiles: - users.emoji / users.pronouns (migration 20261006230000, applied on dev2). - Settings > Profile edits emoji (OpenEmoji picker), pronouns, website, bio. - /u/ shows them and links rel=openprofile to /u//openprofile.md (same public columns, via @profullstack/openprofile). - Chat shows a sender's emoji (OpenEmoji art) and pronouns. - qc: qc profile [--emoji --pronouns --website --bio] [--openprofile]; the TUI shows senders' emoji (HD where supported) and pronouns. - /api/profile/update used setSession with an empty refresh token, so updates ran anonymously and matched no row: now a token-scoped client. Fields are validated (one emoji grapheme, 40-char pronouns, http(s) only). Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 + package.json | 3 +- packages/qryptchat/package.json | 2 +- src/app/api/messages/load/route.js | 2 +- src/app/api/profile/update/route.js | 123 ++++++----------- src/app/api/profile/update/route.test.js | 129 ++++++++---------- .../api/users/by-username/[username]/route.js | 5 +- src/app/settings/page.jsx | 5 + src/app/u/[username]/openprofile.md/route.js | 27 ++++ src/app/u/[username]/page.jsx | 18 ++- src/cli/api.js | 24 ++++ src/cli/commands.js | 18 ++- src/cli/tui.js | 3 +- src/lib/components/chat/MessageItem.jsx | 11 +- .../components/settings/ProfileSettings.jsx | 114 ++++++++++++++++ src/lib/profile/fields.js | 83 +++++++++++ src/lib/profile/fields.test.js | 58 ++++++++ .../20261006230000_user_emoji_pronouns.sql | 7 + tests/cli/qc.test.js | 7 + 19 files changed, 482 insertions(+), 160 deletions(-) create mode 100644 src/app/u/[username]/openprofile.md/route.js create mode 100644 src/lib/components/settings/ProfileSettings.jsx create mode 100644 src/lib/profile/fields.js create mode 100644 src/lib/profile/fields.test.js create mode 100644 supabase/migrations/20261006230000_user_emoji_pronouns.sql diff --git a/bun.lock b/bun.lock index d339a703..3ca971df 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@noble/hashes": "^2.0.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f", "@profullstack/encrypt": "^0.1.0", + "@profullstack/openprofile": "^0.2.0", "@profullstack/pairux-embed": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/text-type-detection": "^1.0.0", @@ -417,6 +418,8 @@ "@profullstack/hqtui": ["@profullstack/hqtui@0.10.1", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-bYP6vMtwxAhB2gr31zBHkd/ONKA1jIAXwxJABSVCpEmjXDxQEhfMKIDJ17MGOEdEpfyjnEVl2xROpRMXmkVU4Q=="], + "@profullstack/openprofile": ["@profullstack/openprofile@0.2.0", "", {}, "sha512-cdFsBbfRXurvy5KqHDNcKXxurK2s/YlfVgnILFRpVl2hq3860rExqDee8IfsFGxw5cPdqGv2fxUxtek1Xy2U5Q=="], + "@profullstack/pairux-embed": ["@profullstack/pairux-embed@0.1.0", "", { "dependencies": { "livekit-client": "^2.17.0" } }, "sha512-dGRx0kW6Y0awBOy9nbWrqAEcZNJEHrBOfxG/RikmNHuGyE+BaP0Qn1ZFis8dDWyQdEJ6VsTIH6xJbu+Gx5ITqg=="], "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], diff --git a/package.json b/package.json index 79cf06d2..54d85751 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.5.9", + "version": "0.6.0", "type": "module", "bin": { "qc": "./bin/qc.js", @@ -61,6 +61,7 @@ "@noble/hashes": "^2.0.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f", "@profullstack/encrypt": "^0.1.0", + "@profullstack/openprofile": "^0.2.0", "@profullstack/pairux-embed": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/text-type-detection": "^1.0.0", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 903f5a94..7bae91a2 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.4.7", + "version": "0.5.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/messages/load/route.js b/src/app/api/messages/load/route.js index 8b33b0fb..2e018d03 100644 --- a/src/app/api/messages/load/route.js +++ b/src/app/api/messages/load/route.js @@ -67,7 +67,7 @@ export const POST = withAuth(async ({ request, locals }) => { .from('messages') .select(` *, - sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url), + sender:users!messages_sender_id_fkey(id, username, display_name, avatar_url, emoji, pronouns), message_recipients!inner(encrypted_content, recipient_user_id) `) .eq('conversation_id', conversationId) diff --git a/src/app/api/profile/update/route.js b/src/app/api/profile/update/route.js index 05dec403..75e29091 100644 --- a/src/app/api/profile/update/route.js +++ b/src/app/api/profile/update/route.js @@ -1,20 +1,15 @@ import { NextResponse } from 'next/server'; -import { createSupabaseServerClient } from '@/lib/supabase.js'; +import { createSupabaseServerClientWithToken } from '@/lib/supabase.js'; +import { bearerToken } from '@/lib/auth/bearer.js'; +import { cleanEmoji, cleanPronouns, cleanWebsite } from '@/lib/profile/fields.js'; /** - * @param {string | null} authHeader - * @returns {string | null} + * POST /api/profile/update { 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 + * default fields (logicsrc.com/openprofile). */ -function getBearerToken(authHeader) { - if (typeof authHeader !== 'string') return null; - - const match = authHeader.match(/^Bearer\s+(.+)$/i); - const token = match?.[1]?.trim(); - - return token || null; -} - -export async function POST(request, { params } = {}) { +export async function POST(request) { try { let body; try { @@ -23,101 +18,67 @@ export async function POST(request, { params } = {}) { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } - const { bio, website } = body; - - // Get authorization header - const authHeader = request.headers.get('authorization'); - const token = getBearerToken(authHeader); + const token = bearerToken(request); if (!token) { return NextResponse.json({ error: 'Missing or invalid authorization header' }, { status: 401 }); } - // Create Supabase client and set session - const supabase = await createSupabaseServerClient(); - - // Set the session to ensure auth.uid() is available for RLS + // A client that carries the token on every request, so RLS sees this user. + // (setSession() with an empty refresh token is refused by auth-js, which + // made every update here run anonymously and match no row.) + const supabase = await createSupabaseServerClientWithToken(token); const { data: { user }, error: authError } = await supabase.auth.getUser(token); if (authError || !user) { - console.error('Auth error:', authError); return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); } - // Set the session for RLS context - await supabase.auth.setSession({ - access_token: token, - refresh_token: '' // Not needed for this operation - }); - - console.log('Authenticated user from JWT:', { id: user.id, email: user.email, phone: user.phone }); - - console.log('Authenticated user from JWT:', { id: user.id, email: user.email, phone: user.phone }); + const { bio, website, emoji, pronouns } = body ?? {}; + const updateData = { updated_at: new Date().toISOString() }; - // Validate input - if (bio !== undefined && typeof bio !== 'string') { - return NextResponse.json({ error: 'Bio must be a string' }, { status: 400 }); + 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 (website !== undefined && typeof website !== 'string') { - return NextResponse.json({ error: 'Website must be a string' }, { status: 400 }); + for (const [field, value, clean] of [ + ['website', website, cleanWebsite], + ['emoji', emoji, cleanEmoji], + ['pronouns', pronouns, cleanPronouns] + ]) { + if (value === undefined) continue; + const result = clean(value); + if ('error' in result) return NextResponse.json({ error: result.error, field }, { status: 400 }); + updateData[field] = result.value; } - // Validate bio length - if (bio && bio.length > 500) { - return NextResponse.json({ error: 'Bio must be 500 characters or less' }, { status: 400 }); - } - - // Validate website URL format if provided - if (website && website.trim()) { - const websiteUrl = website.trim(); - // Basic URL validation - allow with or without protocol - const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i; - if (!urlPattern.test(websiteUrl)) { - return NextResponse.json({ error: 'Please enter a valid website URL' }, { status: 400 }); - } - } - - // Prepare update data - const updateData = { - updated_at: new Date().toISOString(), - ...(bio !== undefined && { bio: bio.trim() || null }), - ...(website !== undefined && { website: website.trim() || null }) - }; - - // Update user profile using RLS - the policy ensures only the authenticated user can update their own profile const { data: updatedUsers, error: updateError } = await supabase .from('users') .update(updateData) .eq('auth_user_id', user.id) - .select('id, username, display_name, avatar_url, bio, website'); + .select('id, username, display_name, avatar_url, bio, website, emoji, pronouns'); if (updateError) { console.error('Error updating profile:', updateError); return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 }); } - - // Check if any rows were updated if (!updatedUsers || updatedUsers.length === 0) { - console.error('No rows updated - user not found or RLS policy blocked update'); return NextResponse.json({ error: 'Profile update failed - user not found or permission denied' }, { status: 404 }); } - const updatedUser = updatedUsers[0]; - - // Transform response data - const responseData = { - id: updatedUser.id, - username: updatedUser.username, - displayName: updatedUser.display_name, - avatarUrl: updatedUser.avatar_url, - bio: updatedUser.bio, - website: updatedUser.website - }; - - return NextResponse.json({ - success: true, - user: responseData + const u = updatedUsers[0]; + return NextResponse.json({ + success: true, + user: { + id: u.id, + username: u.username, + displayName: u.display_name, + avatarUrl: u.avatar_url, + bio: u.bio, + website: u.website, + emoji: u.emoji, + pronouns: u.pronouns + } }); - } catch (err) { console.error('Profile update error:', err); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/profile/update/route.test.js b/src/app/api/profile/update/route.test.js index d9585df4..3ba91cb7 100644 --- a/src/app/api/profile/update/route.test.js +++ b/src/app/api/profile/update/route.test.js @@ -1,35 +1,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ - getUser: vi.fn(), - setSession: vi.fn(), - createSupabaseServerClient: vi.fn(() => ({ - auth: { - getUser: mocks.getUser, - setSession: mocks.setSession - }, +const mocks = vi.hoisted(() => { + const update = vi.fn(); + const getUser = vi.fn(); + const client = { + auth: { getUser }, from: vi.fn(() => ({ - update: vi.fn(() => ({ - eq: vi.fn(() => ({ - select: vi.fn(() => ({ - data: [{ - id: 'row-1', - username: 'alice', - display_name: 'Alice', - avatar_url: null, - bio: 'hello', - website: null - }], - error: null - })) - })) - })) + update: (data) => { + update(data); + return { + eq: () => ({ + select: () => ({ + data: [{ id: 'row-1', username: 'alice', display_name: 'Alice', avatar_url: null, bio: 'hello', website: null, emoji: 'πŸ”­', pronouns: 'she/her', ...data }], + error: null + }) + }) + }; + } })) - })) -})); + }; + return { update, getUser, client, createSupabaseServerClientWithToken: vi.fn(async () => client) }; +}); vi.mock('@/lib/supabase.js', () => ({ - createSupabaseServerClient: mocks.createSupabaseServerClient + createSupabaseServerClientWithToken: mocks.createSupabaseServerClientWithToken })); function profileRequest(authorization, body = { bio: 'hello' }) { @@ -40,75 +34,72 @@ function profileRequest(authorization, body = { bio: 'hello' }) { }); } -describe('profile update bearer authentication', () => { +describe('profile update', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - mocks.getUser.mockResolvedValue({ - data: { user: { id: 'user-1', email: 'alice@example.com', phone: null } }, - error: null - }); + mocks.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); }); - it('normalizes bearer scheme casing and extra spaces before validating the token', async () => { + it('authenticates with a token-scoped client (no setSession with an empty refresh token)', async () => { const { POST } = await import('./route.js'); - const response = await POST(profileRequest('bearer access-token-123 ')); - const body = await response.json(); - expect(response.status).toBe(200); - expect(body.success).toBe(true); + expect(mocks.createSupabaseServerClientWithToken).toHaveBeenCalledWith('access-token-123'); expect(mocks.getUser).toHaveBeenCalledWith('access-token-123'); - expect(mocks.setSession).toHaveBeenCalledWith({ - access_token: 'access-token-123', - refresh_token: '' - }); }); it('rejects an empty bearer header before creating a Supabase client', async () => { const { POST } = await import('./route.js'); - const response = await POST(profileRequest('Bearer ')); - const body = await response.json(); - expect(response.status).toBe(401); - expect(body.error).toBe('Missing or invalid authorization header'); - expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled(); - expect(mocks.getUser).not.toHaveBeenCalled(); + expect((await response.json()).error).toBe('Missing or invalid authorization header'); + expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled(); }); it('returns 400 for malformed JSON before authentication work', async () => { const { POST } = await import('./route.js'); - const response = await POST({ headers: new Headers({ authorization: 'Bearer access-token-123' }), json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token')) }); - const body = await response.json(); - expect(response.status).toBe(400); - expect(body.error).toBe('Invalid JSON body'); - expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled(); - expect(mocks.getUser).not.toHaveBeenCalled(); + expect(mocks.createSupabaseServerClientWithToken).not.toHaveBeenCalled(); }); - it.each([ - ['bio', null, 'Bio must be a string'], - ['bio', false, 'Bio must be a string'], - ['bio', 0, 'Bio must be a string'], - ['website', null, 'Website must be a string'], - ['website', false, 'Website must be a string'], - ['website', 0, 'Website must be a string'] - ])( - 'rejects falsy non-string %s values instead of throwing', - async (field, value, expectedError) => { - const { POST } = await import('./route.js'); + it('saves emoji, pronouns and website (OpenProfile 0.4 default fields)', async () => { + const { POST } = await import('./route.js'); + const response = await POST(profileRequest('Bearer t', { emoji: ' πŸ”­ ', pronouns: ' she/her ', website: 'ada.example' })); + const body = await response.json(); + expect(response.status).toBe(200); + expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ emoji: 'πŸ”­', pronouns: 'she/her', website: 'https://ada.example/' })); + expect(body.user).toMatchObject({ emoji: 'πŸ”­', pronouns: 'she/her', website: 'https://ada.example/' }); + }); - const response = await POST(profileRequest('Bearer access-token-123', { [field]: value })); - const body = await response.json(); + it('clears a field with an empty string or null, and leaves out what is not sent', async () => { + const { POST } = await import('./route.js'); + await POST(profileRequest('Bearer t', { emoji: '', pronouns: null })); + const data = mocks.update.mock.calls[0][0]; + expect(data).toMatchObject({ emoji: null, pronouns: null }); + expect('website' in data).toBe(false); + expect('bio' in data).toBe(false); + }); - expect(response.status).toBe(400); - expect(body.error).toBe(expectedError); - } - ); + it.each([ + [{ emoji: 'abc' }, 'Pick a single emoji'], + [{ emoji: 'πŸ”­πŸš€' }, 'Pick a single emoji'], + [{ pronouns: 'x'.repeat(41) }, 'Pronouns can be at most 40 characters'], + [{ pronouns: 'she/her' }, 'Pronouns contain characters that are not allowed'], + [{ website: 'javascript:alert(1)' }, 'Website must be an http(s) link'], + [{ 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'] + ])('rejects %j', async (payload, error) => { + const { POST } = await import('./route.js'); + const response = await POST(profileRequest('Bearer t', payload)); + expect(response.status).toBe(400); + expect((await response.json()).error).toBe(error); + expect(mocks.update).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/users/by-username/[username]/route.js b/src/app/api/users/by-username/[username]/route.js index c19e8e36..26b09fb4 100644 --- a/src/app/api/users/by-username/[username]/route.js +++ b/src/app/api/users/by-username/[username]/route.js @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { PUBLIC_PROFILE_COLUMNS } from '@/lib/profile/fields.js'; export async function GET(request, { params } = {}) { try { @@ -12,14 +13,14 @@ export async function GET(request, { params } = {}) { // Public profile page (/u/[username]) is viewable while logged out, so this // read cannot rely on the caller's session. RLS on `users` is restricted to // the `authenticated` role, so use the service role and keep the explicit - // column list below as the boundary β€” never widen it to `*`, and never add + // column list (PUBLIC_PROFILE_COLUMNS) as the boundary β€” never widen it to `*`, and never add // phone_number / salt. (backup_pin_hash no longer lives on `users`; PIN // material moved to the service-role-only user_backup_pins table.) const supabase = getServiceRoleClient(); const { data, error } = await supabase .from('users') - .select('id, username, display_name, avatar_url, bio, unique_identifier') + .select(PUBLIC_PROFILE_COLUMNS) .eq('username', normalizedUsername) .single(); diff --git a/src/app/settings/page.jsx b/src/app/settings/page.jsx index 1ea5c33d..cbb80580 100644 --- a/src/app/settings/page.jsx +++ b/src/app/settings/page.jsx @@ -9,6 +9,7 @@ import SMSNotificationSettings from '@/lib/components/settings/SMSNotificationSe import WebhookSettings from '@/lib/components/settings/WebhookSettings.jsx'; import InviteAnon from '@/lib/components/settings/InviteAnon.jsx'; import PasskeySettings from '@/lib/components/settings/PasskeySettings.jsx'; +import ProfileSettings from '@/lib/components/settings/ProfileSettings.jsx'; export default function SettingsPage() { const router = useRouter(); @@ -25,6 +26,10 @@ export default function SettingsPage() {

Settings

+
+

Profile

+ +

Passkeys

diff --git a/src/app/u/[username]/openprofile.md/route.js b/src/app/u/[username]/openprofile.md/route.js new file mode 100644 index 00000000..0255b139 --- /dev/null +++ b/src/app/u/[username]/openprofile.md/route.js @@ -0,0 +1,27 @@ +import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; +import { PUBLIC_PROFILE_COLUMNS, toOpenProfile } from '@/lib/profile/fields.js'; + +/** + * GET /u//openprofile.md: the public profile (exactly what /u/ + * shows) as OpenProfile.md, logicsrc.com/openprofile. The page links it with + * rel="openprofile". + */ +export async function GET(request, { params } = {}) { + const { username } = (await params) || {}; + const name = username?.trim().toLowerCase(); + if (!name) return new Response('Username required\n', { status: 400 }); + + const { data, error } = await getServiceRoleClient() + .from('users') + .select(PUBLIC_PROFILE_COLUMNS) + .eq('username', name) + .single(); + if (error || !data) return new Response('Profile not found\n', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); + + return new Response(toOpenProfile(data), { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300' + } + }); +} diff --git a/src/app/u/[username]/page.jsx b/src/app/u/[username]/page.jsx index 8288479a..032a5678 100644 --- a/src/app/u/[username]/page.jsx +++ b/src/app/u/[username]/page.jsx @@ -38,8 +38,22 @@ function UserProfileInner() {
{profile.avatar_url ? : (profile.display_name || username).charAt(0).toUpperCase()}
-

{profile.display_name || profile.username}

-

@{profile.username}

+ {/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */} + +

+ {profile.emoji && } + {profile.display_name || profile.username} +

+

+ @{profile.username} + {profile.pronouns && Β· {profile.pronouns}} +

+ {profile.bio &&

{profile.bio}

} + {profile.website && ( +

+ {profile.website.replace(/^https?:\/\//, '').replace(/\/$/, '')} +

+ )} {currentUser && currentUser.id !== profile.id && ( )} diff --git a/src/cli/api.js b/src/cli/api.js index e45c471f..6dbfd793 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -111,6 +111,28 @@ export class QcClient { } } + /** Your public profile (what qrypt.chat/u/ shows): 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. + */ + async updateProfile(fields) { + const { user } = await this.request('/api/profile/update', { method: 'POST', body: fields }); + return user; + } + + /** Your public profile as OpenProfile.md (logicsrc.com/openprofile). */ + async openProfile() { + const res = await this.fetch(`${this.base}/u/${encodeURIComponent(this.me?.username ?? '')}/openprofile.md`); + if (!res.ok) throw new QcError(`HTTP ${res.status}`, res.status); + return res.text(); + } + /** Conversations, newest activity first, each with a display title. */ async conversations() { const { conversations = [] } = await this.request('/api/conversations/load', { method: 'POST', body: {} }); @@ -144,6 +166,8 @@ export class QcClient { conversationId: m.conversation_id, senderId: m.sender_id, sender: m.sender?.display_name || m.sender?.username || 'unknown', + emoji: m.sender?.emoji || '', + pronouns: m.sender?.pronouns || '', username: m.sender?.username || '', mine: m.sender_id === this.me?.id, text: m.content, diff --git a/src/cli/commands.js b/src/cli/commands.js index 114c57c3..b6f4c1ac 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -13,6 +13,9 @@ Usage: qc login [--oob] sign in through your browser; --oob to paste a code (SSH) qc logout forget this terminal's session and keys qc whoami who this terminal is signed in as + qc profile [--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 list your chats qc read [-n 20] print the last messages of a chat qc send send a message (text "-" reads stdin; --reply to reply) @@ -38,7 +41,7 @@ export function parseArgs(argv) { if (a.startsWith('--')) { const [k, v] = a.slice(2).split('=', 2); if (v !== undefined) args.flags[k] = v; - else if (['url', 'n', 'limit', 'reply'].includes(k) && argv[i + 1] !== undefined) args.flags[k] = argv[++i]; + else if (['url', 'n', 'limit', 'reply', 'emoji', 'pronouns', 'website', 'bio'].includes(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) args.flags[k] = argv[++i]; else args.flags[k] = true; } else if (a === '-n' && argv[i + 1] !== undefined) { args.flags.n = argv[++i]; @@ -118,6 +121,19 @@ export async function main(argv, { version = '0.0.0' } = {}) { if (!session) throw new Error('Not signed in. Run qc login.'); return flags.json ? json({ user: session.user, base: session.base }) : out(`@${session.user?.username} (${session.user?.display_name ?? ''}) on ${session.base}`); } + case 'profile': { + const c = await client(flags); + 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]); + 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 }); + out(`${p.emoji ? `${p.emoji} ` : ''}${p.displayName ?? p.display_name ?? p.username} @${p.username}${p.pronouns ? ` (${p.pronouns})` : ''}`); + if (p.website) out(p.website); + if (p.bio) out(p.bio); + if (!Object.keys(fields).length) out(`\nSet: qc profile --emoji πŸ”­ --pronouns she/her --website https://you.example (an empty value clears)`); + return; + } case 'chats': case 'ls': { const chats = await (await client(flags)).conversations(); diff --git a/src/cli/tui.js b/src/cli/tui.js index f1aecb7f..a2157d4b 100644 --- a/src/cli/tui.js +++ b/src/cli/tui.js @@ -76,7 +76,8 @@ export function transcriptLines(messages, width, theme) { if (!sameRun) { lines.push({ spans: [ - { text: m.mine ? 'you' : m.sender, fg: m.mine ? theme.primary : nameColor(theme, m.senderId || m.sender), bold: true }, + { text: `${m.emoji ? `${m.emoji} ` : ''}${m.mine ? 'you' : m.sender}`, fg: m.mine ? theme.primary : nameColor(theme, m.senderId || m.sender), bold: true }, + ...(m.pronouns && !m.mine ? [{ text: ` ${m.pronouns}`, fg: theme.muted }] : []), { text: ` ${timeOf(m.at)}`, fg: theme.muted }, ], }); diff --git a/src/lib/components/chat/MessageItem.jsx b/src/lib/components/chat/MessageItem.jsx index eb9865e1..3130abd7 100644 --- a/src/lib/components/chat/MessageItem.jsx +++ b/src/lib/components/chat/MessageItem.jsx @@ -78,7 +78,14 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp )}
- {!isOwn &&
{displayName}
} + {!isOwn && ( +
+ {/* The server only stores a single emoji grapheme here, so this is our artwork, not markup. */} + {sender?.emoji &&
+ )} {message.replyTo && ( + {form.emoji && ( + + )} + Shown next to your name. + {picking && ( +
+ { setForm((f) => ({ ...f, emoji: char })); setPicking(false); }} onClose={() => setPicking(false)} /> +
+ )} +
+
+ +
+ + +
+ +
+ + +
+ +
+ +