10 changed files with 699 additions and 168 deletions
@ -0,0 +1,123 @@
@@ -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<ProfileAccordionBundle>(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 |
||||
} |
||||
} |
||||
@ -0,0 +1,367 @@
@@ -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<string, Event> |
||||
collectedComments: Event[] |
||||
packByDedupeKey: Map<string, TProfileFollowPack> |
||||
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<ProfileAccordionBundle> { |
||||
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<string>() |
||||
const noteIdSet = new Set<string>() |
||||
const packByDedupeKey = new Map<string, TProfileFollowPack>() |
||||
const reactionsByPubkey = new Map<string, Event>() |
||||
const seenProfileReactionEventIds = new Set<string>() |
||||
const collectedComments: Event[] = [] |
||||
const seenCommentIds = new Set<string>() |
||||
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<string>() |
||||
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) |
||||
} |
||||
Loading…
Reference in new issue