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.
259 lines
8.0 KiB
259 lines
8.0 KiB
import { Event } from 'nostr-tools' |
|
import { |
|
buildAuthorInboxOutboxRelayUrls, |
|
buildProfileAugmentedReadRelayUrls, |
|
PROFILE_PAGE_PINS_RESOLVE_LIMIT |
|
} from '@/lib/favorites-feed-relays' |
|
import { |
|
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS |
|
} from '@/constants' |
|
import { normalizeHexPubkey } from '@/lib/pubkey' |
|
import { normalizeUrl } from '@/lib/url' |
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
import client, { eventService, queryService } from '@/services/client.service' |
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' |
|
|
|
const CACHE_DURATION = 5 * 60 * 1000 |
|
|
|
type PinsCacheEntry = { |
|
events: Event[] |
|
lastUpdated: number |
|
} |
|
|
|
const pinsCache = new Map<string, PinsCacheEntry>() |
|
|
|
function orderPinEvents(pinList: Event, eventsById: Map<string, Event>): Event[] { |
|
const ordered: Event[] = [] |
|
const seen = new Set<string>() |
|
|
|
const eIds = pinList.tags |
|
.filter((tag) => tag[0] === 'e' && tag[1]) |
|
.map((tag) => tag[1]!.toLowerCase()) |
|
.reverse() |
|
|
|
for (const id of eIds) { |
|
const ev = eventsById.get(id) |
|
if (ev) { |
|
const k = ev.id.toLowerCase() |
|
if (!seen.has(k)) { |
|
ordered.push(ev) |
|
seen.add(k) |
|
} |
|
} |
|
} |
|
|
|
const aTags = pinList.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]!) |
|
for (const coord of aTags) { |
|
const want = coord.toLowerCase() |
|
const ev = [...eventsById.values()].find((e) => { |
|
const d = e.tags.find((t) => t[0] === 'd')?.[1] ?? '' |
|
return `${e.kind}:${e.pubkey}:${d}`.toLowerCase() === want |
|
}) |
|
if (ev) { |
|
const k = ev.id.toLowerCase() |
|
if (!seen.has(k)) { |
|
ordered.push(ev) |
|
seen.add(k) |
|
} |
|
} |
|
} |
|
|
|
for (const ev of eventsById.values()) { |
|
const k = ev.id.toLowerCase() |
|
if (!seen.has(k)) { |
|
ordered.push(ev) |
|
seen.add(k) |
|
} |
|
} |
|
|
|
return ordered |
|
} |
|
|
|
function blockedRelaysContentKey(blockedRelays: string[]): string { |
|
return [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001') |
|
} |
|
|
|
export function useProfilePins(pubkey: string | undefined) { |
|
const { blockedRelays } = useFavoriteRelays() |
|
const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays]) |
|
const [pinEvents, setPinEvents] = useState<Event[]>([]) |
|
const [loadingPins, setLoadingPins] = useState(false) |
|
|
|
/** Same-tab paint: show cached pins before async relay work (matches timeline showing memory cache). */ |
|
useLayoutEffect(() => { |
|
if (!pubkey) { |
|
setPinEvents([]) |
|
return |
|
} |
|
const cacheKey = `${pubkey}-pins-profile` |
|
const cached = pinsCache.get(cacheKey) |
|
if ( |
|
cached && |
|
cached.events.length > 0 && |
|
Date.now() - cached.lastUpdated < CACHE_DURATION |
|
) { |
|
setPinEvents(cached.events) |
|
cached.events.forEach((e) => client.addEventToCache(e)) |
|
} else { |
|
setPinEvents([]) |
|
} |
|
}, [pubkey]) |
|
|
|
const loadPins = useCallback( |
|
async (forceRefresh = false) => { |
|
if (!pubkey) { |
|
setPinEvents([]) |
|
return |
|
} |
|
const cacheKey = `${pubkey}-pins-profile` |
|
if (!forceRefresh) { |
|
const cached = pinsCache.get(cacheKey) |
|
// Only reuse cache for non-empty pin rows. Empty was previously cached on transient relay |
|
// failures / races, which hid pins for CACHE_DURATION with no refetch. |
|
if ( |
|
cached && |
|
cached.events.length > 0 && |
|
Date.now() - cached.lastUpdated < CACHE_DURATION |
|
) { |
|
setPinEvents(cached.events) |
|
cached.events.forEach((e) => client.addEventToCache(e)) |
|
return |
|
} |
|
} |
|
|
|
setLoadingPins(true) |
|
try { |
|
const pk = normalizeHexPubkey(pubkey) |
|
const [authorRl, pinListEarly] = await Promise.all([ |
|
client.fetchRelayList(pk).catch(() => ({ |
|
read: [] as string[], |
|
write: [] as string[] |
|
})), |
|
client.fetchPinListEvent(pk).catch(() => undefined) |
|
]) |
|
const authorRelays = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays) |
|
const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(authorRelays, blockedRelays) |
|
if (!pinsResolveRelays.length) { |
|
setPinEvents([]) |
|
return |
|
} |
|
|
|
let pinList: Event | null = pinListEarly ?? null |
|
|
|
if (!pinList) { |
|
try { |
|
const rows = await queryService.fetchEvents( |
|
pinsResolveRelays, |
|
{ authors: [pk], kinds: [10001], limit: 1 }, |
|
{ |
|
replaceableRace: true, |
|
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
|
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS |
|
} |
|
) |
|
pinList = |
|
rows.length > 0 |
|
? rows.reduce((best, e) => (e.created_at > best.created_at ? e : best)) |
|
: null |
|
} catch { |
|
pinList = null |
|
} |
|
} |
|
|
|
if (!pinList) { |
|
setPinEvents([]) |
|
return |
|
} |
|
|
|
if (!pinList.tags?.length) { |
|
setPinEvents([]) |
|
return |
|
} |
|
|
|
const max = PROFILE_PAGE_PINS_RESOLVE_LIMIT |
|
const eventIds: string[] = [] |
|
const aTags: string[] = [] |
|
for (const tag of pinList.tags) { |
|
if (eventIds.length + aTags.length >= max) break |
|
if (tag[0] === 'e' && tag[1]) eventIds.push(tag[1].toLowerCase()) |
|
else if (tag[0] === 'a' && tag[1]) aTags.push(tag[1]) |
|
} |
|
|
|
const byId = new Map<string, Event>() |
|
if (eventIds.length > 0) { |
|
const sessionHits = await Promise.all(eventIds.map((id) => eventService.fetchEvent(id))) |
|
for (let i = 0; i < eventIds.length; i++) { |
|
const ev = sessionHits[i] |
|
if (ev) byId.set(ev.id.toLowerCase(), ev) |
|
} |
|
const missing = eventIds.filter((id) => !byId.has(id)) |
|
if (missing.length > 0) { |
|
const rows = await queryService.fetchEvents(pinsResolveRelays, { |
|
ids: missing, |
|
limit: max |
|
}) |
|
for (const e of rows) { |
|
byId.set(e.id.toLowerCase(), e) |
|
} |
|
} |
|
} |
|
|
|
const eventPromises: Promise<Event[]>[] = [] |
|
if (aTags.length > 0) { |
|
const aTagFetches = aTags.map(async (aTagRaw) => { |
|
const parts = aTagRaw.trim().split(':') |
|
if (parts.length < 2) return null |
|
const kind = parseInt(parts[0], 10) |
|
const author = parts[1]?.trim().toLowerCase() |
|
if (!Number.isFinite(kind) || !author || !/^[0-9a-f]{64}$/.test(author)) return null |
|
const d = parts.slice(2).join(':') |
|
const filter = d |
|
? { authors: [author], kinds: [kind], limit: 1, '#d': [d] as [string] } |
|
: { authors: [author], kinds: [kind], limit: 1 } |
|
const events = await queryService.fetchEvents(pinsResolveRelays, filter) |
|
return events[0] ?? null |
|
}) |
|
eventPromises.push( |
|
Promise.all(aTagFetches).then((events) => events.filter((e): e is Event => e !== null)) |
|
) |
|
} |
|
|
|
const eventArrays = await Promise.all(eventPromises) |
|
const flat = eventArrays.flat() |
|
flat.forEach((e) => client.addEventToCache(e)) |
|
for (const e of flat) { |
|
byId.set(e.id.toLowerCase(), e) |
|
} |
|
|
|
const ordered = orderPinEvents(pinList, byId).slice(0, PROFILE_PAGE_PINS_RESOLVE_LIMIT) |
|
setPinEvents(ordered) |
|
if (ordered.length > 0) { |
|
pinsCache.set(cacheKey, { events: ordered, lastUpdated: Date.now() }) |
|
} |
|
} catch { |
|
setPinEvents([]) |
|
} finally { |
|
setLoadingPins(false) |
|
} |
|
}, |
|
[pubkey, blockedKey, blockedRelays] |
|
) |
|
|
|
useEffect(() => { |
|
if (!pubkey) { |
|
setPinEvents([]) |
|
return |
|
} |
|
void loadPins(false) |
|
}, [pubkey, loadPins]) |
|
|
|
const refreshPins = useCallback(() => { |
|
if (pubkey) { |
|
pinsCache.delete(`${pubkey}-pins-profile`) |
|
} |
|
void loadPins(true) |
|
}, [pubkey, loadPins]) |
|
|
|
return { pinEvents, loadingPins, refreshPins } |
|
}
|
|
|