diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx
index caf1835e..a138dd66 100644
--- a/src/components/Profile/ProfileHeaderInteractions.tsx
+++ b/src/components/Profile/ProfileHeaderInteractions.tsx
@@ -3,6 +3,7 @@ import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog'
import { Button } from '@/components/ui/button'
+import { replaceableEventDedupeKey } from '@/lib/event'
import { formatAmount } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { toNote, toProfile } from '@/lib/link'
@@ -343,7 +344,7 @@ export default function ProfileHeaderInteractions({
>
{displayFollowPacks.map((pack) => (
-
+
))}
diff --git a/src/components/Profile/ProfileInteractionsAccordion.tsx b/src/components/Profile/ProfileInteractionsAccordion.tsx
index 1b6cf581..00aeb0c7 100644
--- a/src/components/Profile/ProfileInteractionsAccordion.tsx
+++ b/src/components/Profile/ProfileInteractionsAccordion.tsx
@@ -1,14 +1,11 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
-import { ChevronDown } from 'lucide-react'
+import { ChevronDown, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
-import { useEffect, useRef } from 'react'
import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls'
-import { useProfileInteractions } from '@/hooks/useProfileInteractions'
-import { useProfileBadges } from '@/hooks/useProfileBadges'
-import { useProfileFollowPacks } from '@/hooks/useProfileFollowPacks'
-import { useProfileReports } from '@/hooks/useProfileReports'
+import { useProfileAccordionData } from '@/hooks/useProfileAccordionData'
import { useNostr } from '@/providers/NostrProvider'
import ProfileHeaderInteractions from './ProfileHeaderInteractions'
@@ -16,62 +13,6 @@ type Props = {
pubkey: string | undefined
isExpanded: boolean
onExpandedChange: (open: boolean) => void
- onRefreshReady?: (refresh: (() => void) | null) => void
-}
-
-function ProfileInteractionsContent({
- pubkey,
- relayUrls,
- refreshRelayUrls,
- onRefreshReady
-}: {
- pubkey: string
- relayUrls: string[] | undefined
- refreshRelayUrls: () => void | Promise
- onRefreshReady?: (refresh: (() => void) | null) => void
-}) {
- const { pubkey: viewerPubkey } = useNostr()
- const { zaps, reactions, comments, loading, refresh } = useProfileInteractions(pubkey, relayUrls)
- const { badges, loading: badgesLoading, refresh: refreshBadges } = useProfileBadges(pubkey, relayUrls)
- const { packs, loading: followPacksLoading, refresh: refreshFollowPacks } = useProfileFollowPacks(pubkey, relayUrls)
- const { reports, loading: reportsLoading, refresh: refreshReports } = useProfileReports(pubkey, viewerPubkey)
-
- const onRefreshReadyRef = useRef(onRefreshReady)
- onRefreshReadyRef.current = onRefreshReady
-
- useEffect(() => {
- const doRefresh = () => {
- void (async () => {
- await refreshRelayUrls()
- refresh()
- refreshBadges()
- refreshFollowPacks()
- refreshReports()
- })()
- }
- onRefreshReadyRef.current?.(doRefresh)
- return () => {
- onRefreshReadyRef.current?.(null)
- }
- }, [refreshRelayUrls, refresh, refreshBadges, refreshFollowPacks, refreshReports])
-
- return (
-
- )
}
function ProfileInteractionsSkeleton() {
@@ -103,37 +44,103 @@ function ProfileInteractionsSkeleton() {
export default function ProfileInteractionsAccordion({
pubkey,
isExpanded,
- onExpandedChange,
- onRefreshReady
+ onExpandedChange
}: Props) {
const { t } = useTranslation()
- const { relayUrls, loading: relayUrlsLoading, refresh: refreshRelayUrls } = useProfileRelayUrls(pubkey, isExpanded)
+ const { pubkey: viewerPubkey } = useNostr()
+ const { relayUrls, loading: relayUrlsLoading, refresh: refreshRelayUrls } = useProfileRelayUrls(
+ pubkey,
+ isExpanded
+ )
const relaysReady = !relayUrlsLoading
+ const urlsForFetch = relayUrls.length > 0 ? relayUrls : undefined
+
+ const {
+ zaps,
+ reactions,
+ comments,
+ badges,
+ followPacks,
+ reports,
+ loading: bundleLoading,
+ refresh: refreshBundle
+ } = useProfileAccordionData({
+ pubkey,
+ relayUrls: urlsForFetch,
+ enabled: isExpanded && relaysReady && !!pubkey,
+ viewerPubkey
+ })
+
+ const handleRefresh = () => {
+ void (async () => {
+ const urls = await refreshRelayUrls()
+ refreshBundle(urls.length > 0 ? urls : undefined)
+ })()
+ }
+
const hasContent = isExpanded && pubkey
+ const hasAnyBundleData =
+ zaps.length > 0 ||
+ reactions.length > 0 ||
+ comments.length > 0 ||
+ badges.length > 0 ||
+ followPacks.length > 0 ||
+ reports.length > 0
+ const showSkeleton = hasContent && (!relaysReady || (bundleLoading && !hasAnyBundleData))
return (
-
-
- {t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')}
-
-
-
+
+
+
+ {t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')}
+
+
+
+
+
{hasContent ? (
- !relaysReady ? (
+ showSkeleton ? (
) : (
-
0 ? relayUrls : undefined}
- refreshRelayUrls={refreshRelayUrls}
- onRefreshReady={onRefreshReady}
+
)
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index aacf9705..010ee837 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -288,7 +288,6 @@ export default function Profile({
)
const isSelf = accountPubkey === profile?.pubkey
const [profileInteractionsExpanded, setProfileInteractionsExpanded] = useState(false)
- const profileInteractionsRefreshRef = useRef<(() => void) | null>(null)
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => {
@@ -352,7 +351,6 @@ export default function Profile({
const m = r as MutableRefObject<{ refresh: () => void } | null>
m.current = {
refresh: () => {
- profileInteractionsRefreshRef.current?.()
postsFeedRef.current?.refresh()
mediaFeedRef.current?.refresh()
publicationsFeedRef.current?.refresh()
@@ -425,7 +423,6 @@ export default function Profile({
? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined
}
- onProfileInteractionsRefresh={() => profileInteractionsRefreshRef.current?.()}
/>
{isSelf ? (
@@ -452,7 +449,6 @@ export default function Profile({
const evt = await publish(reaction)
if (evt) {
showSimplePublishSuccess(t('Reaction published'))
- profileInteractionsRefreshRef.current?.()
}
} finally {
setSelfReacting(false)
@@ -508,7 +504,6 @@ export default function Profile({
parentEvent={profileEvent}
open={openSelfReply}
setOpen={setOpenSelfReply}
- onPublishSuccess={() => profileInteractionsRefreshRef.current?.()}
/>
)}
{!isSelf ? (
@@ -649,7 +644,6 @@ export default function Profile({
pubkey={pubkey}
isExpanded={profileInteractionsExpanded}
onExpandedChange={setProfileInteractionsExpanded}
- onRefreshReady={(refresh) => { profileInteractionsRefreshRef.current = refresh ?? null }}
/>
diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx
index ce32e332..be150388 100644
--- a/src/components/ProfileOptions/index.tsx
+++ b/src/components/ProfileOptions/index.tsx
@@ -32,8 +32,7 @@ export default function ProfileOptions({
pubkey,
profileEvent,
onSendPublicMessage,
- onSendCallInvite,
- onProfileInteractionsRefresh
+ onSendCallInvite
}: {
pubkey: string
/** Optional profile event (kind 0) for republishing and viewing JSON */
@@ -42,8 +41,6 @@ export default function ProfileOptions({
onSendPublicMessage?: () => void
/** Opens the post editor to send the call invite URL as a public message to this profile. */
onSendCallInvite?: (url: string) => void
- /** Called after Like or Reply to refresh profile header interactions. */
- onProfileInteractionsRefresh?: () => void
}) {
const { t } = useTranslation()
const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr()
@@ -164,7 +161,6 @@ export default function ProfileOptions({
const evt = await publish(reaction)
if (evt) {
showSimplePublishSuccess(t('Reaction published'))
- onProfileInteractionsRefresh?.()
}
} finally {
setReacting(false)
@@ -291,7 +287,6 @@ export default function ProfileOptions({
parentEvent={eventToUse}
open={openReply}
setOpen={setOpenReply}
- onPublishSuccess={onProfileInteractionsRefresh}
/>
)}
{(localProfileEvent || profileEvent) && (
diff --git a/src/hooks/useProfileAccordionData.tsx b/src/hooks/useProfileAccordionData.tsx
new file mode 100644
index 00000000..b2faa917
--- /dev/null
+++ b/src/hooks/useProfileAccordionData.tsx
@@ -0,0 +1,123 @@
+import {
+ fetchProfileAccordionBundle,
+ profileAccordionBundleCacheKey,
+ type ProfileAccordionBundle
+} from '@/lib/profile-accordion-fetch'
+import {
+ profileAccordionGetCachedBadges,
+ profileAccordionGetCachedFollowPacks,
+ profileAccordionGetCachedInteractions,
+ profileAccordionGetCachedReports
+} from '@/lib/profile-accordion-session-cache'
+import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
+
+const EMPTY: ProfileAccordionBundle = {
+ zaps: [],
+ reactions: [],
+ comments: [],
+ badges: [],
+ followPacks: [],
+ reports: []
+}
+
+function readFullCache(
+ pubkey: string,
+ relayKey: string,
+ viewerPubkey: string | null | undefined
+): ProfileAccordionBundle | null {
+ const zi = profileAccordionGetCachedInteractions(pubkey, relayKey)
+ const zb = profileAccordionGetCachedBadges(pubkey, relayKey)
+ const zf = profileAccordionGetCachedFollowPacks(pubkey, relayKey)
+ const viewer = viewerPubkey?.trim()
+ const reportsReady = !viewer || profileAccordionGetCachedReports(pubkey, viewer) !== undefined
+ if (!zi || zb === undefined || zf === undefined || !reportsReady) return null
+ const reports =
+ viewer ? profileAccordionGetCachedReports(pubkey, viewer) ?? [] : []
+ return {
+ zaps: zi.zaps,
+ reactions: zi.reactions,
+ comments: zi.comments,
+ badges: zb,
+ followPacks: zf,
+ reports
+ }
+}
+
+/**
+ * Loads profile accordion data only when `enabled` (accordion open); hydrates from session cache first.
+ * Use {@link refresh} for manual network refresh.
+ */
+export function useProfileAccordionData(opts: {
+ pubkey: string | undefined
+ relayUrls: string[] | undefined
+ enabled: boolean
+ viewerPubkey: string | null | undefined
+}) {
+ const { pubkey, relayUrls, enabled, viewerPubkey } = opts
+ const { favoriteRelays, blockedRelays } = useFavoriteRelays()
+ const [data, setData] = useState(EMPTY)
+ const [loading, setLoading] = useState(false)
+ const reqId = useRef(0)
+
+ const relayKey = useMemo(
+ () => profileAccordionBundleCacheKey(relayUrls ?? []),
+ [relayUrls]
+ )
+
+ const runFetch = useCallback(
+ async (force: boolean, overrideUrls?: string[]) => {
+ const urls = (overrideUrls?.length ? overrideUrls : relayUrls) ?? []
+ if (!pubkey?.trim() || !urls.length) return
+ const id = ++reqId.current
+ setLoading(true)
+ try {
+ const bundle = await fetchProfileAccordionBundle({
+ pubkey: pubkey.trim(),
+ urls,
+ viewerPubkey,
+ favoriteRelays: favoriteRelays ?? [],
+ blockedRelays,
+ force,
+ onPartial: (partial) => {
+ if (id !== reqId.current) return
+ setData(partial)
+ }
+ })
+ if (id !== reqId.current) return
+ setData(bundle)
+ } finally {
+ if (id === reqId.current) setLoading(false)
+ }
+ },
+ [pubkey, relayUrls, viewerPubkey, favoriteRelays, blockedRelays]
+ )
+
+ const refresh = useCallback(
+ (overrideUrls?: string[]) => {
+ void runFetch(true, overrideUrls)
+ },
+ [runFetch]
+ )
+
+ useLayoutEffect(() => {
+ if (!enabled || !pubkey?.trim() || !relayUrls?.length) {
+ return
+ }
+ const pk = pubkey.trim()
+ const cached = readFullCache(pk, relayKey, viewerPubkey)
+ if (cached) {
+ setData(cached)
+ setLoading(false)
+ return
+ }
+ setLoading(true)
+ void runFetch(false)
+ }, [enabled, pubkey, relayKey, relayUrls, viewerPubkey, runFetch])
+
+ return {
+ ...data,
+ loading,
+ refresh
+ }
+}
diff --git a/src/hooks/useProfileBadges.tsx b/src/hooks/useProfileBadges.tsx
index cd2b180d..21380718 100644
--- a/src/hooks/useProfileBadges.tsx
+++ b/src/hooks/useProfileBadges.tsx
@@ -14,6 +14,7 @@ import {
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useCallback, useEffect, useRef, useState } from 'react'
+import { Event } from 'nostr-tools'
import { tagNameEquals } from '@/lib/tag'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildProfileRelayUrls } from '@/lib/profile-relay-urls'
@@ -55,14 +56,14 @@ function badgeNeedsDefinitionMedia(b: TProfileBadge): boolean {
return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION)
}
-function mergeBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] {
+export function mergeProfileBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] {
const m = new Map()
for (const b of seed) m.set(b.awardId, b)
for (const b of fresh) m.set(b.awardId, b)
return [...m.values()]
}
-async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise {
+export async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise {
return Promise.all(
badges.map(async (b) => {
if (b.thumb || b.image) return b
@@ -173,75 +174,14 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
return
}
- const tags = profileBadgesEvent.tags
- const pairs: { a: string; e: string; eRelayHint?: string }[] = []
- for (let i = 0; i < tags.length - 1; i++) {
- const ta = tags[i]
- const te = tags[i + 1]
- if (
- ta[0] === 'a' &&
- te[0] === 'e' &&
- ta[1] &&
- te[1] &&
- /^[a-f0-9]{64}$/i.test(te[1])
- ) {
- pairs.push({ a: ta[1], e: te[1], eRelayHint: te[2] })
- }
- }
-
- if (pairs.length === 0) {
- if (!seedBadges?.length) setBadges([])
- return
- }
-
- const result: TProfileBadge[] = await Promise.all(
- pairs.map(async ({ a, e, eRelayHint }) => {
- const parsed = parseATag(a)
- if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
- return { a, awardId: e }
- }
-
- const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelaysRef.current)
- const [defEvent, awardEvent] = await Promise.all([
- fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool),
- fetchNip58BadgeAward(e, relayPool)
- ])
-
- const awardATag = awardEvent?.tags.find(tagNameEquals('a'))?.[1]
- const awardMatchesDefinition = !awardEvent || awardATag === a
- const awardCreatedAt =
- awardMatchesDefinition && awardEvent ? awardEvent.created_at : undefined
-
- if (defEvent) {
- try {
- await indexedDb.putReplaceableEvent(defEvent)
- } catch {
- // ignore ingest failures (tombstone / validation)
- }
- }
-
- if (!defEvent) {
- return { a, awardId: e, awardCreatedAt }
- }
-
- const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
- const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
- const media = extractBadgeDefinitionMedia(defEvent)
-
- return {
- a,
- awardId: e,
- name: name ?? parsed.d,
- image: media.image,
- thumb: media.thumb ?? media.image,
- description,
- awardCreatedAt
- }
- })
+ const merged = await resolveProfileBadgeList(
+ profileBadgesEvent,
+ urls,
+ blockedRelaysRef.current,
+ seedBadges
)
if (myFetchId !== fetchIdRef.current) return
- const merged = mergeBadgesByAwardId(seedBadges ?? [], result)
setBadges(merged)
profileAccordionSetBadges(pubkey, relayKey, merged)
} catch {
@@ -262,3 +202,86 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
return { badges, loading, refresh }
}
+
+/**
+ * Resolves NIP-58 badge definitions/awards for the newest kind-30008 `profile_badges` event.
+ * Shared by {@link useProfileBadges} and profile accordion bundle fetch.
+ */
+export async function resolveProfileBadgeList(
+ profileBadgesEvent: Event | undefined,
+ urls: string[],
+ blockedRelays: string[],
+ seedBadges: TProfileBadge[] | null | undefined
+): Promise {
+ if (!profileBadgesEvent) {
+ return seedBadges?.length ? [...seedBadges] : []
+ }
+
+ const tags = profileBadgesEvent.tags
+ const pairs: { a: string; e: string; eRelayHint?: string }[] = []
+ for (let i = 0; i < tags.length - 1; i++) {
+ const ta = tags[i]
+ const te = tags[i + 1]
+ if (
+ ta[0] === 'a' &&
+ te[0] === 'e' &&
+ ta[1] &&
+ te[1] &&
+ /^[a-f0-9]{64}$/i.test(te[1])
+ ) {
+ pairs.push({ a: ta[1], e: te[1], eRelayHint: te[2] })
+ }
+ }
+
+ if (pairs.length === 0) {
+ return seedBadges?.length ? [...seedBadges] : []
+ }
+
+ const result: TProfileBadge[] = await Promise.all(
+ pairs.map(async ({ a, e, eRelayHint }) => {
+ const parsed = parseATag(a)
+ if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
+ return { a, awardId: e }
+ }
+
+ const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelays)
+ const [defEvent, awardEvent] = await Promise.all([
+ fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool),
+ fetchNip58BadgeAward(e, relayPool)
+ ])
+
+ const awardATag = awardEvent?.tags.find(tagNameEquals('a'))?.[1]
+ const awardMatchesDefinition = !awardEvent || awardATag === a
+ const awardCreatedAt =
+ awardMatchesDefinition && awardEvent ? awardEvent.created_at : undefined
+
+ if (defEvent) {
+ try {
+ await indexedDb.putReplaceableEvent(defEvent)
+ } catch {
+ /* ignore */
+ }
+ }
+
+ if (!defEvent) {
+ return { a, awardId: e, awardCreatedAt }
+ }
+
+ const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
+ const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
+ const media = extractBadgeDefinitionMedia(defEvent)
+
+ return {
+ a,
+ awardId: e,
+ name: name ?? parsed.d,
+ image: media.image,
+ thumb: media.thumb ?? media.image,
+ description,
+ awardCreatedAt
+ }
+ })
+ )
+
+ return mergeProfileBadgesByAwardId(seedBadges ?? [], result)
+}
diff --git a/src/hooks/useProfileFollowPacks.tsx b/src/hooks/useProfileFollowPacks.tsx
index 716613bc..8d4f7895 100644
--- a/src/hooks/useProfileFollowPacks.tsx
+++ b/src/hooks/useProfileFollowPacks.tsx
@@ -5,6 +5,7 @@ import {
profileAccordionRelayUrlsKey,
profileAccordionSetFollowPacks
} from '@/lib/profile-accordion-session-cache'
+import { replaceableEventDedupeKey } from '@/lib/event'
import { queryService } from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -94,10 +95,17 @@ export function useProfileFollowPacks(
event: evt,
title: getPackTitle(evt)
}))
- const byId = new Map()
- for (const p of seed ?? []) byId.set(p.event.id, p)
- for (const p of network) byId.set(p.event.id, p)
- const merged = [...byId.values()].sort((a, b) => b.event.created_at - a.event.created_at)
+ const byDedupeKey = new Map()
+ const put = (p: TProfileFollowPack) => {
+ const k = replaceableEventDedupeKey(p.event)
+ const prev = byDedupeKey.get(k)
+ if (!prev || p.event.created_at > prev.event.created_at) {
+ byDedupeKey.set(k, p)
+ }
+ }
+ for (const p of seed ?? []) put(p)
+ for (const p of network) put(p)
+ const merged = [...byDedupeKey.values()].sort((a, b) => b.event.created_at - a.event.created_at)
setPacks(merged)
profileAccordionSetFollowPacks(pubkey, relayKey, merged)
} catch {
diff --git a/src/hooks/useProfileRelayUrls.tsx b/src/hooks/useProfileRelayUrls.tsx
index 223b6c60..ed2b1e26 100644
--- a/src/hooks/useProfileRelayUrls.tsx
+++ b/src/hooks/useProfileRelayUrls.tsx
@@ -21,11 +21,11 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean
relayUrlsRef.current = relayUrls
const fetch = useCallback(
- async (force = false) => {
+ async (force = false): Promise => {
if (!pubkey) {
setRelayUrls((prev) => (prev.length === 0 ? prev : []))
setLoading(false)
- return
+ return []
}
if (!force) {
@@ -33,7 +33,7 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean
if (cached?.length) {
setRelayUrls(cached)
setLoading(false)
- return
+ return cached
}
}
@@ -45,8 +45,10 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean
const urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
profileAccordionSetRelayUrls(pubkey, urls)
setRelayUrls(urls)
+ return urls
} catch {
setRelayUrls((prev) => (prev.length === 0 ? prev : []))
+ return []
} finally {
setLoading(false)
}
@@ -55,7 +57,7 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean
)
const refresh = useCallback(() => {
- if (!pubkey) return Promise.resolve()
+ if (!pubkey) return Promise.resolve([] as string[])
/** Do not invalidate: that wipes interactions/badges/follow-packs cache and forces empty refetches */
return fetch(true)
}, [pubkey, fetch])
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 106f9615..254c8a6d 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -384,6 +384,17 @@ export function getReplaceableCoordinateFromEvent(event: Event) {
return getReplaceableCoordinate(event.kind, event.pubkey, d)
}
+/**
+ * Merge key for NIP-33 addressable events when relays return different ids for the same logical
+ * replaceable. Normalized `kind:pubkey:d`; missing/empty `d` or non-addressable kinds use `event.id`.
+ */
+export function replaceableEventDedupeKey(event: Event): string {
+ if (!kinds.isAddressableKind(event.kind)) return event.id
+ const d = event.tags.find(tagNameEquals('d'))?.[1]
+ if (d == null || d === '') return event.id
+ return normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(event))
+}
+
/** Normalize `kind:pubkey:d` for comparisons (lowercase pubkey; preserve d). */
export function normalizeReplaceableCoordinateString(coord: string): string {
const m = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(coord.trim())
diff --git a/src/lib/profile-accordion-fetch.ts b/src/lib/profile-accordion-fetch.ts
new file mode 100644
index 00000000..f35ff96c
--- /dev/null
+++ b/src/lib/profile-accordion-fetch.ts
@@ -0,0 +1,367 @@
+/**
+ * Orchestrated fetch for the profile interactions accordion: phase 1 (zaps, notes, follow packs,
+ * profile_badges list), then separate batches for comments on notes, comments on profile (#a), and
+ * profile reactions (#e + #a); badge NIP-58 resolution and reports run after. `onPartial` fires as
+ * relays return events (coalesced per microtask). Session cache writes stay at completion only.
+ * Ordering matches {@link useProfileInteractions}.
+ */
+
+import { ExtendedKind } from '@/constants'
+import { getZapInfoFromEvent } from '@/lib/event-metadata'
+import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls'
+import {
+ profileAccordionGetCachedBadges,
+ profileAccordionGetCachedFollowPacks,
+ profileAccordionGetCachedInteractions,
+ profileAccordionGetCachedReports,
+ profileAccordionRelayUrlsKey,
+ profileAccordionSetBadges,
+ profileAccordionSetFollowPacks,
+ profileAccordionSetInteractions,
+ profileAccordionSetReports
+} from '@/lib/profile-accordion-session-cache'
+import type { TProfileBadge } from '@/hooks/useProfileBadges'
+import { enrichBadgesFromIndexedDb, resolveProfileBadgeList } from '@/hooks/useProfileBadges'
+import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
+import type { TProfileZap } from '@/hooks/useProfileInteractions'
+import { replaceableEventDedupeKey } from '@/lib/event'
+import { hexPubkeysEqual } from '@/lib/pubkey'
+import { queryService, replaceableEventService } from '@/services/client.service'
+import { Event, Filter, kinds } from 'nostr-tools'
+
+const NOTE_IDS_FOR_COMMENTS = 50
+const REPORT_LIMIT = 50
+
+const QUERY_OPTS = {
+ eoseTimeout: 2500,
+ globalTimeout: 18_000,
+ firstRelayResultGraceMs: false
+} as const
+
+export type ProfileAccordionBundle = {
+ zaps: TProfileZap[]
+ reactions: Event[]
+ comments: Event[]
+ badges: TProfileBadge[]
+ followPacks: TProfileFollowPack[]
+ reports: Event[]
+}
+
+function getPackTitle(event: Event): string {
+ const titleTag = event.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name')
+ return titleTag?.[1] || 'Follow Pack'
+}
+
+function isProfileBadgesListEvent(pubkey: string, e: Event): boolean {
+ if (e.kind !== ExtendedKind.PROFILE_BADGES) return false
+ if (!hexPubkeysEqual(e.pubkey, pubkey)) return false
+ return e.tags.some((t) => t[0] === 'd' && t[1] === 'profile_badges')
+}
+
+function cacheHydrated(
+ pubkey: string,
+ relayKey: string,
+ viewerPubkey: string | null | undefined
+): ProfileAccordionBundle | null {
+ const zi = profileAccordionGetCachedInteractions(pubkey, relayKey)
+ const zb = profileAccordionGetCachedBadges(pubkey, relayKey)
+ const zf = profileAccordionGetCachedFollowPacks(pubkey, relayKey)
+ const viewer = viewerPubkey?.trim()
+ const reportsReady = !viewer || profileAccordionGetCachedReports(pubkey, viewer) !== undefined
+ if (!zi || zb === undefined || zf === undefined || !reportsReady) return null
+ const reports =
+ viewer ? profileAccordionGetCachedReports(pubkey, viewer) ?? [] : []
+ return {
+ zaps: zi.zaps,
+ reactions: zi.reactions,
+ comments: zi.comments,
+ badges: zb,
+ followPacks: zf,
+ reports
+ }
+}
+
+function bundleSnapshot(args: {
+ collectedZaps: TProfileZap[]
+ reactionsByPubkey: Map
+ collectedComments: Event[]
+ packByDedupeKey: Map
+ badgesForUi: TProfileBadge[]
+ reports: Event[]
+}): ProfileAccordionBundle {
+ const zaps = [...args.collectedZaps].sort((a, b) => b.amount - a.amount)
+ const reactions = Array.from(args.reactionsByPubkey.values()).sort(
+ (a, b) => b.created_at - a.created_at
+ )
+ const comments = [...args.collectedComments].sort((a, b) => b.created_at - a.created_at)
+ const followPacks = [...args.packByDedupeKey.values()].sort(
+ (a, b) => b.event.created_at - a.event.created_at
+ )
+ return {
+ zaps,
+ reactions,
+ comments,
+ badges: args.badgesForUi,
+ followPacks,
+ reports: args.reports
+ }
+}
+
+export async function fetchProfileAccordionBundle(args: {
+ pubkey: string
+ urls: string[]
+ viewerPubkey: string | null | undefined
+ favoriteRelays: string[]
+ blockedRelays: string[]
+ force: boolean
+ /** Called as relays return events so the UI can render incrementally (not only after full EOSE). */
+ onPartial?: (bundle: ProfileAccordionBundle) => void
+}): Promise {
+ const { pubkey, urls, viewerPubkey, favoriteRelays, blockedRelays, force, onPartial } = args
+ const relayKey = profileAccordionRelayUrlsKey(urls)
+ const viewer = viewerPubkey?.trim()
+
+ if (!force) {
+ const hit = cacheHydrated(pubkey, relayKey, viewer)
+ if (hit) return hit
+ }
+
+ const profileReactionATags = new Set([`0:${pubkey}:`, `0:${pubkey}:profile`])
+ const profileAddrs = [`0:${pubkey}:`, `0:${pubkey}:profile`]
+
+ const seedBadges = force ? undefined : profileAccordionGetCachedBadges(pubkey, relayKey)
+ let resolvedBadges: TProfileBadge[] | null = null
+ let reportsSoFar: Event[] = []
+
+ const collectedZaps: TProfileZap[] = []
+ const seenZaps = new Set()
+ const noteIdSet = new Set()
+ const packByDedupeKey = new Map()
+ const reactionsByPubkey = new Map()
+ const seenProfileReactionEventIds = new Set()
+ const collectedComments: Event[] = []
+ const seenCommentIds = new Set()
+ let profileBadgesEvent: Event | undefined
+ let profileMetaEvent: Event | undefined
+
+ const emit = () => {
+ if (!onPartial) return
+ const badgesForUi = resolvedBadges ?? seedBadges ?? []
+ onPartial(
+ bundleSnapshot({
+ collectedZaps,
+ reactionsByPubkey,
+ collectedComments,
+ packByDedupeKey,
+ badgesForUi,
+ reports: reportsSoFar
+ })
+ )
+ }
+
+ let emitCoalesce = false
+ const scheduleEmit = () => {
+ if (!onPartial || emitCoalesce) return
+ emitCoalesce = true
+ queueMicrotask(() => {
+ emitCoalesce = false
+ emit()
+ })
+ }
+
+ const reactionTargetsKind0Profile = (evt: Event): boolean => {
+ if (evt.kind !== kinds.Reaction) return false
+ const aHit = evt.tags.some((t) => t[0] === 'a' && t[1] && profileReactionATags.has(t[1]))
+ if (aHit) return true
+ const pid = profileMetaEvent?.id
+ if (!pid) return false
+ return evt.tags.some((t) => t[0] === 'e' && t[1] && hexPubkeysEqual(t[1], pid))
+ }
+
+ const ingestProfileReaction = (evt: Event) => {
+ if (!reactionTargetsKind0Profile(evt)) return
+ if (hexPubkeysEqual(evt.pubkey, pubkey)) return
+ if (seenProfileReactionEventIds.has(evt.id)) return
+ seenProfileReactionEventIds.add(evt.id)
+ const existing = reactionsByPubkey.get(evt.pubkey)
+ if (!existing || evt.created_at > existing.created_at) {
+ reactionsByPubkey.set(evt.pubkey, evt)
+ }
+ }
+
+ const ingestComment = (evt: Event) => {
+ if (evt.kind !== ExtendedKind.COMMENT) return
+ if (hexPubkeysEqual(evt.pubkey, pubkey)) return
+ if (seenCommentIds.has(evt.id)) return
+ seenCommentIds.add(evt.id)
+ collectedComments.push(evt)
+ }
+
+ const ingestPhase1Event = (evt: Event) => {
+ if (evt.kind === kinds.Zap) {
+ const info = getZapInfoFromEvent(evt)
+ if (!info || !hexPubkeysEqual(info.recipientPubkey ?? '', pubkey) || !info.amount || info.amount <= 0)
+ return
+ const sender = info.senderPubkey ?? evt.pubkey
+ if (hexPubkeysEqual(sender, pubkey)) return
+ if (seenZaps.has(evt.id)) return
+ seenZaps.add(evt.id)
+ collectedZaps.push({
+ pr: evt.id,
+ pubkey: sender,
+ amount: info.amount,
+ created_at: evt.created_at,
+ comment: info.comment
+ })
+ } else if (evt.kind === kinds.ShortTextNote) {
+ noteIdSet.add(evt.id)
+ } else if (evt.kind === ExtendedKind.FOLLOW_PACK) {
+ const key = replaceableEventDedupeKey(evt)
+ const next: TProfileFollowPack = { event: evt, title: getPackTitle(evt) }
+ const prev = packByDedupeKey.get(key)
+ if (!prev || evt.created_at > prev.event.created_at) {
+ packByDedupeKey.set(key, next)
+ }
+ } else if (isProfileBadgesListEvent(pubkey, evt)) {
+ if (!profileBadgesEvent || evt.created_at > profileBadgesEvent.created_at) {
+ profileBadgesEvent = evt
+ }
+ }
+ }
+
+ // Keep phase 1 free of #a reaction/comment: many relays handle those poorly when batched with
+ // zaps/notes/badges. Match {@link useProfileInteractions} — dedicated REQ(s) for profile comments
+ // and reactions after we have note ids + kind-0 id.
+ const phase1Filters: Filter[] = [
+ { '#p': [pubkey], kinds: [kinds.Zap], limit: 100 },
+ { authors: [pubkey], kinds: [kinds.ShortTextNote], limit: NOTE_IDS_FOR_COMMENTS },
+ { '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 },
+ {
+ authors: [pubkey],
+ kinds: [ExtendedKind.PROFILE_BADGES],
+ '#d': ['profile_badges'],
+ limit: 5
+ }
+ ]
+
+ const phase1Opts = {
+ ...QUERY_OPTS,
+ onevent: (evt: Event) => {
+ ingestPhase1Event(evt)
+ scheduleEmit()
+ }
+ }
+
+ const [metaEv, _phase1Events] = await Promise.all([
+ replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, urls),
+ queryService.fetchEvents(urls, phase1Filters, phase1Opts)
+ ])
+ profileMetaEvent = metaEv
+ emit()
+
+ const noteIds = [...noteIdSet].slice(0, NOTE_IDS_FOR_COMMENTS)
+
+ if (noteIds.length > 0) {
+ await queryService.fetchEvents(
+ urls,
+ [{ '#e': noteIds, kinds: [ExtendedKind.COMMENT], limit: 50 }],
+ {
+ ...QUERY_OPTS,
+ onevent: (evt: Event) => {
+ if (evt.kind === ExtendedKind.COMMENT) ingestComment(evt)
+ scheduleEmit()
+ }
+ }
+ )
+ }
+
+ await queryService.fetchEvents(
+ urls,
+ [{ '#a': profileAddrs, kinds: [ExtendedKind.COMMENT], limit: 120 }],
+ {
+ ...QUERY_OPTS,
+ onevent: (evt: Event) => {
+ if (evt.kind === ExtendedKind.COMMENT) ingestComment(evt)
+ scheduleEmit()
+ }
+ }
+ )
+
+ const reactionFilters: Filter[] = []
+ if (profileMetaEvent?.id) {
+ reactionFilters.push({ '#e': [profileMetaEvent.id], kinds: [kinds.Reaction], limit: 80 })
+ }
+ reactionFilters.push({
+ '#a': [...profileReactionATags],
+ kinds: [kinds.Reaction],
+ limit: 80
+ })
+ await queryService.fetchEvents(urls, reactionFilters, {
+ ...QUERY_OPTS,
+ onevent: (evt: Event) => {
+ if (evt.kind === kinds.Reaction) ingestProfileReaction(evt)
+ scheduleEmit()
+ }
+ })
+
+ collectedZaps.sort((a, b) => b.amount - a.amount)
+ const reactions = Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at)
+ collectedComments.sort((a, b) => b.created_at - a.created_at)
+ const followPacks = [...packByDedupeKey.values()].sort((a, b) => b.event.created_at - a.event.created_at)
+
+ let badges = await resolveProfileBadgeList(profileBadgesEvent, urls, blockedRelays, seedBadges)
+ badges = await enrichBadgesFromIndexedDb(badges)
+ resolvedBadges = badges
+ emit()
+
+ let reports: Event[] = []
+ if (viewer) {
+ const reportUrls = await buildProfileReportRelayUrls({
+ viewerPubkey: viewer,
+ favoriteRelays,
+ blockedRelays
+ })
+ if (reportUrls.length > 0) {
+ const seenReportIds = new Set()
+ reports = await queryService.fetchEvents(
+ reportUrls,
+ [{ '#p': [pubkey], kinds: [ExtendedKind.REPORT], limit: REPORT_LIMIT }],
+ {
+ ...QUERY_OPTS,
+ onevent: (evt: Event) => {
+ if (evt.kind !== ExtendedKind.REPORT || seenReportIds.has(evt.id)) return
+ seenReportIds.add(evt.id)
+ reportsSoFar.push(evt)
+ reportsSoFar.sort((a, b) => b.created_at - a.created_at)
+ scheduleEmit()
+ }
+ }
+ )
+ }
+ profileAccordionSetReports(pubkey, viewer, reports)
+ }
+ reportsSoFar = reports
+
+ profileAccordionSetInteractions(pubkey, relayKey, {
+ zaps: collectedZaps,
+ reactions,
+ comments: collectedComments
+ })
+ profileAccordionSetBadges(pubkey, relayKey, badges)
+ profileAccordionSetFollowPacks(pubkey, relayKey, followPacks)
+
+ emit()
+
+ return {
+ zaps: collectedZaps,
+ reactions,
+ comments: collectedComments,
+ badges,
+ followPacks,
+ reports
+ }
+}
+
+export function profileAccordionBundleCacheKey(urls: string[]): string {
+ return profileAccordionRelayUrlsKey(urls)
+}