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:
parent
cd8ef7dbf6
commit
66bbc44f75
@ -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)
|
||||
},
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = ?")
|
||||
.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<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()))?
|
||||
.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,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -162,6 +162,7 @@ pub async fn verify(
|
||||
email: crate::models::public_email(&email),
|
||||
display_name,
|
||||
avatar_url,
|
||||
nostr_pubkey: Some(pubkey_hex),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}))
|
||||
|
||||
@ -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)]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user