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).
This commit is contained in:
SkateSpots 2026-10-09 17:17:11 +07:00
parent fead9ca781
commit 2d1144bb7e
12 changed files with 354 additions and 25 deletions

View file

@ -2519,12 +2519,17 @@ kind-30382 assertion parsing; absence = unknown, never distrust),
`src/lib/trustLadder.ts` (buckets Verified/Unknown/Flagged;
`FLAGGED_REPORTERS_THRESHOLD` 2), `src/hooks/useTrustBucket.ts`, and
`components/TrustBadge.tsx` now renders the three buckets (Unknown hidden by
default, Flagged always shown). `TrustBadge` is used by the map/tour/event cards,
marketplace card, listing detail and coach detail, so those surfaces get it for
free. Still open: batch the bucket at list level (currently one provider query
per card), a "who reported" breakdown, a provider-choice setting, alerts, and
the confidence-gated scoring model. Check `TRUST_SCORE.md` before touching
`useTrustScore` / `useRelatrScore` / `trustFilter`.
default, Flagged always shown, and Verified suppressed for accounts you already
follow). `TrustBadge` is used by the map/tour/event cards, marketplace card,
listing detail and coach detail, so those surfaces get it for free. The provider
read is **batched** (one query per viewer for the union of on-screen subjects,
in `useTrustAssertions.ts`), and `useUserReports` + `components/TrustFlaggedNotice.tsx`
add a flagged-counterparty notice (reporters grouped by the viewer's network,
Report/Dismiss) wired into the listing seller and coach detail. Still open: a
provider-choice setting, a mute action (no NIP-51 kind-10000 hook exists yet),
counterparty alerts, and the confidence-gated scoring model. Check
`TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
`trustFilter`.
**Keep the door open (cheap):** admin keys and relay sets must stay *data you can
swap*, not constants. Hardcoded `ADMIN_PUBKEYS` is fine now — converting it later

View file

@ -26,6 +26,29 @@ Still open from this slice: precompute the bucket at list level and pass it down
(the assertion carries counts, not identities); and a Settings control to pick
the provider instead of only using the designation or house default.
## Second pass (landed)
- **Batched provider read.** Every `TrustBadge` registers its subject into a
shared per-viewer union and ONE query fetches the whole union (no per-card
query); a list that grows triggers a single debounced refetch. Contained to
`useTrustAssertions.ts` — no call site changed.
- **Direct follow is neutral.** `bucketFromLocalScore` now verifies only a
friend-of-friend (60); a direct follow (90) or self (100) shows nothing, since
the viewer already knows them. A provider assertion can still verify a
non-followed account, and `TrustBadge` suppresses verified when the viewer
directly follows. Flagged is never suppressed.
- **Flagged notice with a breakdown and actions.** `useUserReports` reads the
public NIP-56 reports naming an account; `components/TrustFlaggedNotice.tsx`
shows the reporters grouped as people you follow / in your network / other,
plus **Report** (reuses `ReportDialog`) and **Dismiss** (session-only, so the
safety signal returns). Wired into the listing seller card and the coach
detail page.
Still open: a Settings control to pick the trust provider; a mute action (there
is no NIP-51 kind-10000 hook yet — Dismiss is the local equivalent for now);
counterparty alerts; and the confidence-gated scoring model.
Source material: the **GrapeRank** and **Trusted Assertions** specifications in
the `NosFabrica/protocols` repository, plus a public reference

View file

@ -50,9 +50,12 @@ export function TrustBadge({
showUnknown = false,
}: TrustBadgeProps) {
const { t } = useTranslation();
const { bucket, assertion, isLoading } = useTrustBucket(pubkey);
const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey);
if (isLoading) return null;
// A direct follow (or yourself) is already known to the viewer — a "Verified"
// mark there is redundant. Flagged is a safety signal and is never suppressed.
if (bucket === 'verified' && directFollow) return null;
if (bucket === 'unknown' && !showUnknown) return null;
const { className: bucketClass, Icon } = BUCKET_STYLE[bucket];

View file

@ -0,0 +1,119 @@
import { useState } from 'react';
import { AlertTriangle, Flag } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ReportDialog } from '@/components/ReportDialog';
import { useTrustBucket } from '@/hooks/useTrustBucket';
import { useUserReports } from '@/hooks/useUserReports';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { FLAGGED_REPORTERS_THRESHOLD } from '@/lib/trustLadder';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
interface TrustFlaggedNoticeProps {
pubkey: string;
targetTitle?: string;
className?: string;
}
/**
* A safety notice for a flagged counterparty (seller, coach, organiser). It is
* informational, not an automatic takedown: it shows that trusted people
* reported the account, breaks the reporters down by the viewer's own network,
* and offers Report / Dismiss. Dismissal is session-only — a safety signal
* reappears on the next visit rather than staying hidden forever.
*/
export function TrustFlaggedNotice({ pubkey, targetTitle, className }: TrustFlaggedNoticeProps) {
const { t } = useTranslation();
const { bucket, assertion } = useTrustBucket(pubkey);
const { reporterPubkeys, reporterCount } = useUserReports(pubkey);
const { data: scores } = useBatchTrustScores(reporterPubkeys);
const [dismissed, setDismissed] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const flagged = bucket === 'flagged' || reporterCount >= FLAGGED_REPORTERS_THRESHOLD;
if (!flagged || dismissed) return null;
let follow = 0;
let network = 0;
let other = 0;
for (const pk of reporterPubkeys) {
const score = scores?.get(pk);
if (score !== undefined && score >= 90) follow += 1;
else if (score === 60) network += 1;
else other += 1;
}
const trustedCount = assertion?.reporters ?? reporterCount;
return (
<div className={cn('rounded-2xl border-2 border-red-500/40 bg-red-500/10 p-4', className)}>
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-black uppercase tracking-wide text-red-300">
{t('walletUi.trust.flaggedNoticeTitle')}
</p>
<p className="mt-1 text-xs leading-relaxed text-gray-300">
{t('walletUi.trust.flaggedNoticeDesc')}
</p>
{trustedCount > 0 && (
<p className="mt-1 text-xs text-gray-400">
{t('walletUi.trust.reportedByTrusted', { count: trustedCount })}
</p>
)}
{(follow > 0 || network > 0 || other > 0) && (
<div className="mt-2 flex flex-wrap gap-1.5">
{follow > 0 && (
<Chip label={t('walletUi.trust.flaggedReporterFollow', { count: follow })} />
)}
{network > 0 && (
<Chip label={t('walletUi.trust.flaggedReporterNetwork', { count: network })} />
)}
{other > 0 && (
<Chip label={t('walletUi.trust.flaggedReporterOther', { count: other })} />
)}
</div>
)}
<div className="mt-3 flex gap-2">
<Button
size="sm"
variant="outline"
className="h-8 min-h-0 gap-1.5 border-red-500/40 text-red-300 hover:bg-red-500/10"
onClick={() => setReportOpen(true)}
>
<Flag className="h-3.5 w-3.5" />
{t('walletUi.trust.flaggedReport')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 min-h-0 text-gray-400"
onClick={() => setDismissed(true)}
>
{t('walletUi.trust.flaggedDismiss')}
</Button>
</div>
</div>
</div>
<ReportDialog
open={reportOpen}
onOpenChange={setReportOpen}
targetType="user"
targetPubkey={pubkey}
targetTitle={targetTitle}
/>
</div>
);
}
function Chip({ label }: { label: string }) {
return (
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-200">
{label}
</span>
);
}

View file

@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
@ -15,37 +16,107 @@ import {
type UserTrustAssertion,
} from '@/lib/nip85';
const MAX_TARGETS = 500;
/**
* Read kind-30382 Trusted Assertions (NIP-85) for a batch of subjects, from the
* 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 targets = [...pubkeys].sort().slice(0, MAX_TARGETS);
const viewer = user?.pubkey ?? '';
const key = useMemo(() => [...new Set(pubkeys)].sort().join(','), [pubkeys]);
const targets = useMemo(() => (key ? key.split(',') : []), [key]);
return useQuery({
queryKey: ['trust-assertions', targets.join(','), user?.pubkey],
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>();
if (targets.length === 0) return out;
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 (user?.pubkey) {
if (viewer) {
const designationEvents = onlyVerified(await nostr.query(
[{ kinds: [NIP85_PROVIDER_LIST_KIND], authors: [user.pubkey], limit: 1 }],
[{ kinds: [NIP85_PROVIDER_LIST_KIND], authors: [viewer], limit: 1 }],
{ signal },
));
const designation = parseProviderDesignation(designationEvents[0]);
@ -60,12 +131,12 @@ export function useTrustAssertions(pubkeys: string[]) {
// Designation lookup failed — the house provider is the fallback.
}
// 2. Fetch the assertions for these subjects from the resolved provider.
// 2. Fetch the assertions for the whole batch from the resolved provider.
const filter = {
kinds: [NIP85_USER_ASSERTION_KIND],
authors: [provider.pubkey],
'#d': targets,
limit: targets.length,
'#d': all,
limit: all.length,
};
try {
@ -90,4 +161,18 @@ export function useTrustAssertions(pubkeys: string[]) {
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 };
}

View file

@ -9,6 +9,8 @@ export interface TrustBucketResult {
assertion?: UserTrustAssertion;
/** Local web-of-trust score (0-100), when available. */
localScore?: number;
/** The viewer directly follows this account (or it is the viewer). */
directFollow: boolean;
isLoading: boolean;
}
@ -25,11 +27,13 @@ export function useTrustBucket(pubkey: string | undefined): TrustBucketResult {
const assertion = pubkey ? assertions?.get(pubkey) : undefined;
const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore);
const directFollow = localScore !== undefined && localScore >= 90;
return {
bucket,
assertion,
localScore,
directFollow,
isLoading: localLoading || assertionsLoading,
};
}

View file

@ -0,0 +1,70 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { onlyVerified } from '@/lib/verifyEvents';
import type { NostrEvent } from '@nostrify/nostrify';
/** NIP-56 report kind. */
export const REPORT_KIND = 1984;
export interface UserReport {
id: string;
/** The reporter. */
pubkey: string;
createdAt: number;
content: string;
/** NIP-56 reason from the `p` tag, when present. */
reason?: string;
}
/**
* Public user-level reports (NIP-56) that name this pubkey in a `p` tag, read
* from the community relay + the viewer's relays. This is the auditable side of
* a "flagged" account: the provider asserts a count, but the raw reports are
* public, so we can show who said it in aggregate.
*/
export function useUserReports(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const query = useQuery({
queryKey: ['user-reports', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 60_000,
queryFn: async (c): Promise<UserReport[]> => {
if (!pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const events = onlyVerified(await querySkateContent(
[{ kinds: [REPORT_KIND], '#p': [pubkey], limit: 200 }],
{ signal },
));
return events.map(toUserReport).filter((r): r is UserReport => r !== null);
},
});
const reporters = useMemo(() => {
const set = new Set<string>();
for (const report of query.data ?? []) set.add(report.pubkey);
return [...set];
}, [query.data]);
return {
reports: query.data ?? [],
reporterPubkeys: reporters,
/** Distinct reporters — the anti-brigade unit, same as content reports. */
reporterCount: reporters.length,
isLoading: query.isLoading,
};
}
function toUserReport(event: NostrEvent): UserReport | null {
const pTag = event.tags.find(([name]) => name === 'p');
if (!pTag) return null;
return {
id: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
reason: pTag[2],
};
}

View file

@ -6143,6 +6143,13 @@ walletUi: {
top10Hint: 'Reach the top 10 supporters',
},
trust: {
flaggedNoticeTitle: 'Reported by trusted skaters',
flaggedNoticeDesc: 'Take care. Trusted people in the network have reported this account.',
flaggedReporterFollow: '{{count}} you follow',
flaggedReporterNetwork: '{{count}} in your network',
flaggedReporterOther: '{{count}} other accounts',
flaggedReport: 'Report',
flaggedDismiss: 'Dismiss',
bucketVerified: 'Verified',
bucketUnknown: 'Unknown',
bucketFlagged: 'Flagged',

View file

@ -6143,6 +6143,13 @@ walletUi: {
top10Hint: 'Entra en el top 10 de patrocinadores',
},
trust: {
flaggedNoticeTitle: 'Reportada por patinadores de confianza',
flaggedNoticeDesc: 'Ten cuidado. Personas de confianza de la red han reportado esta cuenta.',
flaggedReporterFollow: '{{count}} que sigues',
flaggedReporterNetwork: '{{count}} de tu red',
flaggedReporterOther: '{{count}} otras cuentas',
flaggedReport: 'Reportar',
flaggedDismiss: 'Descartar',
bucketVerified: 'Verificado',
bucketUnknown: 'Desconocido',
bucketFlagged: 'Marcado',

View file

@ -35,13 +35,14 @@ export function bucketFromAssertion(assertion: UserTrustAssertion): TrustBucket
}
/**
* Bucket from the local WoT ladder (used when no provider assertion exists):
* `>= 60` means the viewer follows them or a friend-of-friend does — i.e.
* someone in the network already vouches. NIP-05-only (30) and unknown (0)
* stay unknown. The local ladder has no negative signal, so it never flags.
* Bucket from the local WoT ladder (used when no provider assertion exists).
* Only a friend-of-friend (60) counts as a network vouch — someone you did not
* pick vouches for them. A direct follow (90) or yourself (100) is neutral: a
* "Verified" badge there tells the viewer something they already did, and it
* would dilute the signal. The ladder has no negative signal, so it never flags.
*/
export function bucketFromLocalScore(score: number | undefined): TrustBucket {
if (score !== undefined && score >= 60) return 'verified';
if (score === 60) return 'verified';
return 'unknown';
}

View file

@ -16,6 +16,7 @@ import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { VouchCard } from '@/components/VouchCard';
import { LinkedCrewCard } from '@/components/CrossLinkCards';
import { TrustBadge } from '@/components/TrustBadge';
import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useGigs } from '@/hooks/useGigs';
import { useAuthor } from '@/hooks/useAuthor';
@ -422,6 +423,8 @@ export default function CoachDetailPage() {
</div>
</div>
<TrustFlaggedNotice pubkey={profile.pubkey} targetTitle={profile.title} className="mb-6" />
{/* Bio */}
{profile.bio && (
<div className="mb-6">

View file

@ -10,6 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { TrustBadge } from '@/components/TrustBadge';
import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
import { TradeReputationCard } from '@/components/TradeReputationCard';
import { ListingTradeActions } from '@/components/ListingTradeActions';
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
@ -288,6 +289,7 @@ export default function ListingDetailPage() {
</div>
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} className="ml-auto" />
</Link>
<TrustFlaggedNotice pubkey={item.pubkey} targetTitle={displayName} />
{sellerInfo && sellerInfo.listingCount > 0 && (
<p className="text-xs text-gray-500">
{sellerInfo.listingCount === 1