Browse Source

make sure that the profile tabs have mutually-exclusive contet

imwald
Silberengel 1 month ago
parent
commit
db30fc095b
  1. 3
      src/components/Profile/ProfileHeaderInteractions.tsx
  2. 165
      src/components/Profile/ProfileInteractionsAccordion.tsx
  3. 6
      src/components/Profile/index.tsx
  4. 7
      src/components/ProfileOptions/index.tsx
  5. 123
      src/hooks/useProfileAccordionData.tsx
  6. 159
      src/hooks/useProfileBadges.tsx
  7. 16
      src/hooks/useProfileFollowPacks.tsx
  8. 10
      src/hooks/useProfileRelayUrls.tsx
  9. 11
      src/lib/event.ts
  10. 367
      src/lib/profile-accordion-fetch.ts

3
src/components/Profile/ProfileHeaderInteractions.tsx

@ -3,6 +3,7 @@ import UserAvatar from '@/components/UserAvatar' @@ -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({ @@ -343,7 +344,7 @@ export default function ProfileHeaderInteractions({
>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{displayFollowPacks.map((pack) => (
<FollowPackBadge key={pack.event.id} pack={pack} />
<FollowPackBadge key={replaceableEventDedupeKey(pack.event)} pack={pack} />
))}
</div>
</Section>

165
src/components/Profile/ProfileInteractionsAccordion.tsx

@ -1,14 +1,11 @@ @@ -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 = { @@ -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<void>
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 (
<ProfileHeaderInteractions
profilePubkey={pubkey}
badgeRelayUrls={relayUrls ?? []}
zaps={zaps}
reactions={reactions}
comments={comments}
badges={badges}
followPacks={packs}
reports={reports}
loading={loading}
badgesLoading={badgesLoading}
followPacksLoading={followPacksLoading}
reportsLoading={reportsLoading}
reportsEnabled={!!viewerPubkey}
/>
)
}
function ProfileInteractionsSkeleton() {
@ -103,37 +44,103 @@ 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 (
<Collapsible open={isExpanded} onOpenChange={onExpandedChange} className="min-w-0">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/80 bg-muted/15 px-3 py-2 text-left hover:bg-muted/25 min-w-0">
<span className="text-sm font-medium truncate">
{t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')}
</span>
<ChevronDown
className={cn('size-4 shrink-0 text-muted-foreground transition-transform', isExpanded && 'rotate-180')}
/>
</CollapsibleTrigger>
<div className="flex min-w-0 items-stretch gap-1 rounded-lg border border-border/80 bg-muted/15 hover:bg-muted/25">
<CollapsibleTrigger className="flex min-w-0 flex-1 items-center justify-between gap-2 px-3 py-2 text-left">
<span className="text-sm font-medium truncate">
{t('Zaps')}, {t('Likes')}, {t('Comments')}, {t('Badges')}, {t('In Follow Packs')}, {t('Reports')}
</span>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform',
isExpanded && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<Button
type="button"
variant="ghost"
size="icon"
className="my-1 mr-1 shrink-0 rounded-md"
title={t('Refresh')}
aria-label={t('Refresh')}
disabled={!pubkey}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleRefresh()
}}
>
<RefreshCw className={cn('size-4', bundleLoading && 'animate-spin')} />
</Button>
</div>
<CollapsibleContent className="overflow-hidden">
{hasContent ? (
!relaysReady ? (
showSkeleton ? (
<div className="pt-2">
<ProfileInteractionsSkeleton />
</div>
) : (
<div className="pt-2">
<ProfileInteractionsContent
pubkey={pubkey}
relayUrls={relayUrls.length > 0 ? relayUrls : undefined}
refreshRelayUrls={refreshRelayUrls}
onRefreshReady={onRefreshReady}
<ProfileHeaderInteractions
profilePubkey={pubkey}
badgeRelayUrls={relayUrls}
zaps={zaps}
reactions={reactions}
comments={comments}
badges={badges}
followPacks={followPacks}
reports={reports}
loading={bundleLoading}
badgesLoading={bundleLoading}
followPacksLoading={bundleLoading}
reportsLoading={bundleLoading}
reportsEnabled={!!viewerPubkey}
/>
</div>
)

6
src/components/Profile/index.tsx

@ -288,7 +288,6 @@ export default function Profile({ @@ -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({ @@ -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({ @@ -425,7 +423,6 @@ export default function Profile({
? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined
}
onProfileInteractionsRefresh={() => profileInteractionsRefreshRef.current?.()}
/>
{isSelf ? (
<DropdownMenu>
@ -452,7 +449,6 @@ export default function Profile({ @@ -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({ @@ -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({ @@ -649,7 +644,6 @@ export default function Profile({
pubkey={pubkey}
isExpanded={profileInteractionsExpanded}
onExpandedChange={setProfileInteractionsExpanded}
onRefreshReady={(refresh) => { profileInteractionsRefreshRef.current = refresh ?? null }}
/>
</div>
</div>

7
src/components/ProfileOptions/index.tsx

@ -32,8 +32,7 @@ export default function ProfileOptions({ @@ -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({ @@ -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({ @@ -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({ @@ -291,7 +287,6 @@ export default function ProfileOptions({
parentEvent={eventToUse}
open={openReply}
setOpen={setOpenReply}
onPublishSuccess={onProfileInteractionsRefresh}
/>
)}
{(localProfileEvent || profileEvent) && (

123
src/hooks/useProfileAccordionData.tsx

@ -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
}
}

159
src/hooks/useProfileBadges.tsx

@ -14,6 +14,7 @@ import { @@ -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 { @@ -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<string, TProfileBadge>()
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<TProfileBadge[]> {
export async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProfileBadge[]> {
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[ @@ -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[ @@ -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<TProfileBadge[]> {
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)
}

16
src/hooks/useProfileFollowPacks.tsx

@ -5,6 +5,7 @@ import { @@ -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( @@ -94,10 +95,17 @@ export function useProfileFollowPacks(
event: evt,
title: getPackTitle(evt)
}))
const byId = new Map<string, TProfileFollowPack>()
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<string, TProfileFollowPack>()
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 {

10
src/hooks/useProfileRelayUrls.tsx

@ -21,11 +21,11 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean @@ -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<string[]> => {
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 @@ -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 @@ -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 @@ -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])

11
src/lib/event.ts

@ -384,6 +384,17 @@ export function getReplaceableCoordinateFromEvent(event: Event) { @@ -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())

367
src/lib/profile-accordion-fetch.ts

@ -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…
Cancel
Save