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).
178 lines
5.9 KiB
TypeScript
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 };
|
|
}
|