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 (
+
+ );
+}
+
+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