Browse Source

get payment methods and badges displayed faster

imwald
Silberengel 4 weeks ago
parent
commit
9a197f9bf2
  1. 14
      src/components/Profile/ProfileBadges.tsx
  2. 8
      src/components/Profile/ProfileFeed.tsx
  3. 44
      src/components/Profile/index.tsx
  4. 135
      src/hooks/useProfileWall.tsx
  5. 95
      src/lib/nip58-profile-badges-list.ts
  6. 38
      src/lib/profile-author-replaceables-cache.ts

14
src/components/Profile/ProfileBadges.tsx

@ -5,13 +5,23 @@ import { useTranslation } from 'react-i18next'
export default function ProfileBadges({ export default function ProfileBadges({
pubkey, pubkey,
profileEventId profileEventId,
onRefresh
}: { }: {
pubkey: string pubkey: string
profileEventId?: string profileEventId?: string
/** Full author replaceables refresh (profile, payment, badges from relays). */
onRefresh?: () => void | Promise<void>
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { badges, isLoading, refresh } = useProfileWall(pubkey, profileEventId) const { badges, isLoading, refresh } = useProfileWall(pubkey, profileEventId)
const handleRefresh = () => {
if (onRefresh) {
void onRefresh()
return
}
refresh()
}
if (isLoading && badges.length === 0) { if (isLoading && badges.length === 0) {
return ( return (
@ -27,7 +37,7 @@ export default function ProfileBadges({
return ( return (
<section className="mt-3 min-w-0" aria-label={t('Badges')}> <section className="mt-3 min-w-0" aria-label={t('Badges')}>
<div className="mb-1 flex items-center justify-end gap-2"> <div className="mb-1 flex items-center justify-end gap-2">
<RefreshButton onClick={refresh} onLongPress={null} /> <RefreshButton onClick={handleRefresh} onLongPress={null} />
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{badges.map((badge) => ( {badges.map((badge) => (

8
src/components/Profile/ProfileFeed.tsx

@ -15,7 +15,10 @@ import { useTranslation } from 'react-i18next'
const profileFeedKinds = [...PROFILE_FEED_KINDS] 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 { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } = const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } =
@ -55,7 +58,8 @@ const ProfileFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ p
refreshAuthorRelayLayers() refreshAuthorRelayLayers()
noteListRef.current?.refresh() noteListRef.current?.refresh()
void client.fetchDeletionEventsForPubkey(pubkey) void client.fetchDeletionEventsForPubkey(pubkey)
}, [refreshPins, refreshAuthorRelayLayers, pubkey]) onRefreshExtras?.()
}, [refreshPins, refreshAuthorRelayLayers, pubkey, onRefreshExtras])
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll])

44
src/components/Profile/index.tsx

@ -12,6 +12,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { requestProfileWallRefresh } from '@/hooks/useProfileWall'
import { kinds, type NostrEvent } from 'nostr-tools' import { kinds, type NostrEvent } from 'nostr-tools'
import { createReactionDraftEvent } from '@/lib/draft-event' import { createReactionDraftEvent } from '@/lib/draft-event'
import { getPaymentInfoFromEvent } from '@/lib/event-metadata' 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 { nip66Service } from '@/services/nip66.service'
import PaymentMethodsSection from '@/components/PaymentMethodsSection' import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { buildRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' import { buildRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments'
import { loadAuthorReplaceablesFromLocalCache } from '@/lib/profile-author-replaceables-cache'
import ZapDialog from '@/components/ZapDialog' import ZapDialog from '@/components/ZapDialog'
import { import {
groupPaymentMethodsByDisplayType, groupPaymentMethodsByDisplayType,
@ -184,14 +186,33 @@ export default function Profile({
setProfileEvent(undefined) setProfileEvent(undefined)
return return
} }
let cancelled = false
void loadAuthorReplaceablesFromLocalCache(profile.pubkey).then(({ paymentInfo: pi, profileEvent: pe }) => {
if (cancelled) return
setPaymentInfo(pi)
setProfileEvent(pe)
})
void syncAuthorReplaceablesFromCache(profile.pubkey) void syncAuthorReplaceablesFromCache(profile.pubkey)
return () => {
cancelled = true
}
}, [profile?.pubkey, syncAuthorReplaceablesFromCache]) }, [profile?.pubkey, syncAuthorReplaceablesFromCache])
const refreshAuthorReplaceables = useCallback(async (pubkey: string) => { const refreshAuthorReplaceables = useCallback(async (pubkey: string) => {
requestProfileWallRefresh(pubkey)
try {
await client.forceRefreshProfileAndPaymentInfoCache(pubkey) await client.forceRefreshProfileAndPaymentInfoCache(pubkey)
await syncAuthorReplaceablesFromCache(pubkey) await syncAuthorReplaceablesFromCache(pubkey, { bustCache: true })
} catch (error) {
logger.error('Failed to refresh author replaceables', { error, pubkey })
}
}, [syncAuthorReplaceablesFromCache]) }, [syncAuthorReplaceablesFromCache])
const refreshAuthorExtrasForCurrentProfile = useCallback(() => {
const pk = profilePubkeyRef.current
if (pk) void refreshAuthorReplaceables(pk)
}, [refreshAuthorReplaceables])
useEffect(() => { useEffect(() => {
if (!profile?.pubkey || profile.batchPlaceholder) return if (!profile?.pubkey || profile.batchPlaceholder) return
const pk = profile.pubkey const pk = profile.pubkey
@ -216,7 +237,7 @@ export default function Profile({
const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => { const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => {
const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase() const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase()
if (detailPk !== pk) return if (detailPk !== pk) return
void syncAuthorReplaceablesFromCache(profile.pubkey) void syncAuthorReplaceablesFromCache(profile.pubkey, { bustCache: true })
} }
window.addEventListener( window.addEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
@ -294,18 +315,13 @@ export default function Profile({
if (typeof r === 'function') return if (typeof r === 'function') return
const m = r as MutableRefObject<{ refresh: () => void } | null> const m = r as MutableRefObject<{ refresh: () => void } | null>
m.current = { m.current = {
refresh: () => { // ProfileFeed.refresh already runs onRefreshExtras (payment + badges).
internalFeedRef.current?.refresh() refresh: () => internalFeedRef.current?.refresh()
const pk = profilePubkeyRef.current
if (pk) {
void refreshAuthorReplaceables(pk)
}
}
} }
return () => { return () => {
m.current = null m.current = null
} }
}, [refreshAuthorReplaceables]) }, [])
if (!profile && isFetching) { if (!profile && isFetching) {
return ( return (
@ -596,11 +612,15 @@ export default function Profile({
<SmartRelays pubkey={pubkey} /> <SmartRelays pubkey={pubkey} />
{isSelf && <SmartMuteLink />} {isSelf && <SmartMuteLink />}
</div> </div>
<ProfileBadges pubkey={pubkey} profileEventId={effectiveProfileEvent?.id} /> <ProfileBadges
pubkey={pubkey}
profileEventId={effectiveProfileEvent?.id}
onRefresh={refreshAuthorExtrasForCurrentProfile}
/>
</div> </div>
</div> </div>
</div> </div>
<ProfileFeed ref={profileFeedRef} pubkey={pubkey} /> <ProfileFeed ref={profileFeedRef} pubkey={pubkey} onRefreshExtras={refreshAuthorExtrasForCurrentProfile} />
<ProfileReportsDialog <ProfileReportsDialog
open={openReportsDialog} open={openReportsDialog}
onOpenChange={setOpenReportsDialog} onOpenChange={setOpenReportsDialog}

135
src/hooks/useProfileWall.tsx

@ -8,7 +8,8 @@ import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { getReplaceableCoordinate } from '@/lib/event' import { getReplaceableCoordinate } from '@/lib/event'
import { import {
fetchLegacyProfileBadgesListEvent, fetchLegacyProfileBadgesListEvent,
fetchProfileBadgesListEvent fetchProfileBadgesListEvent,
hydrateProfileBadgesFromLocalCache
} from '@/lib/nip58-profile-badges-list' } from '@/lib/nip58-profile-badges-list'
import { import {
isNip58ProfileBadgesListEvent, isNip58ProfileBadgesListEvent,
@ -23,6 +24,7 @@ import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client, { replaceableEventService } from '@/services/client.service' import client, { replaceableEventService } from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event, kinds, type Filter } from 'nostr-tools' import { Event, kinds, type Filter } from 'nostr-tools'
@ -77,6 +79,22 @@ async function fetchBadgeDefinitionOnRelays(
const CACHE_DURATION = 5 * 60 * 1000 const CACHE_DURATION = 5 * 60 * 1000
const wallCacheByKey = new Map<string, { badges: ResolvedProfileBadge[]; comments: Event[]; lastUpdated: number }>() const wallCacheByKey = new Map<string, { badges: ResolvedProfileBadge[]; comments: Event[]; lastUpdated: number }>()
const wallRefreshListenersByPubkey = new Map<string, Set<() => 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 { function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string {
const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') 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') 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 && cached.badges.length > 0 &&
Date.now() - cached.lastUpdated < CACHE_DURATION Date.now() - cached.lastUpdated < CACHE_DURATION
const pkNormForHydrate = useMemo(() => userIdToPubkey(pubkey) || pubkey, [pubkey])
const [badges, setBadges] = useState<ResolvedProfileBadge[]>( const [badges, setBadges] = useState<ResolvedProfileBadge[]>(
hasUsefulWallCache ? cached!.badges : [] hasUsefulWallCache ? cached!.badges : []
) )
@ -115,6 +134,75 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap) const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap)
useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap
const runGenRef = useRef(0) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -129,16 +217,17 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
Date.now() - mem.lastUpdated < CACHE_DURATION && Date.now() - mem.lastUpdated < CACHE_DURATION &&
refreshToken === 0 refreshToken === 0
) { ) {
setBadges(mem.badges) if (runGen === runGenRef.current) {
setComments(mem.comments) setBadges((prev) => (prev === mem.badges ? prev : mem.badges))
if (runGen === runGenRef.current) setIsLoading(false) setComments((prev) => (prev === mem.comments ? prev : mem.comments))
setIsLoading((prev) => (prev ? false : prev))
}
return return
} }
if (mem?.badges.length === 0) { if (mem?.badges.length === 0) {
wallCacheByKey.delete(cacheKey) wallCacheByKey.delete(cacheKey)
} }
setIsLoading(true)
try { try {
const pkNorm = userIdToPubkey(pubkey) || pubkey const pkNorm = userIdToPubkey(pubkey) || pubkey
if (!isValidPubkey(pkNorm)) { if (!isValidPubkey(pkNorm)) {
@ -164,10 +253,23 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
useGlobalRelayBootstrapRef.current useGlobalRelayBootstrapRef.current
) )
// --- Badges (NIP-58): IndexedDB + profile read relays (favorites / fast-read), not inbox-only --- const localBadges = await hydrateProfileBadgesFromLocalCache(pkNorm)
let listEvent = await fetchProfileBadgesListEvent(pkNorm, relayUrls, { foreground: true }) 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)) { if (!listEvent || !isNip58ProfileBadgesListEvent(listEvent)) {
const legacy = await fetchLegacyProfileBadgesListEvent(pkNorm, relayUrls) const legacy = await fetchLegacyProfileBadgesListEvent(pkNorm, relayUrls, {
cacheFirst: false
})
if (legacy && isNip58ProfileBadgesListEvent(legacy)) listEvent = legacy if (legacy && isNip58ProfileBadgesListEvent(legacy)) listEvent = legacy
} }
@ -185,7 +287,13 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
resolveBadgeDisplayFromDefinition(entry, defByCoord.get(entry.definitionCoordinate)) 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[] = [] let wallComments: Event[] = []
const profileId = profileEventId?.trim().toLowerCase() const profileId = profileEventId?.trim().toLowerCase()
if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) { 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 if (cancelled) return
setBadges(resolvedBadges)
setComments(wallComments) setComments(wallComments)
if (resolvedBadges.length > 0 || wallComments.length > 0) { if (resolvedBadges.length > 0 || wallComments.length > 0) {
wallCacheByKey.set(cacheKey, { wallCacheByKey.set(cacheKey, {
@ -244,13 +351,11 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
return () => { return () => {
cancelled = true cancelled = true
} }
}, [pubkey, profileEventId, cacheKey, refreshToken, relayListsKey]) }, [pubkey, profileEventId, cacheKey, refreshToken])
const refresh = useCallback(() => { const refresh = useCallback(() => {
wallCacheByKey.delete(cacheKey) scheduleManualWallRefetch()
setIsLoading(true) }, [scheduleManualWallRefetch])
setRefreshToken((t) => t + 1)
}, [cacheKey])
return { badges, comments, isLoading, refresh } return { badges, comments, isLoading, refresh }
} }

95
src/lib/nip58-profile-badges-list.ts

@ -6,8 +6,11 @@ import {
import { import {
isNip58ProfileBadgesListEvent, isNip58ProfileBadgesListEvent,
LEGACY_PROFILE_BADGES_D_TAG, LEGACY_PROFILE_BADGES_D_TAG,
parseAddressableCoordinate,
parseProfileBadgeEntries, parseProfileBadgeEntries,
type ProfileBadgeEntry resolveBadgeDisplayFromDefinition,
type ProfileBadgeEntry,
type ResolvedProfileBadge
} from '@/lib/nip58-profile-badges' } from '@/lib/nip58-profile-badges'
import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeHexPubkey } from '@/lib/pubkey'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
@ -41,13 +44,8 @@ export function profileBadgeListTagsAfterRemovingEntry(
return profileBadgeEntriesToTags(next) return profileBadgeEntriesToTags(next)
} }
export async function fetchProfileBadgesListEvent( async function loadProfileBadgesListFromLocalCache(pubkeyHex: string): Promise<Event | undefined> {
pubkeyHex: string,
relayUrls: string[],
options?: { foreground?: boolean }
): Promise<Event | undefined> {
const pk = normalizeHexPubkey(pubkeyHex) const pk = normalizeHexPubkey(pubkeyHex)
const foreground = options?.foreground === true
let cached: Event | undefined let cached: Event | undefined
try { try {
const disk = await indexedDb.getReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST) const disk = await indexedDb.getReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST)
@ -55,6 +53,70 @@ export async function fetchProfileBadgesListEvent(
} catch { } catch {
cached = undefined 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<Event | undefined> {
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<ResolvedProfileBadge[]> {
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<string, Event | undefined>()
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<Event | undefined> {
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 { try {
const fromService = const fromService =
(await replaceableEventService.fetchReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST)) ?? (await replaceableEventService.fetchReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST)) ??
@ -77,10 +139,25 @@ export async function fetchProfileBadgesListEvent(
/** Deprecated NIP-58 profile badges (kind 30008, d=profile_badges). */ /** Deprecated NIP-58 profile badges (kind 30008, d=profile_badges). */
export async function fetchLegacyProfileBadgesListEvent( export async function fetchLegacyProfileBadgesListEvent(
pubkeyHex: string, pubkeyHex: string,
relayUrls: string[] relayUrls: string[],
options?: { cacheFirst?: boolean }
): Promise<Event | undefined> { ): Promise<Event | undefined> {
const pk = normalizeHexPubkey(pubkeyHex) const pk = normalizeHexPubkey(pubkeyHex)
const cacheFirst = options?.cacheFirst !== false
let cached: Event | undefined let cached: Event | undefined
if (cacheFirst) {
try {
const legacyDisk = await indexedDb.getReplaceableEvent(
pk,
ExtendedKind.PROFILE_BADGES,
LEGACY_PROFILE_BADGES_D_TAG
)
if (legacyDisk && isNip58ProfileBadgesListEvent(legacyDisk)) cached = legacyDisk
} catch {
cached = undefined
}
}
if (!cached) {
try { try {
cached = cached =
(await replaceableEventService.fetchReplaceableEvent( (await replaceableEventService.fetchReplaceableEvent(
@ -91,6 +168,8 @@ export async function fetchLegacyProfileBadgesListEvent(
} catch { } catch {
cached = undefined cached = undefined
} }
}
if (cacheFirst && cached) return cached
const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))]
if (!allUrls.length) return cached if (!allUrls.length) return cached

38
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<typeof getPaymentInfoFromEvent> | 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
}
}
Loading…
Cancel
Save