fix: double messages on re-login, nostr profile fetch, show npub in profile

- Fix duplicate messages after logout→re-login: ws.disconnect() now clears
  event listeners so initChat() doesn't stack duplicate handlers
- Nostr profile fetch: race multiple relays (damus, nostr.band, nos.lol)
  for better reliability
- Add nostr_pubkey field to UserPublic — returned from me/login/rooms APIs
- Profile page shows truncated npub instead of email for Nostr users
- Avatar service handles external URLs (Nostr profile pictures)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-16 19:13:23 -06:00
parent cd8ef7dbf6
commit 66bbc44f75
9 changed files with 96 additions and 64 deletions

View File

@ -39,10 +39,16 @@
/>
</div>
<div class="form-group">
<div class="form-group" if={!props.user?.nostr_pubkey}>
<label>Email</label>
<input type="email" value={props.user?.email || 'Nostr user'} disabled class="input-disabled" />
<span class="form-hint">{props.user?.email ? 'Email cannot be changed' : 'Logged in via Nostr'}</span>
<input type="email" value={props.user?.email} disabled class="input-disabled" />
<span class="form-hint">Email cannot be changed</span>
</div>
<div class="form-group" if={props.user?.nostr_pubkey}>
<label>Nostr Public Key</label>
<input type="text" value={npubDisplay()} disabled class="input-disabled input-mono" />
<span class="form-hint">Logged in via Nostr</span>
</div>
<p if={state.error} class="error-text">{state.error}</p>
@ -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)
},

View File

@ -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)
}

View File

@ -14,36 +14,38 @@ 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)
for (const relay of RELAYS) {
try {
ws = new WebSocket(relay)
} catch {
cleanup()
resolve(null)
return
}
const ws = new WebSocket(relay)
connections.push(ws)
ws.onopen = () => {
const subId = 'profile_' + Math.random().toString(36).slice(2, 8)
const subId = 'p_' + Math.random().toString(36).slice(2, 8)
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pubkeyHex], limit: 1 }]))
}
@ -52,24 +54,16 @@ export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) {
const data = JSON.parse(msg.data)
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
const profile = JSON.parse(data[2].content)
cleanup()
resolve({
done({
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
}
} catch {}
}
ws.onerror = () => {
cleanup()
resolve(null)
ws.onerror = () => {}
} catch {}
}
})
}

View File

@ -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

View File

@ -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<Arc<AppState>>,
) -> Result<Json<UserPublic>, (StatusCode, String)> {
let avatar_url: Option<String> =
sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?")
let row = sqlx::query_as::<_, (Option<String>, Option<String>)>(
"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()))?
.flatten();
.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,
}))
}

View File

@ -162,6 +162,7 @@ pub async fn verify(
email: crate::models::public_email(&email),
display_name,
avatar_url,
nostr_pubkey: Some(pubkey_hex),
},
}))
}

View File

@ -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,
},
}))
}

View File

@ -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<String>)>(
"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<String>, Option<String>)>(
"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<String>)>(
"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<String>, Option<String>)>(
"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(),
}))

View File

@ -77,6 +77,8 @@ pub struct UserPublic {
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nostr_pubkey: Option<String>,
}
#[derive(Debug, Deserialize)]