From 2d1144bb7e15dafde66274ee6f6491c4ef39f1a6 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 9 Oct 2026 17:17:11 +0700 Subject: [PATCH] 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). --- AGENTS.md | 17 ++-- TRUST_SCORE.md | 23 +++++ src/components/TrustBadge.tsx | 5 +- src/components/TrustFlaggedNotice.tsx | 119 ++++++++++++++++++++++++++ src/hooks/useTrustAssertions.ts | 111 +++++++++++++++++++++--- src/hooks/useTrustBucket.ts | 4 + src/hooks/useUserReports.ts | 70 +++++++++++++++ src/i18n/en.ts | 7 ++ src/i18n/es.ts | 7 ++ src/lib/trustLadder.ts | 11 +-- src/pages/CoachDetailPage.tsx | 3 + src/pages/ListingDetailPage.tsx | 2 + 12 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 src/components/TrustFlaggedNotice.tsx create mode 100644 src/hooks/useUserReports.ts diff --git a/AGENTS.md b/AGENTS.md index ecf0a81..e2cbbf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/TRUST_SCORE.md b/TRUST_SCORE.md index 0d15cfa..fc32aa5 100644 --- a/TRUST_SCORE.md +++ b/TRUST_SCORE.md @@ -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 diff --git a/src/components/TrustBadge.tsx b/src/components/TrustBadge.tsx index e1e8023..3c1471e 100644 --- a/src/components/TrustBadge.tsx +++ b/src/components/TrustBadge.tsx @@ -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]; diff --git a/src/components/TrustFlaggedNotice.tsx b/src/components/TrustFlaggedNotice.tsx new file mode 100644 index 0000000..2b9c012 --- /dev/null +++ b/src/components/TrustFlaggedNotice.tsx @@ -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 ( +
+
+ +
+

+ {t('walletUi.trust.flaggedNoticeTitle')} +

+

+ {t('walletUi.trust.flaggedNoticeDesc')} +

+ {trustedCount > 0 && ( +

+ {t('walletUi.trust.reportedByTrusted', { count: trustedCount })} +

+ )} + + {(follow > 0 || network > 0 || other > 0) && ( +
+ {follow > 0 && ( + + )} + {network > 0 && ( + + )} + {other > 0 && ( + + )} +
+ )} + +
+ + +
+
+
+ + +
+ ); +} + +function Chip({ label }: { label: string }) { + return ( + + {label} + + ); +} diff --git a/src/hooks/useTrustAssertions.ts b/src/hooks/useTrustAssertions.ts index e1b78d8..f5d2c02 100644 --- a/src/hooks/useTrustAssertions.ts +++ b/src/hooks/useTrustAssertions.ts @@ -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; + 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 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> => { const out = new Map(); - 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(); + 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 }; } diff --git a/src/hooks/useTrustBucket.ts b/src/hooks/useTrustBucket.ts index fe59bc9..fbf4ab4 100644 --- a/src/hooks/useTrustBucket.ts +++ b/src/hooks/useTrustBucket.ts @@ -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, }; } diff --git a/src/hooks/useUserReports.ts b/src/hooks/useUserReports.ts new file mode 100644 index 0000000..f2b1e8e --- /dev/null +++ b/src/hooks/useUserReports.ts @@ -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 => { + 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(); + 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], + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6feafa4..4c2ddd2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index fefc178..bf84b56 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/trustLadder.ts b/src/lib/trustLadder.ts index 148117d..030d960 100644 --- a/src/lib/trustLadder.ts +++ b/src/lib/trustLadder.ts @@ -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'; } diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 036bd74..80d2583 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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() { + + {/* Bio */} {profile.bio && (
diff --git a/src/pages/ListingDetailPage.tsx b/src/pages/ListingDetailPage.tsx index e7ebf17..2487f42 100644 --- a/src/pages/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage.tsx @@ -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() {
+ {sellerInfo && sellerInfo.listingCount > 0 && (

{sellerInfo.listingCount === 1