You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

422 lines
14 KiB

/**
* 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 the former standalone profile-interactions hook (removed; logic lives here).
*/
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. Same ordering as interactions hook — 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)
}
function badgeMergeKey(b: TProfileBadge): string {
return `${b.a}|${b.awardId}`
}
/** Merge two accordion bundles (e.g. provisional relays + delta-only second fetch). */
export function mergeProfileAccordionBundles(
base: ProfileAccordionBundle,
add: ProfileAccordionBundle
): ProfileAccordionBundle {
const zapByPr = new Map(base.zaps.map((z) => [z.pr, z]))
for (const z of add.zaps) {
if (!zapByPr.has(z.pr)) zapByPr.set(z.pr, z)
}
const zaps = [...zapByPr.values()].sort((a, b) => b.amount - a.amount)
const reactionsByPubkey = new Map<string, Event>()
for (const e of base.reactions) {
reactionsByPubkey.set(e.pubkey, e)
}
for (const e of add.reactions) {
const prev = reactionsByPubkey.get(e.pubkey)
if (!prev || e.created_at > prev.created_at) reactionsByPubkey.set(e.pubkey, e)
}
const reactions = [...reactionsByPubkey.values()].sort((a, b) => b.created_at - a.created_at)
const commentById = new Map(base.comments.map((c) => [c.id, c]))
for (const c of add.comments) {
if (!commentById.has(c.id)) commentById.set(c.id, c)
}
const comments = [...commentById.values()].sort((a, b) => b.created_at - a.created_at)
const packByKey = new Map(base.followPacks.map((p) => [replaceableEventDedupeKey(p.event), p]))
for (const p of add.followPacks) {
const k = replaceableEventDedupeKey(p.event)
const prev = packByKey.get(k)
if (!prev || p.event.created_at > prev.event.created_at) packByKey.set(k, p)
}
const followPacks = [...packByKey.values()].sort((a, b) => b.event.created_at - a.event.created_at)
const badgeByKey = new Map(base.badges.map((b) => [badgeMergeKey(b), b]))
for (const b of add.badges) {
const k = badgeMergeKey(b)
if (!badgeByKey.has(k)) badgeByKey.set(k, b)
}
const badges = [...badgeByKey.values()]
const reportById = new Map(base.reports.map((r) => [r.id, r]))
for (const r of add.reports) {
if (!reportById.has(r.id)) reportById.set(r.id, r)
}
const reports = [...reportById.values()].sort((a, b) => b.created_at - a.created_at)
return { zaps, reactions, comments, badges, followPacks, reports }
}