diff --git a/client/src/components/profile-page.riot b/client/src/components/profile-page.riot index 278efaf..5dffb1c 100644 --- a/client/src/components/profile-page.riot +++ b/client/src/components/profile-page.riot @@ -39,10 +39,16 @@ /> -
+
- - {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)]