From 3f050bb18a2418910cdafed390e981ee1ca50a00 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Mar 2026 10:38:00 +0100 Subject: [PATCH] bug-fixes --- .../Profile/ProfileInteractionsAccordion.tsx | 24 ++- src/hooks/useProfileBadges.tsx | 45 ++++- src/hooks/useProfileFollowPacks.tsx | 44 ++++- src/hooks/useProfileInteractions.tsx | 55 +++++- src/hooks/useProfileRelayUrls.tsx | 66 +++++-- src/hooks/useProfileReports.tsx | 38 ++++- src/lib/profile-accordion-session-cache.ts | 161 ++++++++++++++++++ src/services/indexed-db.service.ts | 12 +- 8 files changed, 393 insertions(+), 52 deletions(-) create mode 100644 src/lib/profile-accordion-session-cache.ts diff --git a/src/components/Profile/ProfileInteractionsAccordion.tsx b/src/components/Profile/ProfileInteractionsAccordion.tsx index 9d361e55..ae3d27d6 100644 --- a/src/components/Profile/ProfileInteractionsAccordion.tsx +++ b/src/components/Profile/ProfileInteractionsAccordion.tsx @@ -19,9 +19,15 @@ type Props = { onRefreshReady?: (refresh: (() => void) | null) => void } -function ProfileInteractionsContent({ pubkey, relayUrls, onRefreshReady }: { +function ProfileInteractionsContent({ + pubkey, + relayUrls, + refreshRelayUrls, + onRefreshReady +}: { pubkey: string relayUrls: string[] | undefined + refreshRelayUrls: () => void | Promise onRefreshReady?: (refresh: (() => void) | null) => void }) { const { pubkey: viewerPubkey } = useNostr() @@ -32,14 +38,17 @@ function ProfileInteractionsContent({ pubkey, relayUrls, onRefreshReady }: { useEffect(() => { const doRefresh = () => { - refresh() - refreshBadges() - refreshFollowPacks() - refreshReports() + void (async () => { + await refreshRelayUrls() + refresh() + refreshBadges() + refreshFollowPacks() + refreshReports() + })() } onRefreshReady?.(doRefresh) return () => { onRefreshReady?.(null) } - }, [refresh, refreshBadges, refreshFollowPacks, refreshReports, onRefreshReady]) + }, [refreshRelayUrls, refresh, refreshBadges, refreshFollowPacks, refreshReports, onRefreshReady]) return ( 0 ? relayUrls : undefined} + refreshRelayUrls={refreshRelayUrls} onRefreshReady={onRefreshReady} /> diff --git a/src/hooks/useProfileBadges.tsx b/src/hooks/useProfileBadges.tsx index 66b37eb8..288214ba 100644 --- a/src/hooks/useProfileBadges.tsx +++ b/src/hooks/useProfileBadges.tsx @@ -1,5 +1,11 @@ import { ExtendedKind } from '@/constants' import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media' +import { + profileAccordionGetCachedBadges, + profileAccordionInvalidate, + profileAccordionRelayUrlsKey, + profileAccordionSetBadges +} from '@/lib/profile-accordion-session-cache' import { queryService, replaceableEventService } from '@/services/client.service' import { useCallback, useEffect, useRef, useState } from 'react' import { tagNameEquals } from '@/lib/tag' @@ -38,18 +44,37 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ const [loading, setLoading] = useState(false) const fetchIdRef = useRef(0) - const fetchBadges = useCallback(async () => { + const fetchBadges = useCallback(async (force = false) => { + const myFetchId = (fetchIdRef.current += 1) + if (!pubkey) { - setBadges([]) + if (myFetchId === fetchIdRef.current) { + setBadges([]) + setLoading(false) + } return } - const myFetchId = (fetchIdRef.current += 1) + const urls = + force || !(relayUrls && relayUrls.length > 0) + ? await buildProfileRelayUrls(pubkey, blockedRelays) + : relayUrls + const relayKey = profileAccordionRelayUrlsKey(urls) + + if (!force) { + const cached = profileAccordionGetCachedBadges(pubkey, relayKey) + if (cached) { + if (myFetchId !== fetchIdRef.current) return + setBadges(cached) + setLoading(false) + return + } + } + + if (myFetchId !== fetchIdRef.current) return setLoading(true) try { - const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays)) - const events = await queryService.fetchEvents( urls, { authors: [pubkey], kinds: [ExtendedKind.PROFILE_BADGES], '#d': ['profile_badges'] }, @@ -112,6 +137,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ if (myFetchId !== fetchIdRef.current) return setBadges(result) + profileAccordionSetBadges(pubkey, relayKey, result) } catch { if (myFetchId !== fetchIdRef.current) return setBadges([]) @@ -120,9 +146,14 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ } }, [pubkey, blockedRelays, relayUrls]) + const refresh = useCallback(() => { + if (pubkey) profileAccordionInvalidate(pubkey, 'badges') + void fetchBadges(true) + }, [pubkey, fetchBadges]) + useEffect(() => { - fetchBadges() + void fetchBadges(false) }, [fetchBadges]) - return { badges, loading, refresh: fetchBadges } + return { badges, loading, refresh } } diff --git a/src/hooks/useProfileFollowPacks.tsx b/src/hooks/useProfileFollowPacks.tsx index 26bd8345..a9b2a522 100644 --- a/src/hooks/useProfileFollowPacks.tsx +++ b/src/hooks/useProfileFollowPacks.tsx @@ -1,4 +1,10 @@ import { ExtendedKind } from '@/constants' +import { + profileAccordionGetCachedFollowPacks, + profileAccordionInvalidate, + profileAccordionRelayUrlsKey, + profileAccordionSetFollowPacks +} from '@/lib/profile-accordion-session-cache' import { queryService } from '@/services/client.service' import { Event } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' @@ -25,17 +31,37 @@ export function useProfileFollowPacks( const [loading, setLoading] = useState(false) const fetchIdRef = useRef(0) - const fetchPacks = useCallback(async () => { + const fetchPacks = useCallback(async (force = false) => { + const myFetchId = (fetchIdRef.current += 1) + if (!pubkey) { - setPacks([]) + if (myFetchId === fetchIdRef.current) { + setPacks([]) + setLoading(false) + } return } - const myFetchId = (fetchIdRef.current += 1) + const urls = + force || !(relayUrls && relayUrls.length > 0) + ? await buildProfileRelayUrls(pubkey, blockedRelays) + : relayUrls + const relayKey = profileAccordionRelayUrlsKey(urls) + + if (!force && urls.length > 0) { + const cached = profileAccordionGetCachedFollowPacks(pubkey, relayKey) + if (cached) { + if (myFetchId !== fetchIdRef.current) return + setPacks(cached) + setLoading(false) + return + } + } + + if (myFetchId !== fetchIdRef.current) return setLoading(true) try { - const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays)) if (urls.length === 0) { if (myFetchId === fetchIdRef.current) setPacks([]) return @@ -54,6 +80,7 @@ export function useProfileFollowPacks( title: getPackTitle(evt) })) setPacks(result) + profileAccordionSetFollowPacks(pubkey, relayKey, result) } catch { if (myFetchId !== fetchIdRef.current) return setPacks([]) @@ -62,9 +89,14 @@ export function useProfileFollowPacks( } }, [pubkey, blockedRelays, relayUrls]) + const refresh = useCallback(() => { + if (pubkey) profileAccordionInvalidate(pubkey, 'followPacks') + void fetchPacks(true) + }, [pubkey, fetchPacks]) + useEffect(() => { - fetchPacks() + void fetchPacks(false) }, [fetchPacks]) - return { packs, loading, refresh: fetchPacks } + return { packs, loading, refresh } } diff --git a/src/hooks/useProfileInteractions.tsx b/src/hooks/useProfileInteractions.tsx index 01d80c88..c9f03318 100644 --- a/src/hooks/useProfileInteractions.tsx +++ b/src/hooks/useProfileInteractions.tsx @@ -4,6 +4,12 @@ import { queryService, replaceableEventService } from '@/services/client.service import { hexPubkeysEqual } from '@/lib/pubkey' import { Event, Filter, kinds } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' +import { + profileAccordionGetCachedInteractions, + profileAccordionInvalidate, + profileAccordionRelayUrlsKey, + profileAccordionSetInteractions +} from '@/lib/profile-accordion-session-cache' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' @@ -27,20 +33,41 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s const [loading, setLoading] = useState(false) const fetchIdRef = useRef(0) - const fetchAll = useCallback(async () => { + const fetchAll = useCallback(async (force = false) => { + const myFetchId = (fetchIdRef.current += 1) + if (!pubkey) { - setZaps([]) - setReactions([]) - setComments([]) + if (myFetchId === fetchIdRef.current) { + setZaps([]) + setReactions([]) + setComments([]) + setLoading(false) + } return } - const myFetchId = (fetchIdRef.current += 1) + const urls = + force || !(relayUrls && relayUrls.length > 0) + ? await buildProfileRelayUrls(pubkey, blockedRelays) + : relayUrls + const relayKey = profileAccordionRelayUrlsKey(urls) + + if (!force) { + const cached = profileAccordionGetCachedInteractions(pubkey, relayKey) + if (cached) { + if (myFetchId !== fetchIdRef.current) return + setZaps(cached.zaps) + setReactions(cached.reactions) + setComments(cached.comments) + setLoading(false) + return + } + } + + if (myFetchId !== fetchIdRef.current) return setLoading(true) try { - const urls = relayUrls ?? (await buildProfileRelayUrls(pubkey, blockedRelays)) - const profileMetaPromise = replaceableEventService.fetchReplaceableEvent( pubkey, kinds.Metadata, @@ -189,6 +216,11 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s setZaps(collectedZaps) setReactions(collectedReactions) setComments(collectedComments) + profileAccordionSetInteractions(pubkey, relayKey, { + zaps: collectedZaps, + reactions: collectedReactions, + comments: collectedComments + }) } catch { if (myFetchId !== fetchIdRef.current) return } finally { @@ -196,11 +228,16 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s } }, [pubkey, blockedRelays, relayUrls]) + const refresh = useCallback(() => { + if (pubkey) profileAccordionInvalidate(pubkey, 'interactions') + void fetchAll(true) + }, [pubkey, fetchAll]) + useEffect(() => { - fetchAll() + void fetchAll(false) }, [fetchAll]) - return { zaps, reactions, comments, loading, refresh: fetchAll } + return { zaps, reactions, comments, loading, refresh } } /** @deprecated Use useProfileInteractions instead. Returns zaps only for compatibility. */ diff --git a/src/hooks/useProfileRelayUrls.tsx b/src/hooks/useProfileRelayUrls.tsx index 2f4ca085..aa16cb40 100644 --- a/src/hooks/useProfileRelayUrls.tsx +++ b/src/hooks/useProfileRelayUrls.tsx @@ -1,3 +1,8 @@ +import { + profileAccordionGetCachedRelayUrls, + profileAccordionInvalidate, + profileAccordionSetRelayUrls +} from '@/lib/profile-accordion-session-cache' import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' import { useCallback, useEffect, useState } from 'react' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -8,26 +13,57 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean const [relayUrls, setRelayUrls] = useState([]) const [loading, setLoading] = useState(false) - const fetch = useCallback(async () => { - if (!pubkey || !enabled) { + const fetch = useCallback( + async (force = false) => { + if (!pubkey) { + setRelayUrls([]) + setLoading(false) + return + } + + if (!force) { + const cached = profileAccordionGetCachedRelayUrls(pubkey) + if (cached?.length) { + setRelayUrls(cached) + setLoading(false) + return + } + } + + setLoading(true) + try { + const urls = await buildProfileRelayUrls(pubkey, blockedRelays) + profileAccordionSetRelayUrls(pubkey, urls) + setRelayUrls(urls) + } catch { + setRelayUrls([]) + } finally { + setLoading(false) + } + }, + [pubkey, blockedRelays] + ) + + const refresh = useCallback(() => { + if (pubkey) profileAccordionInvalidate(pubkey, 'relayUrls') + if (!pubkey) return Promise.resolve() + return fetch(true) + }, [pubkey, fetch]) + + useEffect(() => { + if (!pubkey) { setRelayUrls([]) setLoading(false) return } - setLoading(true) - try { - const urls = await buildProfileRelayUrls(pubkey, blockedRelays) - setRelayUrls(urls) - } catch { - setRelayUrls([]) - } finally { + if (!enabled) { + const cached = profileAccordionGetCachedRelayUrls(pubkey) + setRelayUrls(cached ?? []) setLoading(false) + return } - }, [pubkey, enabled, blockedRelays]) - - useEffect(() => { - fetch() - }, [fetch]) + void fetch(false) + }, [pubkey, enabled, fetch]) - return { relayUrls, loading, refresh: fetch } + return { relayUrls, loading, refresh } } diff --git a/src/hooks/useProfileReports.tsx b/src/hooks/useProfileReports.tsx index c6eb220a..0e1d3164 100644 --- a/src/hooks/useProfileReports.tsx +++ b/src/hooks/useProfileReports.tsx @@ -1,5 +1,10 @@ import { ExtendedKind } from '@/constants' import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls' +import { + profileAccordionGetCachedReports, + profileAccordionInvalidate, + profileAccordionSetReports +} from '@/lib/profile-accordion-session-cache' import { queryService } from '@/services/client.service' import { Event } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' @@ -17,15 +22,29 @@ export function useProfileReports( const [loading, setLoading] = useState(false) const fetchIdRef = useRef(0) - const fetchReports = useCallback(async () => { + const fetchReports = useCallback(async (force = false) => { const viewer = viewerPubkey?.trim() + const myFetchId = (fetchIdRef.current += 1) + if (!profilePubkey || !viewer) { - setReports([]) - setLoading(false) + if (myFetchId === fetchIdRef.current) { + setReports([]) + setLoading(false) + } return } - const myFetchId = (fetchIdRef.current += 1) + if (!force) { + const cached = profileAccordionGetCachedReports(profilePubkey, viewer) + if (cached) { + if (myFetchId !== fetchIdRef.current) return + setReports(cached) + setLoading(false) + return + } + } + + if (myFetchId !== fetchIdRef.current) return setLoading(true) try { @@ -56,6 +75,7 @@ export function useProfileReports( } deduped.sort((a, b) => b.created_at - a.created_at) setReports(deduped) + profileAccordionSetReports(profilePubkey, viewer, deduped) } catch { if (myFetchId !== fetchIdRef.current) return setReports([]) @@ -64,9 +84,15 @@ export function useProfileReports( } }, [profilePubkey, viewerPubkey, favoriteRelays, blockedRelays]) + const refresh = useCallback(() => { + const v = viewerPubkey?.trim() + if (profilePubkey && v) profileAccordionInvalidate(profilePubkey, 'reports') + void fetchReports(true) + }, [profilePubkey, viewerPubkey, fetchReports]) + useEffect(() => { - fetchReports() + void fetchReports(false) }, [fetchReports]) - return { reports, loading, refresh: fetchReports } + return { reports, loading, refresh } } diff --git a/src/lib/profile-accordion-session-cache.ts b/src/lib/profile-accordion-session-cache.ts new file mode 100644 index 00000000..8d49cdf3 --- /dev/null +++ b/src/lib/profile-accordion-session-cache.ts @@ -0,0 +1,161 @@ +/** + * In-memory session cache for profile accordion fetches (per viewed profile pubkey). + * Survives collapsing/reopening the accordion; cleared on full page reload. + */ + +import type { TProfileZap } from '@/hooks/useProfileInteractions' +import type { TProfileBadge } from '@/hooks/useProfileBadges' +import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' +import type { Event } from 'nostr-tools' + +export type ProfileAccordionInteractionsSnapshot = { + zaps: TProfileZap[] + reactions: Event[] + comments: Event[] +} + +type Entry = { + relayUrls?: string[] + /** Fingerprint of relays used for interaction/badge/pack slices */ + relayUrlsKey?: string + interactions?: ProfileAccordionInteractionsSnapshot + badges?: TProfileBadge[] + followPacks?: TProfileFollowPack[] + /** viewer hex pubkey → reports */ + reportsByViewer?: Record +} + +const store = new Map() + +export function profileAccordionRelayUrlsKey(urls: string[]): string { + if (urls.length === 0) return '' + return [...urls].sort().join('|') +} + +function getEntry(pubkey: string): Entry { + let e = store.get(pubkey) + if (!e) { + e = {} + store.set(pubkey, e) + } + return e +} + +export function profileAccordionGetCachedRelayUrls(pubkey: string): string[] | undefined { + const urls = getEntry(pubkey).relayUrls + return urls?.length ? urls : undefined +} + +export function profileAccordionSetRelayUrls(pubkey: string, urls: string[]): void { + const e = getEntry(pubkey) + const key = profileAccordionRelayUrlsKey(urls) + if (e.relayUrlsKey && e.relayUrlsKey !== key) { + delete e.interactions + delete e.badges + delete e.followPacks + } + e.relayUrls = urls + e.relayUrlsKey = key +} + +export function profileAccordionGetCachedInteractions( + pubkey: string, + relayKey: string +): ProfileAccordionInteractionsSnapshot | undefined { + const e = store.get(pubkey) + if (!e?.interactions || e.relayUrlsKey !== relayKey) return undefined + return e.interactions +} + +export function profileAccordionSetInteractions( + pubkey: string, + relayKey: string, + data: ProfileAccordionInteractionsSnapshot +): void { + const e = getEntry(pubkey) + e.relayUrlsKey = relayKey + e.interactions = data +} + +export function profileAccordionGetCachedBadges(pubkey: string, relayKey: string): TProfileBadge[] | undefined { + const e = store.get(pubkey) + if (!e?.badges || e.relayUrlsKey !== relayKey) return undefined + return e.badges +} + +export function profileAccordionSetBadges(pubkey: string, relayKey: string, badges: TProfileBadge[]): void { + const e = getEntry(pubkey) + e.relayUrlsKey = relayKey + e.badges = badges +} + +export function profileAccordionGetCachedFollowPacks( + pubkey: string, + relayKey: string +): TProfileFollowPack[] | undefined { + const e = store.get(pubkey) + if (!e?.followPacks || e.relayUrlsKey !== relayKey) return undefined + return e.followPacks +} + +export function profileAccordionSetFollowPacks( + pubkey: string, + relayKey: string, + packs: TProfileFollowPack[] +): void { + const e = getEntry(pubkey) + e.relayUrlsKey = relayKey + e.followPacks = packs +} + +export function profileAccordionGetCachedReports(profilePubkey: string, viewerPubkey: string): Event[] | undefined { + return getEntry(profilePubkey).reportsByViewer?.[viewerPubkey] +} + +export function profileAccordionSetReports( + profilePubkey: string, + viewerPubkey: string, + reports: Event[] +): void { + const e = getEntry(profilePubkey) + if (!e.reportsByViewer) e.reportsByViewer = {} + e.reportsByViewer[viewerPubkey] = reports +} + +export type ProfileAccordionCacheSlice = + | 'relayUrls' + | 'interactions' + | 'badges' + | 'followPacks' + | 'reports' + | 'all' + +export function profileAccordionInvalidate(pubkey: string, slice: ProfileAccordionCacheSlice = 'all'): void { + if (slice === 'all') { + store.delete(pubkey) + return + } + const e = store.get(pubkey) + if (!e) return + switch (slice) { + case 'relayUrls': + delete e.relayUrls + delete e.relayUrlsKey + delete e.interactions + delete e.badges + delete e.followPacks + break + case 'interactions': + delete e.interactions + break + case 'badges': + delete e.badges + break + case 'followPacks': + delete e.followPacks + break + case 'reports': + delete e.reportsByViewer + break + } +} diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index d44558de..3fbe2bbd 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -48,11 +48,13 @@ export const StoreNames = { /** NIP-A7 spell events (kind 777). Key: event id. */ SPELL_EVENTS: 'spellEvents', /** Tombstone list for deleted events (kind 5). Key: event id or replaceable coordinate. */ - TOMBSTONE_LIST: 'tombstoneList' + TOMBSTONE_LIST: 'tombstoneList', + /** NIP-58 badge definitions (kind 30009). Key: pubkey:d */ + BADGE_DEFINITION_EVENTS: 'badgeDefinitionEvents' } /** Schema version we expect. When adding stores or migrations, bump this. */ -const DB_VERSION = 27 +const DB_VERSION = 28 /** Max age for profile and payment info cache before we refetch (5 min). */ const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 @@ -230,6 +232,9 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.TOMBSTONE_LIST)) { db.createObjectStore(StoreNames.TOMBSTONE_LIST, { keyPath: 'key' }) } + if (!db.objectStoreNames.contains(StoreNames.BADGE_DEFINITION_EVENTS)) { + db.createObjectStore(StoreNames.BADGE_DEFINITION_EVENTS, { keyPath: 'key' }) + } } } ); @@ -841,6 +846,8 @@ class IndexedDbService { case ExtendedKind.WIKI_ARTICLE: case kinds.LongFormArticle: return StoreNames.PUBLICATION_EVENTS + case ExtendedKind.BADGE_DEFINITION: + return StoreNames.BADGE_DEFINITION_EVENTS default: return undefined } @@ -1458,6 +1465,7 @@ class IndexedDbService { if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets if (storeName === StoreNames.PAYMENT_INFO_EVENTS) return ExtendedKind.PAYMENT_INFO + if (storeName === StoreNames.BADGE_DEFINITION_EVENTS) return ExtendedKind.BADGE_DEFINITION // PUBLICATION_EVENTS is not replaceable, so we don't handle it here return undefined }