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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" if={!props.user?.nostr_pubkey}>
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<input type="email" value={props.user?.email || 'Nostr user'} disabled class="input-disabled" />
|
<input type="email" value={props.user?.email} disabled class="input-disabled" />
|
||||||
<span class="form-hint">{props.user?.email ? 'Email cannot be changed' : 'Logged in via Nostr'}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p if={state.error} class="error-text">{state.error}</p>
|
<p if={state.error} class="error-text">{state.error}</p>
|
||||||
@ -169,6 +175,11 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.form-hint {
|
.form-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
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() {
|
currentAvatar() {
|
||||||
return getAvatarUrl(this.props.user, 96)
|
return getAvatarUrl(this.props.user, 96)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -11,7 +11,13 @@
|
|||||||
* @returns {string} Avatar URL
|
* @returns {string} Avatar URL
|
||||||
*/
|
*/
|
||||||
export function getAvatarUrl(user, size = 64) {
|
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)
|
return avatarFromEmail(user?.email, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,36 +14,38 @@ export async function signEvent(event) {
|
|||||||
return window.nostr.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.
|
* Returns { name, picture } or null on timeout/error.
|
||||||
*/
|
*/
|
||||||
export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) {
|
export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const relay = 'wss://relay.damus.io'
|
let resolved = false
|
||||||
let ws
|
const connections = []
|
||||||
let timer
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const done = (result) => {
|
||||||
clearTimeout(timer)
|
if (resolved) return
|
||||||
try { ws?.close() } catch {}
|
resolved = true
|
||||||
|
connections.forEach(ws => { try { ws.close() } catch {} })
|
||||||
|
resolve(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
timer = setTimeout(() => {
|
setTimeout(() => done(null), timeoutMs)
|
||||||
cleanup()
|
|
||||||
resolve(null)
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
|
for (const relay of RELAYS) {
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(relay)
|
const ws = new WebSocket(relay)
|
||||||
} catch {
|
connections.push(ws)
|
||||||
cleanup()
|
|
||||||
resolve(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
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 }]))
|
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)
|
const data = JSON.parse(msg.data)
|
||||||
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
|
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
|
||||||
const profile = JSON.parse(data[2].content)
|
const profile = JSON.parse(data[2].content)
|
||||||
cleanup()
|
done({
|
||||||
resolve({
|
|
||||||
name: profile.name || profile.display_name || null,
|
name: profile.name || profile.display_name || null,
|
||||||
picture: profile.picture || 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 = () => {
|
ws.onerror = () => {}
|
||||||
cleanup()
|
} catch {}
|
||||||
resolve(null)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,7 @@ class WebSocketManager {
|
|||||||
disconnect() {
|
disconnect() {
|
||||||
this.token = null
|
this.token = null
|
||||||
this.subscribedRooms.clear()
|
this.subscribedRooms.clear()
|
||||||
|
this.listeners.clear()
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer)
|
clearTimeout(this.reconnectTimer)
|
||||||
this.reconnectTimer = null
|
this.reconnectTimer = null
|
||||||
|
|||||||
@ -64,6 +64,7 @@ pub async fn register(
|
|||||||
email: models::public_email(&email),
|
email: models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
nostr_pubkey: None,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -103,6 +104,7 @@ pub async fn login(
|
|||||||
email: models::public_email(&email),
|
email: models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
nostr_pubkey: None,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -111,18 +113,20 @@ pub async fn me(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Result<Json<UserPublic>, (StatusCode, String)> {
|
) -> Result<Json<UserPublic>, (StatusCode, String)> {
|
||||||
let avatar_url: Option<String> =
|
let row = sqlx::query_as::<_, (Option<String>, Option<String>)>(
|
||||||
sqlx::query_scalar("SELECT avatar_url FROM users WHERE id = ?")
|
"SELECT avatar_url, nostr_pubkey FROM users WHERE id = ?",
|
||||||
|
)
|
||||||
.bind(&auth.user_id)
|
.bind(&auth.user_id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.flatten();
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
Ok(Json(UserPublic {
|
Ok(Json(UserPublic {
|
||||||
id: auth.user_id,
|
id: auth.user_id,
|
||||||
email: models::public_email(&auth.email),
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
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),
|
email: crate::models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
nostr_pubkey: Some(pubkey_hex),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,7 @@ pub async fn update_profile(
|
|||||||
email: models::public_email(&auth.email),
|
email: models::public_email(&auth.email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
nostr_pubkey: None,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -139,6 +140,7 @@ pub async fn upload_avatar(
|
|||||||
email: models::public_email(&auth.email),
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url: Some(avatar_url),
|
avatar_url: Some(avatar_url),
|
||||||
|
nostr_pubkey: None,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -176,6 +178,7 @@ pub async fn delete_avatar(
|
|||||||
email: models::public_email(&auth.email),
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
nostr_pubkey: None,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,7 @@ pub async fn create_room(
|
|||||||
email: models::public_email(&auth.email),
|
email: models::public_email(&auth.email),
|
||||||
display_name: auth.display_name,
|
display_name: auth.display_name,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
nostr_pubkey: None,
|
||||||
}],
|
}],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -73,8 +74,8 @@ pub async fn list_rooms(
|
|||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for room in rooms {
|
for room in rooms {
|
||||||
let members = sqlx::query_as::<_, (String, String, String, Option<String>)>(
|
let members = sqlx::query_as::<_, (String, String, String, Option<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 = ?",
|
"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)
|
.bind(&room.id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
@ -92,11 +93,12 @@ pub async fn list_rooms(
|
|||||||
created_at: room.created_at,
|
created_at: room.created_at,
|
||||||
members: members
|
members: members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, email, display_name, avatar_url)| UserPublic {
|
.map(|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic {
|
||||||
id,
|
id,
|
||||||
email: models::public_email(&email),
|
email: models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
nostr_pubkey,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
@ -131,8 +133,8 @@ pub async fn get_room(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?;
|
||||||
|
|
||||||
let members = sqlx::query_as::<_, (String, String, String, Option<String>)>(
|
let members = sqlx::query_as::<_, (String, String, String, Option<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 = ?",
|
"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)
|
.bind(&room_id)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
@ -150,11 +152,12 @@ pub async fn get_room(
|
|||||||
created_at: room.created_at,
|
created_at: room.created_at,
|
||||||
members: members
|
members: members
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, email, display_name, avatar_url)| UserPublic {
|
.map(|(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic {
|
||||||
id,
|
id,
|
||||||
email,
|
email: models::public_email(&email),
|
||||||
display_name,
|
display_name,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
nostr_pubkey,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -77,6 +77,8 @@ pub struct UserPublic {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub nostr_pubkey: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user