From bdc98102a9cccb2f8c965895728f918a38940e23 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Mar 2026 07:45:58 +0100 Subject: [PATCH] speed up profile loading bug-fixes --- package-lock.json | 4 +- package.json | 2 +- src/components/NoteList/index.tsx | 14 ++++ src/components/PostEditor/PostContent.tsx | 17 +++-- .../PostTextarea/Emoji/suggestion.ts | 13 +++- .../PostTextarea/Mention/suggestion.ts | 13 +++- src/hooks/useFetchProfile.tsx | 47 +++++++----- src/providers/NostrProvider/index.tsx | 5 +- src/services/client.service.ts | 76 +++++++++++++------ 9 files changed, 132 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdcb7459..bb5e5b23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "17.0.0", + "version": "17.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "17.0.0", + "version": "17.0.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 4253b452..bcd63e8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "17.0.0", + "version": "17.0.1", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index f76ed7f7..d3f538aa 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -355,6 +355,20 @@ const NoteList = forwardRef( } }, [loading, hasMore, events, showCount, timelineKey]) + // Prefetch profiles for visible authors in one batched request (IndexedDB + one relay request) + const visiblePubkeysRef = useRef>(new Set()) + useEffect(() => { + const pubkeys = Array.from( + new Set(filteredEvents.slice(0, 80).map((ev) => ev.pubkey).filter((p) => p?.length === 64)) + ) + if (pubkeys.length === 0) return + const prev = visiblePubkeysRef.current + const same = pubkeys.length === prev.size && pubkeys.every((p) => prev.has(p)) + if (same) return + visiblePubkeysRef.current = new Set(pubkeys) + client.fetchProfilesForPubkeys(pubkeys).catch(() => {}) + }, [filteredEvents]) + const showNewEvents = () => { setEvents((oldEvents) => [...newEvents, ...oldEvents]) setNewEvents([]) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 0d0ac897..7a548dac 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -858,13 +858,16 @@ export default function PostContent({ close() } catch (error) { - logger.error('Publishing error', { error }) - logger.error('Publishing error details', { - name: error instanceof Error ? error.name : 'Unknown', - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }) - + // AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise + if (!(error instanceof AggregateError && error.message === 'Failed to publish to any relay')) { + logger.error('Publishing error', { error }) + logger.error('Publishing error details', { + name: error instanceof Error ? error.name : 'Unknown', + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }) + } + // Check if we have relay statuses to display (even if publishing failed) if (error instanceof AggregateError && (error as any).relayStatuses) { const relayStatuses = (error as any).relayStatuses diff --git a/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts index 0ff704d5..c3180735 100644 --- a/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts @@ -16,6 +16,7 @@ const suggestion = { let popup: Instance[] = [] let touchListener: (e: TouchEvent) => void let closePopup: () => void + let exited = false return { onBeforeStart: () => { @@ -86,9 +87,17 @@ const suggestion = { }, onExit() { + if (exited) return + exited = true postEditor.isSuggestionPopupOpen = false - popup[0]?.destroy() - component?.destroy() + if (popup[0]) { + popup[0].destroy() + popup = [] + } + if (component) { + component.destroy() + component = undefined + } document.removeEventListener('touchstart', touchListener) postEditor.removeEventListener('closeSuggestionPopup', closePopup) diff --git a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts index 1566c35d..7534c2a1 100644 --- a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -17,6 +17,7 @@ const suggestion = { let popup: Instance[] = [] let touchListener: (e: TouchEvent) => void let closePopup: () => void + let exited = false return { onBeforeStart: () => { @@ -87,9 +88,17 @@ const suggestion = { }, onExit() { + if (exited) return + exited = true postEditor.isSuggestionPopupOpen = false - popup[0]?.destroy() - component?.destroy() + if (popup[0]) { + popup[0].destroy() + popup = [] + } + if (component) { + component.destroy() + component = undefined + } document.removeEventListener('touchstart', touchListener) postEditor.removeEventListener('closeSuggestionPopup', closePopup) diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index ca3de6df..fc2fa2bb 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -12,31 +12,40 @@ export function useFetchProfile(id?: string, skipCache = false) { const [pubkey, setPubkey] = useState(null) useEffect(() => { - setProfile(null) - setPubkey(null) - const fetchProfile = async () => { + if (!id) { + setProfile(null) + setPubkey(null) + setIsFetching(false) + setError(new Error('No id provided')) + return + } + + let cancelled = false + const pubkey = userIdToPubkey(id) + setPubkey(pubkey) + + const run = async () => { setIsFetching(true) try { - if (!id) { - setIsFetching(false) - setError(new Error('No id provided')) - return - } - - const pubkey = userIdToPubkey(id) - setPubkey(pubkey) - const profile = await client.fetchProfile(id, skipCache) - if (profile) { - setProfile(profile) - } - } catch (err) { - setError(err as Error) + const [cachedResult, fetchResult] = await Promise.allSettled([ + client.getProfileFromIndexedDB(id), + client.fetchProfile(id, skipCache) + ]) + if (cancelled) return + const cached = cachedResult.status === 'fulfilled' ? cachedResult.value : undefined + const profile = fetchResult.status === 'fulfilled' ? fetchResult.value : undefined + if (cached) setProfile(cached) + if (profile) setProfile(profile) + if (fetchResult.status === 'rejected' && !cancelled) setError(fetchResult.reason as Error) } finally { - setIsFetching(false) + if (!cancelled) setIsFetching(false) } } - fetchProfile() + run() + return () => { + cancelled = true + } }, [id]) useEffect(() => { diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index faa02997..43980157 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -922,7 +922,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { // If publishing failed completely, throw an error so the form doesn't close if (!publishResult.success) { logger.error('[Publish] Publishing failed to all relays!', { - relayStatuses: publishResult.relayStatuses + eventKind: event.kind, + eventId: event.id?.substring(0, 8), + relayStatuses: publishResult.relayStatuses, + failedUrls: publishResult.relayStatuses.filter((s) => !s.success).map((s) => s.url) }) const error = new AggregateError( publishResult.relayStatuses diff --git a/src/services/client.service.ts b/src/services/client.service.ts index c08b3cfd..94bc2d38 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -6,12 +6,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { const { search: _search, ...rest } = f return rest as Filter } -import { - compareEvents, - getReplaceableCoordinate, - getReplaceableCoordinateFromEvent, - isReplaceableEvent -} from '@/lib/event' +import { getReplaceableCoordinateFromEvent } from '@/lib/event' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' @@ -58,7 +53,6 @@ class ClientService extends EventTarget { | string[] | undefined > = {} - private replaceableEventCacheMap = new Map() private eventCacheMap = new Map>() private relayListRequestCache = new Map>() // Cache in-flight relay list requests private eventDataLoader = new DataLoader( @@ -1342,7 +1336,6 @@ class ClientService extends EventTarget { async fetchEvent(id: string): Promise { if (!/^[0-9a-f]{64}$/.test(id)) { let eventId: string | undefined - let coordinate: string | undefined const { type, data } = nip19.decode(id) switch (type) { case 'note': @@ -1352,15 +1345,9 @@ class ClientService extends EventTarget { eventId = data.id break case 'naddr': - coordinate = getReplaceableCoordinate(data.kind, data.pubkey, data.identifier) break } - if (coordinate) { - const cache = this.replaceableEventCacheMap.get(coordinate) - if (cache) { - return cache - } - } else if (eventId) { + if (eventId) { const cache = this.eventCacheMap.get(eventId) if (cache) { return cache @@ -1374,15 +1361,9 @@ class ClientService extends EventTarget { // Remove relayStatuses before caching (it's metadata for logging, not part of the event) const cleanEvent = { ...event } as NEvent delete (cleanEvent as any).relayStatuses - + this.eventDataLoader.prime(cleanEvent.id, Promise.resolve(cleanEvent)) - if (isReplaceableEvent(cleanEvent.kind)) { - const coordinate = getReplaceableCoordinateFromEvent(cleanEvent) - const cachedEvent = this.replaceableEventCacheMap.get(coordinate) - if (!cachedEvent || compareEvents(cleanEvent, cachedEvent) > 0) { - this.replaceableEventCacheMap.set(coordinate, cleanEvent) - } - } + // Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere } private async fetchEventById(relayUrls: string[], id: string): Promise { @@ -1764,6 +1745,53 @@ class ClientService extends EventTarget { } } + /** + * Fetch profiles for many pubkeys in one go: one IndexedDB batch read, one relay request for + * any missing. Deduplicates the input. Use when you have a list of visible pubkeys (e.g. from + * a feed) to avoid N separate profile fetches. + */ + async fetchProfilesForPubkeys(pubkeys: string[]): Promise { + const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64))) + if (deduped.length === 0) return [] + const events = await this.fetchReplaceableEventsFromBigRelays(deduped, kinds.Metadata) + const profiles: TProfile[] = [] + for (let i = 0; i < deduped.length; i++) { + const ev = events[i] + if (ev) { + this.addUsernameToIndex(ev) + profiles.push(getProfileFromEvent(ev)) + } else { + const pubkey = deduped[i]! + profiles.push({ + pubkey, + npub: pubkeyToNpub(pubkey) ?? '', + username: formatPubkey(pubkey) + }) + } + } + return profiles + } + + /** Read profile from IndexedDB only (no network). Use for fast avatar/profile display from cache. */ + async getProfileFromIndexedDB(id: string): Promise { + let pubkey: string | undefined + try { + if (/^[0-9a-f]{64}$/.test(id)) { + pubkey = id + } else { + const { data, type } = nip19.decode(id) + if (type === 'npub') pubkey = data + else if (type === 'nprofile') pubkey = data.pubkey + } + } catch { + return undefined + } + if (!pubkey) return undefined + const event = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) + if (!event || event === null) return undefined + return getProfileFromEvent(event) + } + async updateProfileEventCache(event: NEvent) { await this.updateReplaceableEventFromBigRelaysCache(event) } @@ -1785,7 +1813,6 @@ class ClientService extends EventTarget { * Fixes missing profile pics and broken reactions after "Clear cache" on mobile. */ clearInMemoryCaches(): void { - this.replaceableEventCacheMap.clear() this.relayListRequestCache.clear() this.eventDataLoader.clearAll() this.replaceableEventFromBigRelaysDataloader.clearAll() @@ -1822,7 +1849,6 @@ class ClientService extends EventTarget { }) throw error } finally { - // Remove from cache after completion (cache result in replaceableEventCacheMap) this.relayListRequestCache.delete(pubkey) } })()