diff --git a/src/components/Profile/ProfileBadges.tsx b/src/components/Profile/ProfileBadges.tsx index 4396cdd5..2c642f25 100644 --- a/src/components/Profile/ProfileBadges.tsx +++ b/src/components/Profile/ProfileBadges.tsx @@ -5,13 +5,23 @@ import { useTranslation } from 'react-i18next' export default function ProfileBadges({ pubkey, - profileEventId + profileEventId, + onRefresh }: { pubkey: string profileEventId?: string + /** Full author replaceables refresh (profile, payment, badges from relays). */ + onRefresh?: () => void | Promise }) { const { t } = useTranslation() const { badges, isLoading, refresh } = useProfileWall(pubkey, profileEventId) + const handleRefresh = () => { + if (onRefresh) { + void onRefresh() + return + } + refresh() + } if (isLoading && badges.length === 0) { return ( @@ -27,7 +37,7 @@ export default function ProfileBadges({ return (
- +
{badges.map((badge) => ( diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx index 0f8d0fe2..99e2b014 100644 --- a/src/components/Profile/ProfileFeed.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -15,7 +15,10 @@ import { useTranslation } from 'react-i18next' const profileFeedKinds = [...PROFILE_FEED_KINDS] -const ProfileFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { +const ProfileFeed = forwardRef< + { refresh: () => void }, + { pubkey: string; /** Payment methods, badges, and other author replaceables. */ onRefreshExtras?: () => void } +>(({ pubkey, onRefreshExtras }, ref) => { const { t } = useTranslation() const { isEventDeleted } = useDeletedEvent() const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = @@ -55,7 +58,8 @@ const ProfileFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ p refreshAuthorRelayLayers() noteListRef.current?.refresh() void client.fetchDeletionEventsForPubkey(pubkey) - }, [refreshPins, refreshAuthorRelayLayers, pubkey]) + onRefreshExtras?.() + }, [refreshPins, refreshAuthorRelayLayers, pubkey, onRefreshExtras]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 22e38134..7b4923db 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -12,6 +12,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' +import { requestProfileWallRefresh } from '@/hooks/useProfileWall' import { kinds, type NostrEvent } from 'nostr-tools' import { createReactionDraftEvent } from '@/lib/draft-event' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' @@ -80,6 +81,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { nip66Service } from '@/services/nip66.service' import PaymentMethodsSection from '@/components/PaymentMethodsSection' import { buildRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' +import { loadAuthorReplaceablesFromLocalCache } from '@/lib/profile-author-replaceables-cache' import ZapDialog from '@/components/ZapDialog' import { groupPaymentMethodsByDisplayType, @@ -184,14 +186,33 @@ export default function Profile({ setProfileEvent(undefined) return } + let cancelled = false + void loadAuthorReplaceablesFromLocalCache(profile.pubkey).then(({ paymentInfo: pi, profileEvent: pe }) => { + if (cancelled) return + setPaymentInfo(pi) + setProfileEvent(pe) + }) void syncAuthorReplaceablesFromCache(profile.pubkey) + return () => { + cancelled = true + } }, [profile?.pubkey, syncAuthorReplaceablesFromCache]) const refreshAuthorReplaceables = useCallback(async (pubkey: string) => { - await client.forceRefreshProfileAndPaymentInfoCache(pubkey) - await syncAuthorReplaceablesFromCache(pubkey) + requestProfileWallRefresh(pubkey) + try { + await client.forceRefreshProfileAndPaymentInfoCache(pubkey) + await syncAuthorReplaceablesFromCache(pubkey, { bustCache: true }) + } catch (error) { + logger.error('Failed to refresh author replaceables', { error, pubkey }) + } }, [syncAuthorReplaceablesFromCache]) + const refreshAuthorExtrasForCurrentProfile = useCallback(() => { + const pk = profilePubkeyRef.current + if (pk) void refreshAuthorReplaceables(pk) + }, [refreshAuthorReplaceables]) + useEffect(() => { if (!profile?.pubkey || profile.batchPlaceholder) return const pk = profile.pubkey @@ -216,7 +237,7 @@ export default function Profile({ const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => { const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase() if (detailPk !== pk) return - void syncAuthorReplaceablesFromCache(profile.pubkey) + void syncAuthorReplaceablesFromCache(profile.pubkey, { bustCache: true }) } window.addEventListener( ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, @@ -294,18 +315,13 @@ export default function Profile({ if (typeof r === 'function') return const m = r as MutableRefObject<{ refresh: () => void } | null> m.current = { - refresh: () => { - internalFeedRef.current?.refresh() - const pk = profilePubkeyRef.current - if (pk) { - void refreshAuthorReplaceables(pk) - } - } + // ProfileFeed.refresh already runs onRefreshExtras (payment + badges). + refresh: () => internalFeedRef.current?.refresh() } return () => { m.current = null } - }, [refreshAuthorReplaceables]) + }, []) if (!profile && isFetching) { return ( @@ -596,11 +612,15 @@ export default function Profile({ {isSelf && }
- + - + () +const wallRefreshListenersByPubkey = new Map void>>() + +function normalizeWallRefreshPubkey(pubkey: string): string | null { + const pk = (userIdToPubkey(pubkey) || pubkey).trim().toLowerCase() + return /^[0-9a-f]{64}$/.test(pk) ? pk : null +} + +/** Invalidate in-memory wall cache and schedule a badge re-fetch (avoids sync window events during React updates). */ +export function requestProfileWallRefresh(pubkey: string): void { + const pk = normalizeWallRefreshPubkey(pubkey) + if (!pk) return + const listeners = wallRefreshListenersByPubkey.get(pk) + if (!listeners?.size) return + for (const listener of listeners) listener() +} + function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') @@ -97,6 +115,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine cached.badges.length > 0 && Date.now() - cached.lastUpdated < CACHE_DURATION + const pkNormForHydrate = useMemo(() => userIdToPubkey(pubkey) || pubkey, [pubkey]) const [badges, setBadges] = useState( hasUsefulWallCache ? cached!.badges : [] ) @@ -115,6 +134,75 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap) useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap const runGenRef = useRef(0) + const manualRefreshBumpScheduledRef = useRef(false) + const relayListsKeyRef = useRef(relayListsKey) + + const bumpWallRefetch = useCallback(() => { + wallCacheByKey.delete(cacheKey) + queueMicrotask(() => { + setIsLoading(true) + setRefreshToken((t) => t + 1) + }) + }, [cacheKey]) + + const scheduleManualWallRefetch = useCallback(() => { + if (manualRefreshBumpScheduledRef.current) return + manualRefreshBumpScheduledRef.current = true + wallCacheByKey.delete(cacheKey) + queueMicrotask(() => { + manualRefreshBumpScheduledRef.current = false + setIsLoading(true) + setRefreshToken((t) => t + 1) + }) + }, [cacheKey]) + + useEffect(() => { + if (!isValidPubkey(pkNormForHydrate)) return + let cancelled = false + void hydrateProfileBadgesFromLocalCache(pkNormForHydrate).then((local) => { + if (cancelled || local.length === 0) return + setBadges((prev) => (prev.length > 0 ? prev : local)) + setIsLoading(false) + }) + return () => { + cancelled = true + } + }, [pkNormForHydrate]) + + useEffect(() => { + const pk = normalizeWallRefreshPubkey(pkNormForHydrate) + if (!pk) return + + const listeners = wallRefreshListenersByPubkey.get(pk) ?? new Set() + listeners.add(scheduleManualWallRefetch) + wallRefreshListenersByPubkey.set(pk, listeners) + + const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => { + const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase() + if (detailPk !== pk) return + bumpWallRefetch() + } + window.addEventListener( + ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, + onAuthorReplaceablesRefreshed + ) + return () => { + listeners.delete(scheduleManualWallRefetch) + if (listeners.size === 0) { + wallRefreshListenersByPubkey.delete(pk) + } + window.removeEventListener( + ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, + onAuthorReplaceablesRefreshed + ) + } + }, [pkNormForHydrate, scheduleManualWallRefetch, bumpWallRefetch]) + + useEffect(() => { + if (relayListsKeyRef.current === relayListsKey) return + relayListsKeyRef.current = relayListsKey + bumpWallRefetch() + }, [relayListsKey, bumpWallRefetch]) useEffect(() => { let cancelled = false @@ -129,16 +217,17 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine Date.now() - mem.lastUpdated < CACHE_DURATION && refreshToken === 0 ) { - setBadges(mem.badges) - setComments(mem.comments) - if (runGen === runGenRef.current) setIsLoading(false) + if (runGen === runGenRef.current) { + setBadges((prev) => (prev === mem.badges ? prev : mem.badges)) + setComments((prev) => (prev === mem.comments ? prev : mem.comments)) + setIsLoading((prev) => (prev ? false : prev)) + } return } if (mem?.badges.length === 0) { wallCacheByKey.delete(cacheKey) } - setIsLoading(true) try { const pkNorm = userIdToPubkey(pubkey) || pubkey if (!isValidPubkey(pkNorm)) { @@ -164,10 +253,23 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine useGlobalRelayBootstrapRef.current ) - // --- Badges (NIP-58): IndexedDB + profile read relays (favorites / fast-read), not inbox-only --- - let listEvent = await fetchProfileBadgesListEvent(pkNorm, relayUrls, { foreground: true }) + const localBadges = await hydrateProfileBadgesFromLocalCache(pkNorm) + if (!cancelled && localBadges.length > 0) { + setBadges(localBadges) + setIsLoading(false) + } else if (!cancelled) { + setIsLoading(true) + } + + // --- Badges (NIP-58): show cache first; relay refresh may upgrade list/definitions --- + let listEvent = await fetchProfileBadgesListEvent(pkNorm, relayUrls, { + foreground: true, + cacheFirst: false + }) if (!listEvent || !isNip58ProfileBadgesListEvent(listEvent)) { - const legacy = await fetchLegacyProfileBadgesListEvent(pkNorm, relayUrls) + const legacy = await fetchLegacyProfileBadgesListEvent(pkNorm, relayUrls, { + cacheFirst: false + }) if (legacy && isNip58ProfileBadgesListEvent(legacy)) listEvent = legacy } @@ -185,7 +287,13 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine resolveBadgeDisplayFromDefinition(entry, defByCoord.get(entry.definitionCoordinate)) ) - // --- Wall comments (kind 1111 on profile kind 0) --- + if (cancelled) return + if (resolvedBadges.length > 0 || localBadges.length === 0) { + setBadges(resolvedBadges) + } + setIsLoading(false) + + // --- Wall comments (kind 1111): after badges so payment UI is not blocked --- let wallComments: Event[] = [] const profileId = profileEventId?.trim().toLowerCase() if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) { @@ -223,7 +331,6 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine } if (cancelled) return - setBadges(resolvedBadges) setComments(wallComments) if (resolvedBadges.length > 0 || wallComments.length > 0) { wallCacheByKey.set(cacheKey, { @@ -244,13 +351,11 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine return () => { cancelled = true } - }, [pubkey, profileEventId, cacheKey, refreshToken, relayListsKey]) + }, [pubkey, profileEventId, cacheKey, refreshToken]) const refresh = useCallback(() => { - wallCacheByKey.delete(cacheKey) - setIsLoading(true) - setRefreshToken((t) => t + 1) - }, [cacheKey]) + scheduleManualWallRefetch() + }, [scheduleManualWallRefetch]) return { badges, comments, isLoading, refresh } } diff --git a/src/lib/nip58-profile-badges-list.ts b/src/lib/nip58-profile-badges-list.ts index d92d8de1..a6ecce27 100644 --- a/src/lib/nip58-profile-badges-list.ts +++ b/src/lib/nip58-profile-badges-list.ts @@ -6,8 +6,11 @@ import { import { isNip58ProfileBadgesListEvent, LEGACY_PROFILE_BADGES_D_TAG, + parseAddressableCoordinate, parseProfileBadgeEntries, - type ProfileBadgeEntry + resolveBadgeDisplayFromDefinition, + type ProfileBadgeEntry, + type ResolvedProfileBadge } from '@/lib/nip58-profile-badges' import { normalizeHexPubkey } from '@/lib/pubkey' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' @@ -41,13 +44,8 @@ export function profileBadgeListTagsAfterRemovingEntry( return profileBadgeEntriesToTags(next) } -export async function fetchProfileBadgesListEvent( - pubkeyHex: string, - relayUrls: string[], - options?: { foreground?: boolean } -): Promise { +async function loadProfileBadgesListFromLocalCache(pubkeyHex: string): Promise { const pk = normalizeHexPubkey(pubkeyHex) - const foreground = options?.foreground === true let cached: Event | undefined try { const disk = await indexedDb.getReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST) @@ -55,6 +53,70 @@ export async function fetchProfileBadgesListEvent( } catch { cached = undefined } + const sessionHits = client.eventService.listSessionEventsAuthoredBy(pk, { + kinds: [ExtendedKind.PROFILE_BADGES_LIST], + limit: 8 + }) + for (const ev of sessionHits) { + if (!isNip58ProfileBadgesListEvent(ev)) continue + if (!cached || ev.created_at >= cached.created_at) cached = ev + } + if (cached && isNip58ProfileBadgesListEvent(cached)) return cached + + try { + const legacy = + (await indexedDb.getReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES, LEGACY_PROFILE_BADGES_D_TAG)) ?? + undefined + if (legacy && isNip58ProfileBadgesListEvent(legacy)) return legacy + } catch { + /* best-effort */ + } + return undefined +} + +async function loadBadgeDefinitionFromLocalCache(coordinate: string): Promise { + const parsed = parseAddressableCoordinate(coordinate) + if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) return undefined + try { + const disk = await indexedDb.getReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d) + if (disk) return disk + } catch { + /* best-effort */ + } + return undefined +} + +/** Resolve NIP-58 badges from IndexedDB/session only (no relay REQ). */ +export async function hydrateProfileBadgesFromLocalCache( + pubkeyHex: string +): Promise { + let listEvent = await loadProfileBadgesListFromLocalCache(pubkeyHex) + if (!listEvent || !isNip58ProfileBadgesListEvent(listEvent)) return [] + const entries = parseProfileBadgeEntries(listEvent) + const defCoords = [...new Set(entries.map((e) => e.definitionCoordinate))] + const defByCoord = new Map() + await Promise.all( + defCoords.map(async (coord) => { + defByCoord.set(coord, await loadBadgeDefinitionFromLocalCache(coord)) + }) + ) + return entries.map((entry) => + resolveBadgeDisplayFromDefinition(entry, defByCoord.get(entry.definitionCoordinate)) + ) +} + +export async function fetchProfileBadgesListEvent( + pubkeyHex: string, + relayUrls: string[], + options?: { foreground?: boolean; /** When true and local cache exists, return cache immediately and skip relay wait. */ cacheFirst?: boolean } +): Promise { + const pk = normalizeHexPubkey(pubkeyHex) + const foreground = options?.foreground === true + const cacheFirst = options?.cacheFirst !== false + let cached = await loadProfileBadgesListFromLocalCache(pk) + if (cacheFirst && cached) { + return cached + } try { const fromService = (await replaceableEventService.fetchReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST)) ?? @@ -77,20 +139,37 @@ export async function fetchProfileBadgesListEvent( /** Deprecated NIP-58 profile badges (kind 30008, d=profile_badges). */ export async function fetchLegacyProfileBadgesListEvent( pubkeyHex: string, - relayUrls: string[] + relayUrls: string[], + options?: { cacheFirst?: boolean } ): Promise { const pk = normalizeHexPubkey(pubkeyHex) + const cacheFirst = options?.cacheFirst !== false let cached: Event | undefined - try { - cached = - (await replaceableEventService.fetchReplaceableEvent( + if (cacheFirst) { + try { + const legacyDisk = await indexedDb.getReplaceableEvent( pk, ExtendedKind.PROFILE_BADGES, LEGACY_PROFILE_BADGES_D_TAG - )) ?? undefined - } catch { - cached = undefined + ) + if (legacyDisk && isNip58ProfileBadgesListEvent(legacyDisk)) cached = legacyDisk + } catch { + cached = undefined + } + } + if (!cached) { + try { + cached = + (await replaceableEventService.fetchReplaceableEvent( + pk, + ExtendedKind.PROFILE_BADGES, + LEGACY_PROFILE_BADGES_D_TAG + )) ?? undefined + } catch { + cached = undefined + } } + if (cacheFirst && cached) return cached const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] if (!allUrls.length) return cached diff --git a/src/lib/profile-author-replaceables-cache.ts b/src/lib/profile-author-replaceables-cache.ts new file mode 100644 index 00000000..4af6f928 --- /dev/null +++ b/src/lib/profile-author-replaceables-cache.ts @@ -0,0 +1,38 @@ +import { ExtendedKind } from '@/constants' +import { getPaymentInfoFromEvent } from '@/lib/event-metadata' +import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import client from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import { kinds, type Event } from 'nostr-tools' + +function pickNewestEvent(...candidates: (Event | undefined | null)[]): Event | undefined { + let best: Event | undefined + for (const e of candidates) { + if (!e || shouldDropEventOnIngest(e)) continue + if (!best || e.created_at >= best.created_at) best = e + } + return best +} + +/** IndexedDB + session only — no relay round-trip (for instant profile payment UI). */ +export async function loadAuthorReplaceablesFromLocalCache(pubkey: string): Promise<{ + paymentInfo: ReturnType | null + profileEvent: Event | undefined +}> { + const pk = pubkey.trim().toLowerCase() + const [idbPayment, idbMeta] = await Promise.all([ + indexedDb.getReplaceableEvent(pk, ExtendedKind.PAYMENT_INFO).catch(() => undefined), + indexedDb.getReplaceableEvent(pk, kinds.Metadata).catch(() => undefined) + ]) + const sesPayment = client.eventService.listSessionEventsAuthoredBy(pk, { + kinds: [ExtendedKind.PAYMENT_INFO], + limit: 8 + })[0] + const sesMeta = client.eventService.getSessionMetadataForPubkey(pk) + const paymentEvent = pickNewestEvent(idbPayment, sesPayment) + const profileEvent = pickNewestEvent(idbMeta, sesMeta) + return { + paymentInfo: paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null, + profileEvent + } +}