SkateSpots/src/hooks/useTrustAssertions.ts
SkateSpots 2d1144bb7e feat(trust): batch provider reads, neutral direct follows, flagged notice
Second pass of TRUST_SCORE.md.

- useTrustAssertions now batches: every TrustBadge registers its subject into
  a shared per-viewer union and ONE query fetches the union (no per-card
  query), with a debounced refetch when a list grows. Same hook API.
- A direct follow (or self) is no longer shown as Verified - only a
  friend-of-friend or a provider assertion counts; Flagged is never
  suppressed.
- useUserReports reads public NIP-56 reports for an account, and
  TrustFlaggedNotice shows a flagged counterparty with the reporters grouped
  by the viewer's network plus Report / Dismiss. Wired into the listing
  seller card and the coach detail page.
- New walletUi.trust.* keys (en/es).
2026-10-09 17:17:11 +07:00

178 lines
5.9 KiB
TypeScript

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<string>;
timer: ReturnType<typeof setTimeout> | null;
}
const batches = new Map<string, PendingBatch>();
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<Map<string, UserTrustAssertion>> => {
const out = new Map<string, UserTrustAssertion>();
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<string, UserTrustAssertion>();
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 };
}