From 2e1a0ac858b97591db79db8b469708fd27d77de1 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 17:11:03 -0600 Subject: [PATCH 01/14] feat: add SHA-256 integrity hashes to messages with copy/link buttons Add a hash column to messages table computed from SHA-256(created_at + content) to ensure message integrity. Existing messages get backfilled during migration. All messages now show copy and permalink buttons on hover, with hash-based URL fragments that auto-scroll and highlight the target message. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 22 +++++- client/src/components/app.riot | 75 +++++++++++++++++-- client/src/components/chat-room.riot | 2 +- client/src/components/login-page.riot | 2 +- client/src/components/message-bubble.riot | 87 +++++++++++++++++------ client/src/components/register-page.riot | 4 +- client/src/services/api.js | 12 ++++ client/src/services/websocket.js | 19 ++++- server/Cargo.lock | 1 + server/Cargo.toml | 1 + server/migrations/007_message_hash.sql | 5 ++ server/src/handlers/auth.rs | 34 ++++++--- server/src/handlers/rooms.rs | 14 ++-- server/src/handlers/ws.rs | 25 ++++++- server/src/main.rs | 32 +++++++++ server/src/models/mod.rs | 14 ++++ 16 files changed, 298 insertions(+), 51 deletions(-) create mode 100644 server/migrations/007_message_hash.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 124d1e9..71fae71 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -36,7 +36,27 @@ "Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Write-Output ''Killed''\")", "Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; Set-Location ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")", "Bash(git init)", - "Bash(git add -A)" + "Bash(git add -A)", + "Bash(git commit:*)", + "Bash(git branch stream)", + "Bash(git checkout stream)", + "Bash(powershell -Command \"cd ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; cargo build 2>&1\")", + "Bash(powershell -Command \"cd ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1\")", + "Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Select-Object Id, ProcessName\")", + "Bash(powershell -Command \"cd ''Z:\\\\Projects\\\\Hot\\\\Duke\\\\GroupChat2\\\\server''; cargo build 2>&1; Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Seconds 1; Start-Process -FilePath ''.\\\\target\\\\debug\\\\groupchat-server.exe'' -WindowStyle Hidden; Start-Sleep -Seconds 2; Get-Process groupchat-server -ErrorAction SilentlyContinue | Select-Object Id\")", + "Bash(powershell -Command \"try { $r = Invoke-WebRequest -Uri ''http://localhost:3001/api/models'' -TimeoutSec 5 -ErrorAction Stop; $r.StatusCode } catch { $_Exception.Message }\")", + "Bash(powershell -Command \"Get-Process groupchat-server -ErrorAction SilentlyContinue | Stop-Process -Force\")", + "Bash(powershell -Command \"Start-Process -FilePath ''.\\\\target\\\\debug\\\\groupchat-server.exe'' -WindowStyle Hidden\")", + "Bash(npm run build)", + "Bash(git add client/src/components/app.riot client/src/components/chat-room.riot client/src/components/message-bubble.riot client/src/services/stream-buffer.js server/src/handlers/ws.rs server/src/main.rs server/src/models/mod.rs server/src/services/openrouter.rs)", + "Bash(taskkill /F /IM server.exe)", + "Bash(timeout /t 2 /nobreak)", + "Bash(curl -s http://localhost:3001/api/auth/me -H \"Authorization: Bearer test\")", + "Bash(git add server/migrations/004_ai_name.sql server/src/main.rs server/src/models/mod.rs server/src/handlers/rooms.rs server/src/handlers/ws.rs server/src/handlers/invites.rs client/src/components/app.riot client/src/components/chat-room.riot client/src/components/create-room-modal.riot client/src/components/message-bubble.riot)", + "WebFetch(domain:api.dicebear.com)", + "mcp__Desktop_Commander__list_sessions", + "mcp__Desktop_Commander__write_file", + "Bash(git checkout -b CAN)" ] } } diff --git a/client/src/components/app.riot b/client/src/components/app.riot index e241caf..b96a8a9 100644 --- a/client/src/components/app.riot +++ b/client/src/components/app.riot @@ -146,10 +146,24 @@ .no-room-content p { font-size: var(--text-sm); } + + :global(.hash-highlight) { + animation: hash-flash 3s ease; + } + + @keyframes hash-flash { + 0%, 15% { + background: rgba(108, 92, 231, 0.2); + border-radius: 8px; + } + 100% { + background: transparent; + } + } diff --git a/client/src/components/chat-room.riot b/client/src/components/chat-room.riot index 2cb1503..a5703e9 100644 --- a/client/src/components/chat-room.riot +++ b/client/src/components/chat-room.riot @@ -72,7 +72,7 @@
-
+
{props.message?.sender_name}
{props.message?.content}
-
- - - - {formatModel(props.message.ai_meta.model)} - - - ⚡ {calcSpeed(props.message.ai_meta)} tok/sec - - - 🎯 {props.message.ai_meta.completion_tokens} tokens - - - ⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s - + +
@@ -262,7 +270,7 @@ overflow-y: auto; } - .ai-stats-bar { + .message-actions-bar { display: flex; align-items: center; gap: var(--space-sm); @@ -271,9 +279,21 @@ font-size: 11px; color: var(--text-muted); flex-wrap: wrap; + opacity: 0; + transition: opacity var(--transition-fast); } - .ai-stat-btn { + .message:hover .message-actions-bar, + .message-actions-bar:focus-within { + opacity: 1; + } + + /* Always show for AI messages with stats */ + .ai-message .message-actions-bar { + opacity: 1; + } + + .msg-action-btn { display: inline-flex; align-items: center; justify-content: center; @@ -284,12 +304,12 @@ line-height: 1; } - .ai-stat-btn:hover { + .msg-action-btn:hover { background: var(--bg-hover); color: var(--text-primary); } - .ai-stat-btn.copied { + .msg-action-btn.copied { color: var(--success); } @@ -345,10 +365,14 @@ diff --git a/client/src/services/api.js b/client/src/services/api.js index 2ca90a1..ccb0745 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -38,7 +38,9 @@ async function request(method, path, body) { throw new Error('Session expired — please log in again') } const text = await res.text() - throw new Error(text || `HTTP ${res.status}`) + const err = new Error(text || `HTTP ${res.status}`) + err.status = res.status + throw err } if (res.status === 204 || res.headers.get('content-length') === '0') { From 55c17b2999ee1dc99f0f8aa74754ed321ef7e443 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 17:44:44 -0600 Subject: [PATCH 05/14] fix: support hash-only permalink format with server-side resolution Add /api/messages/hash/:hash endpoint that resolves a message hash to its room ID (with membership check). The client now handles both #roomId/hash and #hash formats - the latter calls the API to find which room the message belongs to, then loads it and scrolls. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/app.riot | 37 +++++++++++++++++++++++++--------- client/src/services/api.js | 1 + server/src/handlers/rooms.rs | 24 ++++++++++++++++++++++ server/src/main.rs | 1 + 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/client/src/components/app.riot b/client/src/components/app.riot index 8e011e9..fd314b8 100644 --- a/client/src/components/app.riot +++ b/client/src/components/app.riot @@ -480,7 +480,7 @@ /** Stash a message permalink hash so it survives the login flow */ stashPendingLink() { const fragment = window.location.hash?.slice(1) - if (fragment && fragment.includes('/')) { + if (fragment) { sessionStorage.setItem('pendingMessageLink', fragment) } }, @@ -533,21 +533,41 @@ }) }, - /** Parse #roomId/messageHash from URL or sessionStorage, load the room, and scroll to the message */ + /** Parse #roomId/messageHash or #messageHash from URL or sessionStorage, load the room, and scroll */ async navigateToMessageLink() { // Try URL hash first, then sessionStorage (for post-login flow) let fragment = window.location.hash?.slice(1) - if (!fragment || !fragment.includes('/')) { + if (!fragment) { fragment = sessionStorage.getItem('pendingMessageLink') sessionStorage.removeItem('pendingMessageLink') } if (!fragment) return - const slashIdx = fragment.indexOf('/') - if (slashIdx === -1) return + let roomId, msgHash + + if (fragment.includes('/')) { + // New format: #roomId/messageHash + const slashIdx = fragment.indexOf('/') + roomId = fragment.slice(0, slashIdx) + msgHash = fragment.slice(slashIdx + 1) + } else { + // Old format: #messageHash — resolve room via API + msgHash = fragment + try { + const result = await api.resolveMessageHash(msgHash) + roomId = result.room_id + } catch (e) { + const status = e?.status + if (status === 404) { + this.update({ linkError: 'Message not found or you don\'t have access' }) + } else { + this.update({ linkError: 'Could not find this message' }) + } + window.history.replaceState(null, '', window.location.pathname) + return + } + } - const roomId = fragment.slice(0, slashIdx) - const msgHash = fragment.slice(slashIdx + 1) if (!roomId || !msgHash) return // Store the target hash so selectRoom skips scrollToBottom @@ -559,7 +579,7 @@ await this.selectRoom(roomId) } catch (e) { this._pendingScrollHash = null - const status = e?.status || e?.response?.status + const status = e?.status if (status === 403) { this.update({ linkError: 'You don\'t have access to this room' }) } else if (status === 404) { @@ -567,7 +587,6 @@ } else { this.update({ linkError: 'Could not load this room' }) } - // Clean the hash from URL window.history.replaceState(null, '', window.location.pathname) return } diff --git a/client/src/services/api.js b/client/src/services/api.js index ccb0745..2148d7a 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -86,6 +86,7 @@ export const api = { joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`), deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`), clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`), + resolveMessageHash: (hash) => request('GET', `/messages/hash/${hash}`), // Models listModels: () => request('GET', '/models'), diff --git a/server/src/handlers/rooms.rs b/server/src/handlers/rooms.rs index 01abfc6..c742751 100644 --- a/server/src/handlers/rooms.rs +++ b/server/src/handlers/rooms.rs @@ -236,6 +236,30 @@ pub async fn get_messages( Ok(Json(payloads)) } +pub async fn resolve_message_hash( + State(state): State>, + auth: AuthUser, + Path(hash): Path, +) -> Result, (StatusCode, String)> { + // Find the message by hash + let row = sqlx::query_as::<_, (String,)>( + "SELECT m.room_id FROM messages m \ + JOIN room_members rm ON rm.room_id = m.room_id AND rm.user_id = ? \ + JOIN rooms r ON r.id = m.room_id AND r.deleted_at IS NULL \ + WHERE m.hash = ? LIMIT 1", + ) + .bind(&auth.user_id) + .bind(&hash) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match row { + Some((room_id,)) => Ok(Json(serde_json::json!({ "room_id": room_id, "hash": hash }))), + None => Err((StatusCode::NOT_FOUND, "Message not found or no access".into())), + } +} + pub async fn join_room( State(state): State>, auth: AuthUser, diff --git a/server/src/main.rs b/server/src/main.rs index 4a65a83..8f8d3f7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -287,6 +287,7 @@ async fn main() { .route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages)) .route("/api/rooms/:room_id/join", post(handlers::rooms::join_room)) .route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room)) + .route("/api/messages/hash/:hash", get(handlers::rooms::resolve_message_hash)) // Upload (chat images) .route("/api/upload", post(handlers::upload::upload_chat_image)) // Models From 9634c275b3c896834d87762b7c880ddf03365bfb Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 17:48:03 -0600 Subject: [PATCH 06/14] style: add glowing border to permalink-highlighted messages The linked message now gets a purple box-shadow glow and background highlight that lasts 4 seconds, making it much more obvious which message the user was linked to. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/app.riot | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/client/src/components/app.riot b/client/src/components/app.riot index fd314b8..c00e796 100644 --- a/client/src/components/app.riot +++ b/client/src/components/app.riot @@ -172,16 +172,19 @@ } :global(.hash-highlight) { - animation: hash-flash 3s ease; + animation: hash-flash 4s ease; + border-radius: 8px; + box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.5), 0 0 12px rgba(108, 92, 231, 0.2); } @keyframes hash-flash { - 0%, 15% { - background: rgba(108, 92, 231, 0.2); - border-radius: 8px; + 0%, 30% { + background: rgba(108, 92, 231, 0.15); + box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.5), 0 0 16px rgba(108, 92, 231, 0.25); } 100% { background: transparent; + box-shadow: none; } } @@ -606,7 +609,7 @@ if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }) el.classList.add('hash-highlight') - setTimeout(() => el.classList.remove('hash-highlight'), 3000) + setTimeout(() => el.classList.remove('hash-highlight'), 4000) } }, } From 1a2f0e7951ac6e696a9caccae4e7656e489d4715 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 18:43:01 -0600 Subject: [PATCH 07/14] feat: add Nostr NIP-07 browser extension login and invite by pubkey - Server: nostr crate, migration 008 (nostr_pubkey column), challenge/verify endpoints for Schnorr-signed NIP-07 auth, invite-by-nostr endpoint - Client: NIP-07 extension detection, relay profile fetch, Nostr login button on login/register pages, Nostr tab in invite modal, profile page handles no-email Nostr users - Sentinel emails (nostr:) hidden at API boundary via public_email() Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/invite-modal.riot | 115 ++++++++++- client/src/components/login-page.riot | 103 ++++++++++ client/src/components/profile-page.riot | 4 +- client/src/components/register-page.riot | 93 +++++++++ client/src/services/api.js | 7 +- client/src/services/nostr.js | 75 +++++++ server/Cargo.lock | 239 +++++++++++++++++++++++ server/Cargo.toml | 1 + server/migrations/008_nostr.sql | 1 + server/src/handlers/auth.rs | 8 +- server/src/handlers/invites.rs | 72 ++++++- server/src/handlers/mod.rs | 1 + server/src/handlers/nostr_auth.rs | 167 ++++++++++++++++ server/src/handlers/profile.rs | 8 +- server/src/handlers/rooms.rs | 6 +- server/src/main.rs | 14 ++ server/src/models/mod.rs | 30 +++ 17 files changed, 927 insertions(+), 17 deletions(-) create mode 100644 client/src/services/nostr.js create mode 100644 server/migrations/008_nostr.sql create mode 100644 server/src/handlers/nostr_auth.rs diff --git a/client/src/components/invite-modal.riot b/client/src/components/invite-modal.riot index 89f5427..9f72e9d 100644 --- a/client/src/components/invite-modal.riot +++ b/client/src/components/invite-modal.riot @@ -10,7 +10,18 @@
-
+
+ + +
+ +
-
+

Invite link generated!

+ +
+
+ + update({ nostrPubkey: e.target.value })} + required + /> +
+ +

{state.error}

+ + +
+ +
+

Added {state.nostrDisplayName} to room

+

+ This Nostr user hasn't joined GroupChat yet. They'll need to log in with their Nostr extension first. +

+ +
@@ -150,6 +194,49 @@ font-size: var(--text-xs); color: var(--text-muted); } + + .invite-tabs { + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-lg); + border-bottom: 1px solid var(--border); + padding-bottom: var(--space-xs); + } + + .invite-tab { + background: none; + border: none; + padding: var(--space-xs) var(--space-md); + font-size: var(--text-sm); + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-md) var(--radius-md) 0 0; + transition: color var(--transition-fast), background var(--transition-fast); + } + + .invite-tab:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + } + + .invite-tab.active { + color: var(--accent); + border-bottom: 2px solid var(--accent); + font-weight: 500; + } + + .success-msg { + color: var(--success); + font-weight: 500; + margin-bottom: var(--space-md); + } + + .info-msg { + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: 1.5; + margin-bottom: var(--space-md); + } diff --git a/client/src/components/profile-page.riot b/client/src/components/profile-page.riot index a3e780c..278efaf 100644 --- a/client/src/components/profile-page.riot +++ b/client/src/components/profile-page.riot @@ -41,8 +41,8 @@
- - Email cannot be changed + + {props.user?.email ? 'Email cannot be changed' : 'Logged in via Nostr'}

{state.error}

diff --git a/client/src/components/register-page.riot b/client/src/components/register-page.riot index 0058dff..b915293 100644 --- a/client/src/components/register-page.riot +++ b/client/src/components/register-page.riot @@ -51,6 +51,17 @@ +
+ or +
+ + +
+
- - {props.user?.email ? 'Email cannot be changed' : 'Logged in via Nostr'} + + Email cannot be changed +
+ +
+ + + Logged in via Nostr

{state.error}

@@ -169,6 +175,11 @@ cursor: not-allowed; } + .input-mono { + font-family: var(--font-mono); + font-size: var(--text-xs); + } + .form-hint { display: block; margin-top: 4px; @@ -230,6 +241,13 @@ }) }, + npubDisplay() { + const hex = this.props.user?.nostr_pubkey + if (!hex) return '' + // Show truncated hex with npub prefix hint + return 'npub...' + hex.slice(-12) + }, + currentAvatar() { return getAvatarUrl(this.props.user, 96) }, diff --git a/client/src/services/avatar.js b/client/src/services/avatar.js index 78f5b5e..be735ff 100644 --- a/client/src/services/avatar.js +++ b/client/src/services/avatar.js @@ -11,7 +11,13 @@ * @returns {string} Avatar URL */ export function getAvatarUrl(user, size = 64) { - if (user?.avatar_url) return user.avatar_url + if (user?.avatar_url) { + // External URLs (e.g. Nostr profile pictures) are used as-is + if (user.avatar_url.startsWith('http://') || user.avatar_url.startsWith('https://')) { + return user.avatar_url + } + return user.avatar_url + } return avatarFromEmail(user?.email, size) } diff --git a/client/src/services/nostr.js b/client/src/services/nostr.js index b525513..3082053 100644 --- a/client/src/services/nostr.js +++ b/client/src/services/nostr.js @@ -14,62 +14,56 @@ export async function signEvent(event) { return window.nostr.signEvent(event) } +const RELAYS = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol', +] + /** - * Fetch a Nostr kind:0 profile from a relay via WebSocket. + * Fetch a Nostr kind:0 profile from relays via WebSocket. + * Races multiple relays, returns first result. * Returns { name, picture } or null on timeout/error. */ export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) { return new Promise((resolve) => { - const relay = 'wss://relay.damus.io' - let ws - let timer + let resolved = false + const connections = [] - const cleanup = () => { - clearTimeout(timer) - try { ws?.close() } catch {} + const done = (result) => { + if (resolved) return + resolved = true + connections.forEach(ws => { try { ws.close() } catch {} }) + resolve(result) } - timer = setTimeout(() => { - cleanup() - resolve(null) - }, timeoutMs) + setTimeout(() => done(null), timeoutMs) - try { - ws = new WebSocket(relay) - } catch { - cleanup() - resolve(null) - return - } - - ws.onopen = () => { - const subId = 'profile_' + Math.random().toString(36).slice(2, 8) - ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pubkeyHex], limit: 1 }])) - } - - ws.onmessage = (msg) => { + for (const relay of RELAYS) { try { - const data = JSON.parse(msg.data) - if (data[0] === 'EVENT' && data[2]?.kind === 0) { - const profile = JSON.parse(data[2].content) - cleanup() - resolve({ - name: profile.name || profile.display_name || null, - picture: profile.picture || null, - }) - } else if (data[0] === 'EOSE') { - // End of stored events — no profile found - cleanup() - resolve(null) - } - } catch { - // ignore parse errors, wait for timeout - } - } + const ws = new WebSocket(relay) + connections.push(ws) - ws.onerror = () => { - cleanup() - resolve(null) + ws.onopen = () => { + const subId = 'p_' + Math.random().toString(36).slice(2, 8) + ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pubkeyHex], limit: 1 }])) + } + + ws.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data) + if (data[0] === 'EVENT' && data[2]?.kind === 0) { + const profile = JSON.parse(data[2].content) + done({ + name: profile.name || profile.display_name || null, + picture: profile.picture || null, + }) + } + } catch {} + } + + ws.onerror = () => {} + } catch {} } }) } diff --git a/client/src/services/websocket.js b/client/src/services/websocket.js index a9b6822..0a68302 100644 --- a/client/src/services/websocket.js +++ b/client/src/services/websocket.js @@ -78,6 +78,7 @@ class WebSocketManager { disconnect() { this.token = null this.subscribedRooms.clear() + this.listeners.clear() if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null diff --git a/server/src/handlers/auth.rs b/server/src/handlers/auth.rs index 18338c0..3189440 100644 --- a/server/src/handlers/auth.rs +++ b/server/src/handlers/auth.rs @@ -64,6 +64,7 @@ pub async fn register( email: models::public_email(&email), display_name, avatar_url: None, + nostr_pubkey: None, }, })) } @@ -103,6 +104,7 @@ pub async fn login( email: models::public_email(&email), display_name, avatar_url, + nostr_pubkey: None, }, })) } @@ -111,18 +113,20 @@ pub async fn me( auth: AuthUser, State(state): State>, ) -> Result, (StatusCode, String)> { - let avatar_url: Option = - sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?") - .bind(&auth.user_id) - .fetch_optional(&state.db) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .flatten(); + let row = sqlx::query_as::<_, (Option, Option)>( + "SELECT avatar_url, nostr_pubkey FROM users WHERE id = ?", + ) + .bind(&auth.user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .unwrap_or((None, None)); Ok(Json(UserPublic { id: auth.user_id, email: models::public_email(&auth.email), display_name: auth.display_name, - avatar_url, + avatar_url: row.0, + nostr_pubkey: row.1, })) } diff --git a/server/src/handlers/nostr_auth.rs b/server/src/handlers/nostr_auth.rs index c238839..dd862f7 100644 --- a/server/src/handlers/nostr_auth.rs +++ b/server/src/handlers/nostr_auth.rs @@ -162,6 +162,7 @@ pub async fn verify( email: crate::models::public_email(&email), display_name, avatar_url, + nostr_pubkey: Some(pubkey_hex), }, })) } diff --git a/server/src/handlers/profile.rs b/server/src/handlers/profile.rs index 4ebce73..d8b6c4e 100644 --- a/server/src/handlers/profile.rs +++ b/server/src/handlers/profile.rs @@ -55,6 +55,7 @@ pub async fn update_profile( email: models::public_email(&auth.email), display_name, avatar_url, + nostr_pubkey: None, }, })) } @@ -139,6 +140,7 @@ pub async fn upload_avatar( email: models::public_email(&auth.email), display_name: auth.display_name, avatar_url: Some(avatar_url), + nostr_pubkey: None, }, })) } @@ -176,6 +178,7 @@ pub async fn delete_avatar( email: models::public_email(&auth.email), display_name: auth.display_name, avatar_url: None, + nostr_pubkey: None, }, })) } diff --git a/server/src/handlers/rooms.rs b/server/src/handlers/rooms.rs index f4468d8..0930890 100644 --- a/server/src/handlers/rooms.rs +++ b/server/src/handlers/rooms.rs @@ -55,6 +55,7 @@ pub async fn create_room( email: models::public_email(&auth.email), display_name: auth.display_name, avatar_url: None, + nostr_pubkey: None, }], })) } @@ -73,8 +74,8 @@ pub async fn list_rooms( let mut result = Vec::new(); for room in rooms { - let members = sqlx::query_as::<_, (String, String, String, Option)>( - "SELECT u.id, u.email, u.display_name, u.avatar_url FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?", + let members = sqlx::query_as::<_, (String, String, String, Option, Option)>( + "SELECT u.id, u.email, u.display_name, u.avatar_url, u.nostr_pubkey FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?", ) .bind(&room.id) .fetch_all(&state.db) @@ -92,11 +93,12 @@ pub async fn list_rooms( created_at: room.created_at, members: members .into_iter() - .map(|(id, email, display_name, avatar_url)| UserPublic { + .map(|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic { id, email: models::public_email(&email), display_name, avatar_url, + nostr_pubkey, }) .collect(), }); @@ -131,8 +133,8 @@ pub async fn get_room( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?; - let members = sqlx::query_as::<_, (String, String, String, Option)>( - "SELECT u.id, u.email, u.display_name, u.avatar_url FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?", + let members = sqlx::query_as::<_, (String, String, String, Option, Option)>( + "SELECT u.id, u.email, u.display_name, u.avatar_url, u.nostr_pubkey FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?", ) .bind(&room_id) .fetch_all(&state.db) @@ -150,11 +152,12 @@ pub async fn get_room( created_at: room.created_at, members: members .into_iter() - .map(|(id, email, display_name, avatar_url)| UserPublic { + .map(|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic { id, - email, + email: models::public_email(&email), display_name, avatar_url, + nostr_pubkey, }) .collect(), })) diff --git a/server/src/models/mod.rs b/server/src/models/mod.rs index 6fe7479..61e8b5b 100644 --- a/server/src/models/mod.rs +++ b/server/src/models/mod.rs @@ -77,6 +77,8 @@ pub struct UserPublic { pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nostr_pubkey: Option, } #[derive(Debug, Deserialize)] From b963c969158b545109228e04efaf4b7ea05a4b39 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 19:17:20 -0600 Subject: [PATCH 10/14] feat: show abbreviated message hash in chat bubble header Displays first 7 chars of SHA-256 hash after sender name and time. Full hash visible on hover via title attribute. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/message-bubble.riot | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/src/components/message-bubble.riot b/client/src/components/message-bubble.riot index 2692045..70a899b 100644 --- a/client/src/components/message-bubble.riot +++ b/client/src/components/message-bubble.riot @@ -23,6 +23,7 @@
{props.message?.sender_name} {formatTime(props.message?.created_at)} + {props.message.hash.slice(0, 7)}
@@ -152,6 +153,20 @@ color: var(--text-muted); } + .message-hash { + font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace); + font-size: 10px; + color: var(--text-muted); + opacity: 0.5; + cursor: default; + user-select: all; + transition: opacity var(--transition-fast); + } + + .message-hash:hover { + opacity: 1; + } + .message-content { padding: var(--space-sm) var(--space-md); border-radius: var(--radius-lg); From 16bc3315eb51264a89f0212f55090e8bff4da505 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 19:21:24 -0600 Subject: [PATCH 11/14] feat: show full date/time and unix timestamp on time hover Hovering over the message time now shows a tooltip with the full human-readable date/time and the unix timestamp below it. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/message-bubble.riot | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/components/message-bubble.riot b/client/src/components/message-bubble.riot index 70a899b..6f501d4 100644 --- a/client/src/components/message-bubble.riot +++ b/client/src/components/message-bubble.riot @@ -22,7 +22,7 @@
{props.message?.sender_name} - {formatTime(props.message?.created_at)} + {formatTime(props.message?.created_at)} {props.message.hash.slice(0, 7)}
@@ -431,6 +431,14 @@ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }, + fullTimestamp(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const human = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'long' }) + const unix = Math.floor(date.getTime() / 1000) + return `${human}\nUnix: ${unix}` + }, + formatModel(model) { if (!model) return 'unknown' // "openai/gpt-4o" → "gpt-4o", "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet" From 927d106eaeaf642dc143fb2bf824e4280c9bca1b Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 20:18:27 -0600 Subject: [PATCH 12/14] Add provider-based web search with Tavily support - Add `SEARCH_PROVIDER` config with Tavily/Brave API key validation in server and prod script - Introduce unified `web_search` tool and shared search service with Tavily + Brave backends - Update chat UI tool status/result labels to treat both search tools consistently --- client/src/components/chat-room.riot | 6 +- client/src/components/message-bubble.riot | 8 +- prod.sh | 16 ++- server/.env.example | 8 +- server/src/handlers/ws.rs | 22 +++-- server/src/main.rs | 23 ++++- server/src/models/mod.rs | 2 +- server/src/services/brave.rs | 29 +----- server/src/services/mod.rs | 2 + server/src/services/openrouter.rs | 4 +- server/src/services/search.rs | 79 +++++++++++++++ server/src/services/tavily.rs | 91 ++++++++++++++++++ .../17fc5479-fa75-4a91-818a-06e6ea01e689.png | Bin 0 -> 1430613 bytes 13 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 server/src/services/search.rs create mode 100644 server/src/services/tavily.rs create mode 100644 server/uploads/avatars/17fc5479-fa75-4a91-818a-06e6ea01e689.png diff --git a/client/src/components/chat-room.riot b/client/src/components/chat-room.riot index a5703e9..e81321d 100644 --- a/client/src/components/chat-room.riot +++ b/client/src/components/chat-room.riot @@ -101,7 +101,7 @@