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) +}