diff --git a/AGENTS.md b/AGENTS.md index fb22047..6aa28f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ src/ │ ├── session.ts # NIP-07 session & private key handling │ ├── navigation.ts # Client-side routing helpers │ ├── overlays.ts # Image gallery overlay -│ ├── event-cache.ts # In-memory event deduplication +│ ├── event-cache.ts # Compatibility wrapper over the main event cache │ ├── timeline-cache.ts # Profile cache for timeline rendering │ ├── deletion-targets.ts # Deleted event tracking │ ├── meta.ts # Dynamic OG meta tags @@ -165,6 +165,14 @@ Default relays are defined in `src/features/relays/relays.ts`. Pruning limits: 10,000 events max; 14-day TTL general, 30-day TTL home timeline. +### Cache Source Of Truth + +- Use `nostr_cache_v2` as the single IndexedDB source of truth for cached app data. +- Read from cache first. Only fetch from relays when the required cache entry is missing. +- After fetching from relays, write the result back to the main cache and render from that cached shape. +- Do not introduce parallel caches for the same entity type. Compatibility wrappers are acceptable only if they delegate to `nostr_cache_v2`. +- When cached data and freshly fetched data both exist, treat the cached value as the authoritative render source for that code path unless the task explicitly changes cache invalidation behavior. + ### URL Routing Client-side routing via History API (`pushState` / `popstate`): @@ -219,6 +227,7 @@ import { renderEvent } from '../common/event-render.js'; ### Coding Practices - When making changes, always ensure they are corrected to avoid any side effects (e.g. update all call sites when renaming a function) +- Keep data flow consistent with the cache model: cache-first, remote-on-miss, then cache the remote result. ## Nostr Protocol Reference diff --git a/src/common/db/types.ts b/src/common/db/types.ts index c6a79f0..0186728 100644 --- a/src/common/db/types.ts +++ b/src/common/db/types.ts @@ -6,7 +6,8 @@ import type { // Database configuration export const DB_NAME = 'nostr_cache_v2' as const; -export const DB_VERSION = 1 as const; +// Bump when store/index definitions change so existing browsers run migrations. +export const DB_VERSION = 2 as const; // Store names export const STORE_NAMES = { diff --git a/src/common/event-cache.ts b/src/common/event-cache.ts index 0d465b7..da3ed06 100644 --- a/src/common/event-cache.ts +++ b/src/common/event-cache.ts @@ -1,174 +1,43 @@ import type { NostrEvent } from '../../types/nostr'; import { isTimelineCacheEnabled } from './cache-settings.js'; +import { + clearEvents, + getCacheStats, + getEvent, + LIMITS, + storeEvent, +} from './db/index.js'; -const DB_NAME: string = 'nostr_event_cache_v1'; -const DB_VERSION: number = 1; -const STORE_NAME: string = 'events'; -export const EVENT_CACHE_LIMIT: number = 1000; -const TTL_MS: number = 7 * 24 * 60 * 60 * 1000; - -interface CachedEventRecord { - id: string; - event: NostrEvent; - storedAt: number; -} - -function openDb(): Promise { - return new Promise((resolve, reject) => { - if (typeof indexedDB === 'undefined') { - reject(new Error('indexedDB not available')); - return; - } - const request: IDBOpenDBRequest = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = (): void => { - const db: IDBDatabase = request.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store: IDBObjectStore = db.createObjectStore(STORE_NAME, { - keyPath: 'id', - }); - store.createIndex('storedAt', 'storedAt'); - } - }; - request.onsuccess = (): void => { - resolve(request.result); - }; - request.onerror = (): void => { - reject(request.error || new Error('Failed to open indexedDB')); - }; - }); -} - -function requestToPromise(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = (): void => resolve(request.result); - request.onerror = (): void => - reject(request.error || new Error('IndexedDB request failed')); - }); -} - -async function pruneStore(db: IDBDatabase): Promise { - const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite'); - const store: IDBObjectStore = tx.objectStore(STORE_NAME); - const count: number = await requestToPromise(store.count()); - if (count <= EVENT_CACHE_LIMIT) { - return; - } - - const index: IDBIndex = store.index('storedAt'); - let remainingToDelete: number = count - EVENT_CACHE_LIMIT; - - await new Promise((resolve, reject) => { - const cursorRequest: IDBRequest = - index.openCursor(); - cursorRequest.onsuccess = (): void => { - const cursor: IDBCursorWithValue | null = cursorRequest.result; - if (!cursor || remainingToDelete <= 0) { - resolve(); - return; - } - cursor.delete(); - remainingToDelete -= 1; - cursor.continue(); - }; - cursorRequest.onerror = (): void => reject(cursorRequest.error); - }); -} +// Compatibility wrapper: event lookups now use the main IndexedDB cache. +export const EVENT_CACHE_LIMIT: number = LIMITS.EVENTS_HARD; export async function getEventCacheStats(): Promise<{ count: number; bytes: number; }> { - if (typeof indexedDB === 'undefined') { - return { count: 0, bytes: 0 }; - } - try { - const db: IDBDatabase = await openDb(); - const tx: IDBTransaction = db.transaction(STORE_NAME, 'readonly'); - const store: IDBObjectStore = tx.objectStore(STORE_NAME); - const encoder: TextEncoder | null = - typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; - let count: number = 0; - let bytes: number = 0; - await new Promise((resolve, reject) => { - const cursorRequest: IDBRequest = - store.openCursor(); - cursorRequest.onsuccess = (): void => { - const cursor: IDBCursorWithValue | null = cursorRequest.result; - if (!cursor) { - resolve(); - return; - } - const record: CachedEventRecord = cursor.value as CachedEventRecord; - const json: string = JSON.stringify(record.event); - bytes += encoder ? encoder.encode(json).length : json.length; - count += 1; - cursor.continue(); - }; - cursorRequest.onerror = (): void => reject(cursorRequest.error); - }); - return { count, bytes }; - } catch { - return { count: 0, bytes: 0 }; - } + const stats = await getCacheStats(); + return { + count: stats.events.count, + bytes: stats.events.bytes, + }; } export async function clearEventCache(): Promise { - if (typeof indexedDB === 'undefined') { - return; - } - try { - const db: IDBDatabase = await openDb(); - const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite'); - const store: IDBObjectStore = tx.objectStore(STORE_NAME); - store.clear(); - } catch { - // ignore cache errors - } + await clearEvents(); } export async function getCachedEvent( eventId: string, ): Promise { - if (typeof indexedDB === 'undefined' || !isTimelineCacheEnabled()) { - return null; - } - try { - const db: IDBDatabase = await openDb(); - const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite'); - const store: IDBObjectStore = tx.objectStore(STORE_NAME); - const record: CachedEventRecord | undefined = await requestToPromise< - CachedEventRecord | undefined - >(store.get(eventId)); - if (!record) { - return null; - } - const now: number = Date.now(); - if (now - record.storedAt > TTL_MS) { - store.delete(eventId); - return null; - } - return record.event; - } catch { + if (!isTimelineCacheEnabled()) { return null; } + return await getEvent(eventId); } export async function setCachedEvent(event: NostrEvent): Promise { - if (typeof indexedDB === 'undefined' || !isTimelineCacheEnabled()) { + if (!isTimelineCacheEnabled()) { return; } - try { - const db: IDBDatabase = await openDb(); - const tx: IDBTransaction = db.transaction(STORE_NAME, 'readwrite'); - const store: IDBObjectStore = tx.objectStore(STORE_NAME); - const record: CachedEventRecord = { - id: event.id, - event, - storedAt: Date.now(), - }; - store.put(record); - await pruneStore(db); - } catch { - // ignore cache errors - } + await storeEvent(event); } diff --git a/src/common/event-render.ts b/src/common/event-render.ts index 62a0472..bf88434 100644 --- a/src/common/event-render.ts +++ b/src/common/event-render.ts @@ -6,7 +6,10 @@ import type { OGPResponse, PubkeyHex, } from '../../types/nostr'; -import { fetchProfile } from '../features/profile/profile.js'; +import { + fetchProfile, + getAuthoritativeProfile, +} from '../features/profile/profile.js'; import { getRelays, normalizeRelayUrl } from '../features/relays/relays.js'; import { fetchOGP, @@ -99,6 +102,27 @@ function normalizeHttpUrl(url: string): string | null { } } +function normalizeAvatarUrl(url: string): string | null { + try { + const parsed: URL = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString(); + } + if (parsed.protocol === 'blob:') { + return url; + } + if ( + parsed.protocol === 'data:' && + url.trim().toLowerCase().startsWith('data:image/') + ) { + return url; + } + return null; + } catch { + return null; + } +} + function hasTextSelectionWithin(container: HTMLElement): boolean { const selection: Selection | null = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { @@ -781,10 +805,14 @@ async function loadReactionDetails( } catch (error: unknown) { console.warn('Failed to load profile for reaction:', error); } + const renderProfile: NostrProfile | null = getAuthoritativeProfile( + event.pubkey as PubkeyHex, + profile, + ); const npub: Npub = nip19.npubEncode(event.pubkey); - const name: string = getDisplayName(npub, profile); - const avatar: string = getAvatarURL(event.pubkey, profile); + const name: string = getDisplayName(npub, renderProfile); + const avatar: string = getAvatarURL(event.pubkey, renderProfile); const row: HTMLAnchorElement = document.createElement('a'); row.className = @@ -981,7 +1009,11 @@ function renderReplyBadge( (profile: NostrProfile | null): void => { const nameEl = container.querySelector('.reply-badge-username'); if (nameEl) { - nameEl.textContent = `@${getDisplayName(parentNpub as Npub, profile)}`; + const renderProfile: NostrProfile | null = getAuthoritativeProfile( + parentAuthorPubkey, + profile, + ); + nameEl.textContent = `@${getDisplayName(parentNpub as Npub, renderProfile)}`; } }, ); @@ -1002,12 +1034,16 @@ export function renderEvent( pubkey: PubkeyHex, output: HTMLElement, ): void { + const renderProfile: NostrProfile | null = getAuthoritativeProfile( + pubkey, + profile, + ); const isRepost: boolean = event.kind === 6 || event.kind === 16; const repostEventId: string | null = isRepost ? resolveRepostEventId(event) : null; - const avatar: string = getAvatarURL(pubkey, profile); - const name: string = getDisplayName(npub, profile); + const avatar: string = getAvatarURL(pubkey, renderProfile); + const name: string = getDisplayName(npub, renderProfile); const safeName: string = escapeHtmlAttribute(name); const safeNpub: string = escapeHtmlAttribute(npub); const createdAt: string = new Date(event.created_at * 1000).toLocaleString(); @@ -1024,7 +1060,9 @@ export function renderEvent( storedPubkey && storedPubkey === event.pubkey, ); const isLoggedIn: boolean = Boolean(storedPubkey); - const canZapTarget: boolean = Boolean(profile?.lud16 || profile?.lud06); + const canZapTarget: boolean = Boolean( + renderProfile?.lud16 || renderProfile?.lud06, + ); const actionBtnBase: string = 'event-action-btn inline-flex items-center justify-center p-1 rounded transition-colors'; const actionBtnDisabled: string = 'opacity-60 cursor-not-allowed'; @@ -1266,7 +1304,7 @@ export function renderEvent( } // Avatar display based on energy saving mode const safeAvatar: string = - normalizeHttpUrl(avatar) || 'https://placekitten.com/100/100'; + normalizeAvatarUrl(avatar) || 'https://placekitten.com/100/100'; const avatarHtml: string = isEnergySavingMode ? `
👤
` : `Avatar👤` : ` = new Map(); const FOLLOW_LIST_MAX_FUTURE_SKEW_SECONDS: number = 5 * 60; @@ -30,14 +30,15 @@ export async function fetchFollowList( const promises = relays.map(async (relayUrl: string): Promise => { try { - const socket: WebSocket = createRelayWebSocket(relayUrl); await new Promise((resolve) => { let settled: boolean = false; + let unsubscribe: (() => void) | null = null; + const finish = (): void => { if (settled) return; settled = true; clearTimeout(timeout); - socket.close(); + unsubscribe?.(); resolve(); }; @@ -46,77 +47,75 @@ export async function fetchFollowList( finish(); }, 5000); - socket.onopen = (): void => { - const subId: string = `follows-${Math.random().toString(36).slice(2)}`; - const req: [ - string, - string, - { kinds: number[]; authors: string[]; limit: number }, - ] = ['REQ', subId, { kinds: [3], authors: [pubkeyHex], limit: 50 }]; - console.log(`Requesting follows from ${relayUrl}`); - socket.send(JSON.stringify(req)); - }; + console.log(`Requesting follows from ${relayUrl}`); + void openRelaySubscription( + relayUrl, + { kinds: [3], authors: [pubkeyHex], limit: 50 }, + { + onEvent: (event: NostrEvent): void => { + if (event.kind !== 3 || event.pubkey !== pubkeyHex) { + return; + } - socket.onmessage = (msg: MessageEvent): void => { - const arr: any[] = JSON.parse(msg.data); - if (arr[0] === 'EVENT' && arr[2]?.kind === 3) { - const event: NostrEvent = arr[2]; - if (event.pubkey !== pubkeyHex) { - return; - } - - if (!verifyEvent(event)) { - console.warn( - `Ignoring invalid follow-list signature from ${relayUrl}`, - ); - return; - } - - const nowSeconds: number = Math.floor(Date.now() / 1000); - if ( - event.created_at > - nowSeconds + FOLLOW_LIST_MAX_FUTURE_SKEW_SECONDS - ) { - console.warn( - `Ignoring future-skewed follow list from ${relayUrl}: ${event.created_at}`, - ); - return; - } - - const isNewerAndRicher: boolean = - event.created_at > latestFollowTimestamp && - event.tags.length >= latestFollowTags.length; - const isSameSecondButRicher: boolean = - event.created_at === latestFollowTimestamp && - event.tags.length > latestFollowTags.length; - if (isNewerAndRicher || isSameSecondButRicher) { - latestFollowTimestamp = event.created_at; - latestFollowTags = event.tags; - } - relayResults.set(relayUrl, { - gotEvent: true, - tagCount: event.tags.length, - createdAt: event.created_at, - }); - console.log( - `Got kind 3 event from ${relayUrl} with ${event.tags.length} tags at ${event.created_at}`, - ); - } else if (arr[0] === 'EOSE') { - if (!relayResults.has(relayUrl)) { + if (!verifyEvent(event)) { + console.warn( + `Ignoring invalid follow-list signature from ${relayUrl}`, + ); + return; + } + + const nowSeconds: number = Math.floor(Date.now() / 1000); + if ( + event.created_at > + nowSeconds + FOLLOW_LIST_MAX_FUTURE_SKEW_SECONDS + ) { + console.warn( + `Ignoring future-skewed follow list from ${relayUrl}: ${event.created_at}`, + ); + return; + } + + const isNewerAndRicher: boolean = + event.created_at > latestFollowTimestamp && + event.tags.length >= latestFollowTags.length; + const isSameSecondButRicher: boolean = + event.created_at === latestFollowTimestamp && + event.tags.length > latestFollowTags.length; + if (isNewerAndRicher || isSameSecondButRicher) { + latestFollowTimestamp = event.created_at; + latestFollowTags = event.tags; + } relayResults.set(relayUrl, { - gotEvent: false, - tagCount: 0, - createdAt: null, + gotEvent: true, + tagCount: event.tags.length, + createdAt: event.created_at, }); - } + console.log( + `Got kind 3 event from ${relayUrl} with ${event.tags.length} tags at ${event.created_at}`, + ); + }, + onEose: (): void => { + if (!relayResults.has(relayUrl)) { + relayResults.set(relayUrl, { + gotEvent: false, + tagCount: 0, + createdAt: null, + }); + } + finish(); + }, + onClosed: (): void => { + finish(); + }, + }, + ) + .then((nextUnsubscribe: () => void): void => { + unsubscribe = nextUnsubscribe; + }) + .catch((error: unknown): void => { + console.error(`WebSocket error [${relayUrl}]`, error); finish(); - } - }; - - socket.onerror = (err: Event): void => { - console.error(`WebSocket error [${relayUrl}]`, err); - finish(); - }; + }); }); } catch (e) { console.warn(`Failed to fetch follows from ${relayUrl}:`, e); @@ -146,13 +145,13 @@ async function fetchEventFromRelay( ): Promise { return await new Promise((resolve) => { let settled: boolean = false; - const socket: WebSocket = createRelayWebSocket(relayUrl); + let unsubscribe: (() => void) | null = null; const finish = (event: NostrEvent | null): void => { if (settled) return; settled = true; clearTimeout(timeout); - socket.close(); + unsubscribe?.(); resolve(event); }; @@ -161,30 +160,27 @@ async function fetchEventFromRelay( finish(null); }, timeoutMs); - socket.onopen = (): void => { - const subId: string = `event-${Math.random().toString(36).slice(2)}`; - const req: [string, string, { ids: string[]; limit: number }] = [ - 'REQ', - subId, - { ids: [eventId], limit: 1 }, - ]; - socket.send(JSON.stringify(req)); - }; - - socket.onmessage = (msg: MessageEvent): void => { - const arr: any[] = JSON.parse(msg.data); - if (arr[0] === 'EVENT' && arr[2]) { - finish(arr[2] as NostrEvent); - return; - } - if (arr[0] === 'EOSE') { + void openRelaySubscription( + relayUrl, + { ids: [eventId], limit: 1 }, + { + onEvent: (event: NostrEvent): void => { + finish(event); + }, + onEose: (): void => { + finish(null); + }, + onClosed: (): void => { + finish(null); + }, + }, + ) + .then((nextUnsubscribe: () => void): void => { + unsubscribe = nextUnsubscribe; + }) + .catch((): void => { finish(null); - } - }; - - socket.onerror = (): void => { - finish(null); - }; + }); }); } @@ -242,13 +238,13 @@ export async function isEventDeleted( try { return await new Promise((resolve) => { let settled: boolean = false; - const socket: WebSocket = createRelayWebSocket(relayUrl); + let unsubscribe: (() => void) | null = null; const finish = (value: boolean): void => { if (settled) return; settled = true; clearTimeout(timeout); - socket.close(); + unsubscribe?.(); resolve(value); }; @@ -257,50 +253,41 @@ export async function isEventDeleted( finish(false); }, perRelayTimeoutMs); - socket.onopen = (): void => { - const subId: string = `deleted-${Math.random().toString(36).slice(2)}`; - const req: [ - string, - string, - { - kinds: number[]; - authors: string[]; - '#e': string[]; - limit: number; - }, - ] = [ - 'REQ', - subId, - { - kinds: [5], - authors: [authorPubkey], - '#e': [eventId], - limit: 20, - }, - ]; - socket.send(JSON.stringify(req)); - }; - - socket.onmessage = (msg: MessageEvent): void => { - const arr: any[] = JSON.parse(msg.data); - if (arr[0] === 'EVENT' && arr[2]?.kind === 5) { - const deleteEvent: NostrEvent = arr[2]; + void openRelaySubscription( + relayUrl, + { + kinds: [5], + authors: [authorPubkey], + '#e': [eventId], + limit: 20, + }, + { + onEvent: (deleteEvent: NostrEvent): void => { + if (deleteEvent.kind !== 5) { + return; + } const referencesTarget: boolean = deleteEvent.tags.some( (tag: string[]): boolean => tag[0] === 'e' && tag[1] === eventId, ); if (referencesTarget) { finish(true); - return; } - } else if (arr[0] === 'EOSE') { + }, + onEose: (): void => { + finish(false); + }, + onClosed: (): void => { + finish(false); + }, + }, + ) + .then((nextUnsubscribe: () => void): void => { + unsubscribe = nextUnsubscribe; + }) + .catch((): void => { finish(false); - } - }; - - socket.onerror = (): void => { - finish(false); - }; + }); }); } catch (e) { console.warn(`Failed to check delete event on ${relayUrl}:`, e); @@ -363,14 +350,14 @@ export async function fetchRepliesForEvent( const promises = relays.map(async (relayUrl: string): Promise => { try { - const socket: WebSocket = createRelayWebSocket(relayUrl); await new Promise((resolve) => { let settled: boolean = false; + let unsubscribe: (() => void) | null = null; const finish = (): void => { if (settled) return; settled = true; clearTimeout(timeout); - socket.close(); + unsubscribe?.(); resolve(); }; @@ -379,29 +366,27 @@ export async function fetchRepliesForEvent( finish(); }, 5000); - socket.onopen = (): void => { - const subId: string = `replies-${Math.random().toString(36).slice(2)}`; - const req: [ - string, - string, - { kinds: number[]; '#e': string[]; limit: number }, - ] = ['REQ', subId, { kinds: [1], '#e': [eventId], limit: 200 }]; - socket.send(JSON.stringify(req)); - }; - - socket.onmessage = (msg: MessageEvent): void => { - const arr: any[] = JSON.parse(msg.data); - if (arr[0] === 'EVENT' && arr[2]) { - const event: NostrEvent = arr[2]; - results.set(event.id, event); - } else if (arr[0] === 'EOSE') { + void openRelaySubscription( + relayUrl, + { kinds: [1], '#e': [eventId], limit: 200 }, + { + onEvent: (event: NostrEvent): void => { + results.set(event.id, event); + }, + onEose: (): void => { + finish(); + }, + onClosed: (): void => { + finish(); + }, + }, + ) + .then((nextUnsubscribe: () => void): void => { + unsubscribe = nextUnsubscribe; + }) + .catch((): void => { finish(); - } - }; - - socket.onerror = (): void => { - finish(); - }; + }); }); } catch (e) { console.warn(`Failed to fetch replies from ${relayUrl}:`, e); diff --git a/src/common/relay-socket.ts b/src/common/relay-socket.ts index 26345dc..37437fb 100644 --- a/src/common/relay-socket.ts +++ b/src/common/relay-socket.ts @@ -21,6 +21,21 @@ interface AuthChallengeMessage { challenge: string; } +interface SharedRelaySubscription { + onEvent?: ((event: NostrEvent) => void) | undefined; + onEose?: (() => void) | undefined; + onClosed?: ((reason: string) => void) | undefined; +} + +interface SharedRelayConnection { + relayUrl: string; + socket: WebSocket | null; + openPromise: Promise | null; + subscriptions: Map; +} + +const sharedRelayConnections: Map = new Map(); + interface WindowWithNostr extends Window { nostr?: { signEvent: (event: { @@ -253,3 +268,183 @@ export function createRelayWebSocket( } return socket; } + +function getSharedRelayConnection(relayUrl: string): SharedRelayConnection { + const normalizedRelayUrl: string = normalizeRelayUrl(relayUrl) || relayUrl; + const existing: SharedRelayConnection | undefined = + sharedRelayConnections.get(normalizedRelayUrl); + if (existing) { + return existing; + } + + const connection: SharedRelayConnection = { + relayUrl: normalizedRelayUrl, + socket: null, + openPromise: null, + subscriptions: new Map(), + }; + sharedRelayConnections.set(normalizedRelayUrl, connection); + return connection; +} + +function cleanupSharedSubscription( + connection: SharedRelayConnection, + subId: string, +): void { + connection.subscriptions.delete(subId); + if (connection.socket?.readyState === WebSocket.OPEN) { + connection.socket.send(JSON.stringify(['CLOSE', subId])); + } +} + +function attachSharedRelayListeners(connection: SharedRelayConnection): void { + const socket: WebSocket | null = connection.socket; + if (!socket) { + return; + } + + const handledChallenges: Set = new Set(); + + socket.addEventListener('message', (event: MessageEvent): void => { + if (typeof event.data !== 'string') { + return; + } + + const authMessage: AuthChallengeMessage | null = parseAuthChallengeMessage( + event.data, + ); + if (authMessage?.type === 'AUTH') { + if (handledChallenges.has(authMessage.challenge)) { + return; + } + handledChallenges.add(authMessage.challenge); + void handleRelayAuthChallenge( + socket, + connection.relayUrl, + authMessage.challenge, + ); + return; + } + + let parsedMessage: unknown; + try { + parsedMessage = JSON.parse(event.data); + } catch { + return; + } + if (!Array.isArray(parsedMessage)) { + return; + } + + const type: unknown = parsedMessage[0]; + const subId: unknown = parsedMessage[1]; + if (typeof subId !== 'string') { + return; + } + + const subscription: SharedRelaySubscription | undefined = + connection.subscriptions.get(subId); + if (!subscription) { + return; + } + + if (type === 'EVENT' && parsedMessage[2]) { + subscription.onEvent?.(parsedMessage[2] as NostrEvent); + return; + } + + if (type === 'EOSE') { + subscription.onEose?.(); + return; + } + + if (type === 'CLOSED') { + subscription.onClosed?.( + typeof parsedMessage[2] === 'string' ? parsedMessage[2] : '', + ); + cleanupSharedSubscription(connection, subId); + } + }); + + socket.addEventListener('open', (): void => { + recordRelaySuccess(connection.relayUrl); + }); + + socket.addEventListener('error', (): void => { + recordRelayFailure(connection.relayUrl); + }); + + socket.addEventListener('close', (): void => { + connection.socket = null; + connection.openPromise = null; + connection.subscriptions.clear(); + }); +} + +async function ensureSharedRelaySocket( + relayUrl: string, +): Promise { + const connection: SharedRelayConnection = getSharedRelayConnection(relayUrl); + const currentSocket: WebSocket | null = connection.socket; + + if (currentSocket?.readyState === WebSocket.OPEN) { + return connection; + } + + if (connection.openPromise) { + await connection.openPromise; + return connection; + } + + connection.openPromise = new Promise((resolve, reject) => { + const socket: WebSocket = new WebSocket(connection.relayUrl); + connection.socket = socket; + attachSharedRelayListeners(connection); + + socket.addEventListener( + 'open', + (): void => { + connection.openPromise = null; + resolve(socket); + }, + { once: true }, + ); + + socket.addEventListener( + 'error', + (): void => { + connection.openPromise = null; + reject(new Error(`Failed to connect to relay ${connection.relayUrl}`)); + }, + { once: true }, + ); + + socket.addEventListener( + 'close', + (): void => { + connection.openPromise = null; + }, + { once: true }, + ); + }); + + await connection.openPromise; + return connection; +} + +export async function openRelaySubscription( + relayUrl: string, + filter: Record, + subscription: SharedRelaySubscription, +): Promise<() => void> { + const connection: SharedRelayConnection = await ensureSharedRelaySocket( + relayUrl, + ); + const subId: string = `sub-${Math.random().toString(36).slice(2)}`; + connection.subscriptions.set(subId, subscription); + connection.socket?.send(JSON.stringify(['REQ', subId, filter])); + + return (): void => { + cleanupSharedSubscription(connection, subId); + }; +} diff --git a/src/common/timeline-loader.ts b/src/common/timeline-loader.ts index c97832a..b710dbb 100644 --- a/src/common/timeline-loader.ts +++ b/src/common/timeline-loader.ts @@ -19,7 +19,10 @@ import { import { renderEvent } from './event-render.js'; import { fetchingProfiles, profileCache } from './timeline-cache.js'; import { getAvatarURL, getDisplayName } from '../utils/utils.js'; -import { fetchProfile } from '../features/profile/profile.js'; +import { + fetchProfile, + getAuthoritativeProfile, +} from '../features/profile/profile.js'; import { getCachedProfile as getPersistentCachedProfile } from '../features/profile/profile-cache.js'; import { getRelays } from '../features/relays/relays.js'; import { createBackwardReq, getRxNostr } from '../features/relays/rx-nostr-client.js'; @@ -121,6 +124,10 @@ function updateRenderedProfile( event: NostrEvent, fetchedProfile: NostrProfile, ): void { + const renderProfile: NostrProfile | null = getAuthoritativeProfile( + event.pubkey as PubkeyHex, + fetchedProfile, + ); const eventElements: NodeListOf = output.querySelectorAll('.event-container'); eventElements.forEach((el: Element): void => { @@ -129,13 +136,10 @@ function updateRenderedProfile( const avatarEl: Element | null = el.querySelector('.event-avatar'); if (nameEl) { const npubStr: Npub = nip19.npubEncode(event.pubkey); - nameEl.textContent = `👤 ${getDisplayName(npubStr, fetchedProfile)}`; + nameEl.textContent = `👤 ${getDisplayName(npubStr, renderProfile)}`; } if (avatarEl) { - (avatarEl as HTMLImageElement).src = getAvatarURL( - event.pubkey, - fetchedProfile, - ); + (avatarEl as HTMLImageElement).src = getAvatarURL(event.pubkey, renderProfile); } } }); @@ -192,7 +196,7 @@ function getLiveRenderProfile( }); } - return profile; + return getAuthoritativeProfile(event.pubkey as PubkeyHex, profile); } function insertRenderedEventSorted( diff --git a/src/features/event/event-page.ts b/src/features/event/event-page.ts index 573a615..6eb6d3e 100644 --- a/src/features/event/event-page.ts +++ b/src/features/event/event-page.ts @@ -6,7 +6,6 @@ import type { PubkeyHex, } from '../../../types/nostr'; import { - getEvent as getCachedEvent, getProfile as getCachedProfile, } from '../../common/db/index.js'; import { @@ -14,7 +13,7 @@ import { loadReactionsForEvent, renderEvent, } from '../../common/event-render.js'; -import { setCachedEvent } from '../../common/event-cache.js'; +import { getCachedEvent, setCachedEvent } from '../../common/event-cache.js'; import { fetchEventById, fetchRepliesForEvent, @@ -23,7 +22,7 @@ import { import { setEventMeta } from '../../common/meta.js'; import { setActiveNav } from '../../common/navigation.js'; import { getAvatarURL, getDisplayName } from '../../utils/utils.js'; -import { fetchProfile } from '../profile/profile.js'; +import { fetchProfile, getAuthoritativeProfile } from '../profile/profile.js'; import { getRelays, normalizeRelayUrl } from '../relays/relays.js'; interface LoadEventPageOptions { @@ -140,9 +139,8 @@ export async function loadEventPage( return; } - // Try to load event from IndexedDB cache first + // Cache-first: all event reads go through the main IndexedDB-backed cache. let event: NostrEvent | null = await getCachedEvent(eventId); - const _fromCache = !!event; // Only show loading spinner if not in cache if (!event && options.output) { @@ -183,7 +181,13 @@ export async function loadEventPage( event.pubkey, ); if (!isRouteActive()) return; // Guard before render - renderEvent(event, cachedProfile, npubStr, event.pubkey, options.output); + renderEvent( + event, + getAuthoritativeProfile(event.pubkey as PubkeyHex, cachedProfile), + npubStr, + event.pubkey, + options.output, + ); // Insert ancestor section before the root card const rootCard = options.output.querySelector( @@ -228,6 +232,10 @@ export async function loadEventPage( // Update profile if we got one from relays (whether cached or not) if (eventProfile) { + const renderProfile: NostrProfile | null = getAuthoritativeProfile( + event.pubkey as PubkeyHex, + eventProfile, + ); if (!isRouteActive()) return; // Guard before DOM update const eventCard: HTMLElement | null = options.output.querySelector('.event-container'); @@ -238,10 +246,10 @@ export async function loadEventPage( '.event-avatar', ) as HTMLImageElement | null; if (nameEl) { - nameEl.textContent = `👤 ${getDisplayName(npubStr, eventProfile)}`; + nameEl.textContent = `👤 ${getDisplayName(npubStr, renderProfile)}`; } if (avatarEl) { - const avatarUrl = getAvatarURL(event.pubkey, eventProfile); + const avatarUrl = getAvatarURL(event.pubkey, renderProfile); avatarEl.src = avatarUrl; } } diff --git a/src/features/notifications/notifications.ts b/src/features/notifications/notifications.ts index 7341f10..909deba 100644 --- a/src/features/notifications/notifications.ts +++ b/src/features/notifications/notifications.ts @@ -7,7 +7,7 @@ import type { } from '../../../types/nostr'; import { createRelayWebSocket } from '../../common/relay-socket.js'; import { getDisplayName, replaceEmojiShortcodes } from '../../utils/utils.js'; -import { fetchProfile } from '../profile/profile.js'; +import { fetchProfile, getAuthoritativeProfile } from '../profile/profile.js'; import { recordRelayFailure } from '../relays/relays.js'; interface LoadNotificationsOptions { @@ -300,8 +300,12 @@ async function loadDisplayNames( pubkeys.map(async (pubkey: PubkeyHex): Promise => { try { const profile: NostrProfile | null = await fetchProfile(pubkey, relays); + const renderProfile: NostrProfile | null = getAuthoritativeProfile( + pubkey, + profile, + ); const npub: Npub = nip19.npubEncode(pubkey); - displayNames.set(pubkey, getDisplayName(npub, profile)); + displayNames.set(pubkey, getDisplayName(npub, renderProfile)); } catch (error: unknown) { console.warn( 'Failed to load display name for notification author:', diff --git a/src/features/profile/profile.ts b/src/features/profile/profile.ts index fa4e0bc..850a165 100644 --- a/src/features/profile/profile.ts +++ b/src/features/profile/profile.ts @@ -7,7 +7,7 @@ import type { } from '../../../types/nostr'; import { storeProfile } from '../../common/db/index.js'; import { isNip05Identifier, resolveNip05 } from '../../common/nip05.js'; -import { createRelayWebSocket } from '../../common/relay-socket.js'; +import { openRelaySubscription } from '../../common/relay-socket.js'; import { openZapComposer } from '../../common/zap.js'; import { getAvatarURL, getDisplayName } from '../../utils/utils.js'; import { recordRelayFailure } from '../relays/relays.js'; @@ -226,6 +226,29 @@ async function cacheResolvedProfile( }); } +export function getAuthoritativeProfile( + pubkeyHex: PubkeyHex, + remoteProfile: NostrProfile | null, +): NostrProfile | null { + const cachedMem: + | { profile: NostrProfile | null; expiresAt: number } + | undefined = profileMemoryCache.get(pubkeyHex); + if (cachedMem?.profile) { + return cachedMem.profile; + } + + const cachedProfile: NostrProfile | null = getCachedProfile(pubkeyHex); + if (cachedProfile) { + profileMemoryCache.set(pubkeyHex, { + profile: cachedProfile, + expiresAt: Date.now() + PROFILE_MEM_CACHE_TTL_MS, + }); + return cachedProfile; + } + + return remoteProfile; +} + function getStoredPubkey(): PubkeyHex | null { const storedPubkey: string | null = localStorage.getItem('nostr_pubkey'); return storedPubkey ? (storedPubkey as PubkeyHex) : null; @@ -353,13 +376,13 @@ export async function fetchProfile( const profile: NostrProfile | null = await new Promise((resolve) => { let settled: boolean = false; - const socket: WebSocket = createRelayWebSocket(relayUrl); + let unsubscribe: (() => void) | null = null; const finish = (value: NostrProfile | null): void => { if (settled) return; settled = true; clearTimeout(timeout); - socket.close(); + unsubscribe?.(); resolve(value); }; @@ -368,78 +391,53 @@ export async function fetchProfile( finish(null); }, 5000); - socket.onopen = (): void => { - const subId: string = `profile-${Math.random().toString(36).slice(2)}`; - const req: [ - string, - string, - { kinds: number[]; authors: string[]; limit: number }, - ] = [ - 'REQ', - subId, - { kinds: [0], authors: [pubkeyHex], limit: 1 }, - ]; - socket.send(JSON.stringify(req)); - }; - - socket.onmessage = (msg: MessageEvent): void => { - const parsedMessage: unknown = JSON.parse(msg.data); - if (!Array.isArray(parsedMessage)) { - return; - } - - const messageType: unknown = parsedMessage[0]; - const eventPayload: unknown = parsedMessage[2]; - const eventKind: unknown = - typeof eventPayload === 'object' && - eventPayload !== null && - 'kind' in eventPayload - ? (eventPayload as { kind?: unknown }).kind - : undefined; - - if (messageType === 'EVENT' && eventKind === 0) { - try { - const rawContent: unknown = - typeof eventPayload === 'object' && - eventPayload !== null && - 'content' in eventPayload - ? (eventPayload as { content?: unknown }).content - : undefined; - const rawTags: unknown = - typeof eventPayload === 'object' && - eventPayload !== null && - 'tags' in eventPayload - ? (eventPayload as { tags?: unknown }).tags - : undefined; - if (typeof rawContent !== 'string') { - finish(null); + void openRelaySubscription( + relayUrl, + { kinds: [0], authors: [pubkeyHex], limit: 1 }, + { + onEvent: (eventPayload: NostrEvent): void => { + if (eventPayload.kind !== 0) { return; } - const parsed: NostrProfile = JSON.parse(rawContent); - const emojiTags: string[][] = Array.isArray(rawTags) - ? rawTags.filter( - (tag: unknown): tag is string[] => - Array.isArray(tag) && tag[0] === 'emoji', - ) - : []; - parsed.emojiTags = emojiTags; - finish(parsed); - return; - } catch (e) { - console.warn('Failed to parse profile JSON', e); - } - } - - if (messageType === 'EOSE') { + try { + if (typeof eventPayload.content !== 'string') { + finish(null); + return; + } + + const parsed: NostrProfile = JSON.parse( + eventPayload.content, + ); + const emojiTags: string[][] = Array.isArray( + eventPayload.tags, + ) + ? eventPayload.tags.filter( + (tag: unknown): tag is string[] => + Array.isArray(tag) && tag[0] === 'emoji', + ) + : []; + parsed.emojiTags = emojiTags; + finish(parsed); + } catch (e) { + console.warn('Failed to parse profile JSON', e); + } + }, + onEose: (): void => { + finish(null); + }, + onClosed: (): void => { + finish(null); + }, + }, + ) + .then((nextUnsubscribe: () => void): void => { + unsubscribe = nextUnsubscribe; + }) + .catch((err: unknown): void => { + console.error(`WebSocket error [${relayUrl}]`, err); finish(null); - } - }; - - socket.onerror = (err: Event): void => { - console.error(`WebSocket error [${relayUrl}]`, err); - finish(null); - }; + }); }); if (!profile) { @@ -481,21 +479,25 @@ export function renderProfile( profile: NostrProfile | null, profileSection: HTMLElement, ): void { - const avatar: string = getAvatarURL(pubkey, profile); - const rawName: string = getDisplayName(npub, profile); - const banner: string | undefined = profile?.banner; - const emojiTags: string[][] = profile?.emojiTags || []; - const nip05: string | undefined = profile?.nip05?.trim(); + const renderProfileData: NostrProfile | null = getAuthoritativeProfile( + pubkey, + profile, + ); + const avatar: string = getAvatarURL(pubkey, renderProfileData); + const rawName: string = getDisplayName(npub, renderProfileData); + const banner: string | undefined = renderProfileData?.banner; + const emojiTags: string[][] = renderProfileData?.emojiTags || []; + const nip05: string | undefined = renderProfileData?.nip05?.trim(); const hasNip05: boolean = !!nip05 && isNip05Identifier(nip05); - const websiteUrl: string | null = normalizeProfileWebsiteUrl(profile); + const websiteUrl: string | null = normalizeProfileWebsiteUrl(renderProfileData); const websiteLabel: string = websiteUrl?.replace(/^https?:\/\//i, '').replace(/\/$/, '') || ''; const isEnergySavingMode: boolean = localStorage.getItem('energy_saving_mode') === 'true'; const nameHtml: string = emojifyAndLinkify(rawName, emojiTags); - const bioHtml: string = profile?.about - ? emojifyAndLinkify(profile.about, emojiTags) + const bioHtml: string = renderProfileData?.about + ? emojifyAndLinkify(renderProfileData.about, emojiTags) : ''; // Avatar HTML based on energy saving mode @@ -563,6 +565,10 @@ export function setupProfileZapButton( profile: NostrProfile | null, profileSection: HTMLElement, ): void { + const renderProfileData: NostrProfile | null = getAuthoritativeProfile( + pubkey, + profile, + ); const zapAction: HTMLElement | null = profileSection.querySelector( '#profile-zap-action', ); @@ -570,7 +576,8 @@ export function setupProfileZapButton( return; } - const zapIdentifier: string | undefined = profile?.lud16 || profile?.lud06; + const zapIdentifier: string | undefined = + renderProfileData?.lud16 || renderProfileData?.lud06; if (!zapIdentifier) { zapAction.innerHTML = ''; return; @@ -597,8 +604,8 @@ export function setupProfileZapButton( openZapComposer({ targetType: 'profile', recipientPubkey: pubkey, - recipientName: getDisplayName(npub, profile), - recipientProfile: profile, + recipientName: getDisplayName(npub, renderProfileData), + recipientProfile: renderProfileData, }); }); } diff --git a/src/features/search/search-page.ts b/src/features/search/search-page.ts index b6ab025..cff9b2d 100644 --- a/src/features/search/search-page.ts +++ b/src/features/search/search-page.ts @@ -10,7 +10,7 @@ import { renderEvent } from '../../common/event-render.js'; import { createRelayWebSocket } from '../../common/relay-socket.js'; import { fetchingProfiles, profileCache } from '../../common/timeline-cache.js'; import { getAvatarURL, getDisplayName } from '../../utils/utils.js'; -import { fetchProfile } from '../profile/profile.js'; +import { fetchProfile, getAuthoritativeProfile } from '../profile/profile.js'; import { getCachedProfile as getPersistentCachedProfile } from '../profile/profile-cache.js'; export interface SearchPageOptions { @@ -79,6 +79,10 @@ function updateRenderedProfile( pubkey: PubkeyHex, profile: NostrProfile | null, ): void { + const renderProfile: NostrProfile | null = getAuthoritativeProfile( + pubkey, + profile, + ); const eventElements: NodeListOf = output.querySelectorAll('.event-container'); eventElements.forEach((el: Element): void => { @@ -87,13 +91,13 @@ function updateRenderedProfile( } const nameEl: Element | null = el.querySelector('.event-username'); const avatarEl: Element | null = el.querySelector('.event-avatar'); - if (profile) { + if (renderProfile) { if (nameEl) { const npubStr: Npub = nip19.npubEncode(pubkey); - nameEl.textContent = `👤 ${getDisplayName(npubStr, profile)}`; + nameEl.textContent = `👤 ${getDisplayName(npubStr, renderProfile)}`; } if (avatarEl) { - (avatarEl as HTMLImageElement).src = getAvatarURL(pubkey, profile); + (avatarEl as HTMLImageElement).src = getAvatarURL(pubkey, renderProfile); } } });