import { useEffect, useMemo } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; import { onlyVerified } from '@/lib/verifyEvents'; import { HOUSE_TRUST_PROVIDER, NIP85_PROVIDER_LIST_KIND, NIP85_USER_ASSERTION_KIND, parseProviderDesignation, parseUserTrustAssertions, resolveProvider, type TrustProvider, type UserTrustAssertion, } from '@/lib/nip85'; /** * Read kind-30382 Trusted Assertions (NIP-85) for a set of subjects, from the * provider the viewer designates via their own kind-10040 — falling back to the * configured house provider. Signatures are verified and the event author must * equal the resolved provider before a score is used. * * BATCHING * -------- * TrustBadge is rendered once per card, so a naive hook would fire one relay * query per author. Instead every consumer registers its targets into a shared * per-viewer union, and ONE query fetches the whole union; each caller then * reads only its own subjects out of the result. New targets arriving later * (an infinite list) trigger a single debounced refetch. * * A missing assertion yields no entry: that means "nothing published", which is * NOT the same as distrust. Callers must treat absence as `unknown`. */ const MAX_UNION = 600; const BATCH_WINDOW_MS = 100; interface PendingBatch { targets: Set; timer: ReturnType | null; } const batches = new Map(); function batchFor(viewer: string): PendingBatch { let batch = batches.get(viewer); if (!batch) { batch = { targets: new Set(), timer: null }; batches.set(viewer, batch); } return batch; } /** * Register the targets one consumer needs. Returns true when the union grew, so * the caller can schedule a refetch. Idempotent, and deliberately called during * render so a whole grid registers before the shared query's first pass. */ function registerTargets(viewer: string, targets: string[]): boolean { const batch = batchFor(viewer); let grew = false; for (const target of targets) { if (!batch.targets.has(target)) { batch.targets.add(target); grew = true; } } // Cap the union (insertion order) so a long session cannot grow forever. while (batch.targets.size > MAX_UNION) { const oldest = batch.targets.values().next().value; if (oldest === undefined) break; batch.targets.delete(oldest); } return grew; } export function useTrustAssertions(pubkeys: string[]) { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); const viewer = user?.pubkey ?? ''; const key = useMemo(() => [...new Set(pubkeys)].sort().join(','), [pubkeys]); const targets = useMemo(() => (key ? key.split(',') : []), [key]); const grew = registerTargets(viewer, targets); const queryKey = useMemo(() => ['trust-assertions-batch', viewer], [viewer]); useEffect(() => { if (!grew) return; // The first fetch already sees the targets registered before it ran; only a // later addition to an established batch needs a refetch. if (queryClient.getQueryState(queryKey)?.data === undefined) return; const batch = batchFor(viewer); if (batch.timer) clearTimeout(batch.timer); batch.timer = setTimeout(() => { batch.timer = null; queryClient.invalidateQueries({ queryKey }); }, BATCH_WINDOW_MS); }, [grew, viewer, queryKey, queryClient]); const query = useQuery({ queryKey, queryFn: async (c): Promise> => { const out = new Map(); const all = [...batchFor(viewer).targets]; if (all.length === 0) return out; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); // 1. Resolve the provider: the viewer's own designation, else the house. let provider: TrustProvider = HOUSE_TRUST_PROVIDER; try { if (viewer) { const designationEvents = onlyVerified(await nostr.query( [{ kinds: [NIP85_PROVIDER_LIST_KIND], authors: [viewer], limit: 1 }], { signal }, )); const designation = parseProviderDesignation(designationEvents[0]); provider = resolveProvider( designation, NIP85_USER_ASSERTION_KIND, 'rank', HOUSE_TRUST_PROVIDER, ); } } catch { // Designation lookup failed — the house provider is the fallback. } // 2. Fetch the assertions for the whole batch from the resolved provider. const filter = { kinds: [NIP85_USER_ASSERTION_KIND], authors: [provider.pubkey], '#d': all, limit: all.length, }; try { let events: NostrEvent[]; if (provider.relay) { try { events = await nostr.relay(provider.relay).query([filter], { signal }); } catch { events = await nostr.query([filter], { signal }); } } else { events = await nostr.query([filter], { signal }); } return parseUserTrustAssertions(onlyVerified(events), provider.pubkey); } catch { // Provider unreachable — absence, not distrust. return out; } }, enabled: targets.length > 0, staleTime: 60 * 60 * 1000, // 1h gcTime: 24 * 60 * 60 * 1000, retry: 1, }); // Hand each caller only its own subjects out of the shared batch result. const data = useMemo(() => { const full = query.data; if (!full) return undefined; const out = new Map(); for (const target of targets) { const assertion = full.get(target); if (assertion) out.set(target, assertion); } return out; }, [query.data, targets]); return { data, isLoading: query.isLoading, isFetching: query.isFetching }; }