import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { VOUCH_LABEL_NAMESPACE, VOUCH_LABEL_VALUE, } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; export interface VouchData { /** All voucher pubkeys (deduped, latest label per author). */ vouchers: string[]; /** Total vouch count. */ count: number; /** * Trust-weighted score: sum of each voucher's web-of-trust score (0-100) * relative to the viewer. A ring of untrusted fakes barely moves this. */ weightedScore: number; /** How many vouchers are in the viewer's web of trust (score > 0). */ trustedVouchers: number; /** Whether the current viewer has already vouched. */ viewerVouched: boolean; } /** * Query vouches for a Lab profile coordinate (`kind:pubkey:d`) and compute a * trust-weighted score using the viewer's web of trust. */ export function useVouches(coord: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { user } = useCurrentUser(); const query = useQuery({ queryKey: ['vouches', coord, getQueryKeySuffix()], queryFn: async (): Promise => { if (!coord) return []; const signal = AbortSignal.timeout(8000); const events = await querySkateContent( [ { kinds: [1985], '#a': [coord], '#l': [VOUCH_LABEL_VALUE], limit: 500, }, ], { signal } ); // Latest valid vouch per author. const latest = new Map(); for (const ev of events) { const hasNamespace = ev.tags.some(([n, v]) => n === 'L' && v === VOUCH_LABEL_NAMESPACE); const hasLabel = ev.tags.some(([n, v]) => n === 'l' && v === VOUCH_LABEL_VALUE); const refs = ev.tags.some(([n, v]) => n === 'a' && v === coord); if (!hasNamespace || !hasLabel || !refs) continue; // A retraction is a label with `["l","vouch"]` removed — we simply treat // the newest event; if its content is "retracted" we drop it. const author = ev.pubkey.toLowerCase(); const existing = latest.get(author); if (!existing || ev.created_at > existing.created_at) latest.set(author, ev); } // Drop retracted vouches (content marker) and self-vouches. const [, providerPubkey] = coord.split(':'); const vouchers: string[] = []; for (const [author, ev] of latest) { if (ev.content === 'retracted') continue; if (author === providerPubkey?.toLowerCase()) continue; // no self-vouch vouchers.push(author); } return vouchers; }, enabled: !!coord, staleTime: 30000, }); const vouchers = query.data ?? []; const { data: trustScores } = useBatchTrustScores(vouchers); let weightedScore = 0; let trustedVouchers = 0; for (const pk of vouchers) { const score = trustScores?.get(pk) ?? 0; weightedScore += score; if (score > 0) trustedVouchers += 1; } const data: VouchData = { vouchers, count: vouchers.length, weightedScore, trustedVouchers, viewerVouched: !!user && vouchers.includes(user.pubkey.toLowerCase()), }; return { ...query, data }; } interface VouchParams { /** Provider profile coordinate `kind:pubkey:d`. */ coord: string; /** Provider display name (for readability). */ providerName?: string; /** Set true to retract a previous vouch. */ retract?: boolean; } /** Publish (or retract) a vouch for a Lab provider, signed with the viewer's key. */ export function useVouchFor() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ coord, providerName, retract }: VouchParams) => { if (!user) throw new Error('You must be logged in to vouch'); const [, providerPubkey] = coord.split(':'); if (providerPubkey?.toLowerCase() === user.pubkey.toLowerCase()) { throw new Error("You can't vouch for your own profile"); } const event = await user.signer.signEvent({ kind: 1985, content: retract ? 'retracted' : `Vouches for ${providerName ?? 'this skater'}`, tags: [ ['L', VOUCH_LABEL_NAMESPACE], ['l', VOUCH_LABEL_VALUE, VOUCH_LABEL_NAMESPACE], ['a', coord], ['p', providerPubkey ?? ''], ['alt', retract ? 'Retracted a SkateSpots vouch' : `Vouches for a SkateSpots provider (${providerName ?? coord})`], ], created_at: Math.floor(Date.now() / 1000), }); await nostr.event(event, { signal: AbortSignal.timeout(15000) }); return event; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vouches'] }); }, }); }