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.
 
 
 
 

264 lines
8.8 KiB

import { ExtendedKind } from '@/constants'
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
import {
fetchNip58BadgeAward,
fetchNip58BadgeDefinition,
mergeNip58BadgeRelayPool
} from '@/lib/fetch-badge-nip58'
import {
profileAccordionGetCachedBadges,
profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey,
profileAccordionSetBadges
} from '@/lib/profile-accordion-session-cache'
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useCallback, useEffect, useRef, useState } from 'react'
import { tagNameEquals } from '@/lib/tag'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildProfileRelayUrls } from '@/lib/profile-relay-urls'
export type TProfileBadge = {
/** Badge definition coordinate (e.g. "30009:alice:bravery") */
a: string
/** Badge award event id */
awardId: string
/** Human-readable name from definition */
name?: string
/** High-res image URL */
image?: string
/** Thumbnail URL (prefer thumb over image for grid display) */
thumb?: string
/** From badge definition (NIP-58) */
description?: string
/** Kind 8 award `created_at` when loaded */
awardCreatedAt?: number
}
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */
function parseATag(aTag: string): { kind: number; pubkey: string; d: string } | null {
const parts = aTag.split(':')
if (parts.length < 3) return null
const kind = parseInt(parts[0], 10)
if (isNaN(kind)) return null
const pk = parts[1]
if (!/^[0-9a-fA-F]{64}$/.test(pk)) return null
const d = parts.slice(2).join(':')
if (!d) return null
return { kind, pubkey: pk.toLowerCase(), d }
}
/** True when we should re-resolve the badge definition (missing media but coordinate looks like kind 30009). */
function badgeNeedsDefinitionMedia(b: TProfileBadge): boolean {
if (b.thumb || b.image) return false
const parsed = parseATag(b.a)
return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION)
}
function mergeBadgesByAwardId(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[]> {
return Promise.all(
badges.map(async (b) => {
if (b.thumb || b.image) return b
const parsed = parseATag(b.a)
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) return b
try {
const def = await indexedDb.getReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d)
if (!def) return b
const name = def.tags.find(tagNameEquals('name'))?.[1]
const description = def.tags.find(tagNameEquals('description'))?.[1]
const media = extractBadgeDefinitionMedia(def)
return {
...b,
name: name ?? b.name ?? parsed.d,
image: media.image,
thumb: media.thumb ?? media.image,
description: description ?? b.description
}
} catch {
return b
}
})
)
}
/** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */
/** Pass relayUrls to share with other profile fetches. */
export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[]) {
const { blockedRelays } = useFavoriteRelays()
const blockedRelaysRef = useRef(blockedRelays)
blockedRelaysRef.current = blockedRelays
const relayUrlsRef = useRef(relayUrls)
relayUrlsRef.current = relayUrls
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays)
const relayUrlsKey = profileAccordionRelayUrlsKey(relayUrls ?? [])
const [badges, setBadges] = useState<TProfileBadge[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
const fetchBadges = useCallback(async (force = false) => {
const myFetchId = (fetchIdRef.current += 1)
if (!pubkey) {
if (myFetchId === fetchIdRef.current) {
setBadges([])
setLoading(false)
}
return
}
const relayUrlsLatest = relayUrlsRef.current
let urls =
relayUrlsLatest && relayUrlsLatest.length > 0
? relayUrlsLatest
: profileAccordionGetCachedRelayUrls(pubkey) ?? []
if (force || urls.length === 0) {
urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
}
const relayKey = profileAccordionRelayUrlsKey(urls)
const seedBadges = profileAccordionGetCachedBadges(pubkey, relayKey)
let deferLoading = !!(force && seedBadges?.length)
if (!force) {
const cached = seedBadges
if (cached?.length) {
if (cached.some(badgeNeedsDefinitionMedia)) {
const enriched = await enrichBadgesFromIndexedDb(cached)
if (!enriched.some(badgeNeedsDefinitionMedia)) {
if (myFetchId !== fetchIdRef.current) return
setBadges(enriched)
profileAccordionSetBadges(pubkey, relayKey, enriched)
setLoading(false)
return
}
deferLoading = false
// Session cache was incomplete and IndexedDB has no definitions — fetch from network below.
} else {
if (myFetchId !== fetchIdRef.current) return
setBadges(cached)
setLoading(false)
return
}
}
}
if (force && seedBadges?.length && myFetchId === fetchIdRef.current) {
setBadges(seedBadges)
}
if (myFetchId !== fetchIdRef.current) return
if (!deferLoading) {
setLoading(true)
}
try {
const events = await queryService.fetchEvents(
urls,
{ authors: [pubkey], kinds: [ExtendedKind.PROFILE_BADGES], '#d': ['profile_badges'] },
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
)
const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0]
if (!profileBadgesEvent || myFetchId !== fetchIdRef.current) {
if (myFetchId === fetchIdRef.current && !seedBadges?.length) setBadges([])
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
}
})
)
if (myFetchId !== fetchIdRef.current) return
const merged = mergeBadgesByAwardId(seedBadges ?? [], result)
setBadges(merged)
profileAccordionSetBadges(pubkey, relayKey, merged)
} catch {
if (myFetchId !== fetchIdRef.current) return
if (!seedBadges?.length) setBadges([])
} finally {
if (myFetchId === fetchIdRef.current) setLoading(false)
}
}, [pubkey, blockedRelaysKey, relayUrlsKey])
const refresh = useCallback(() => {
void fetchBadges(true)
}, [pubkey, fetchBadges])
useEffect(() => {
void fetchBadges(false)
}, [fetchBadges])
return { badges, loading, refresh }
}