From 0f8c77908b5d7f676c42f02073aac92cad05ea71 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 9 Oct 2026 18:04:16 +0700 Subject: [PATCH] perf(trust): compute the local WoT graph once per viewer TrustBadge called useTrustScore per card, which fetched the viewer's follow list plus up to 100 friends' follow lists for EVERY card. The graph does not depend on the target, so useViewerGraph computes it once per viewer (one shared React Query) and deriveLocalScore returns the same 100/90/60/0 ladder (the NIP-05 30 tier classified as unknown anyway). TrustBadge now costs no per-card network work on top of the batched assertion read. Behaviour preserved; logged-out users still use the house graph. Also records the ANALYZE=1 bundle findings in AGENTS.md. --- AGENTS.md | 15 ++++++- src/hooks/useTrustBucket.ts | 13 ++++-- src/hooks/useViewerGraph.ts | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useViewerGraph.ts diff --git a/AGENTS.md b/AGENTS.md index f32073f..8445364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1914,6 +1914,16 @@ Node 22 + npm 10 · Bun 1.3 · Java 21 · Android SDK at `~/Android/Sdk` · `ngi `/tmp` that got wiped (2026-10-05) — don't recreate it in `/tmp`. Needs the dev server (`skatespots-dev.service`, `:8080`) up, else the frame shows "cannot be reached". - `npm test` = `npm i` + `tsc` + `eslint` + `vitest` + `vite build`. **Must pass before committing.** +- **Bundle analysis (2026-10-09):** `ANALYZE=1 npm run build` runs + `rollup-plugin-visualizer` (already in `vite.config.ts`) and writes machine-readable + `stats.json` — parse `nodeParts[].metaUid` → `nodeMetas[uid].id` for sizes (delete the + file after; it is not committed). Findings: the entry chunk is ~1.9 MB minified + (~584 kB gzip); its single biggest resident is **`src/i18n/en.ts` (~352 kB rendered, + ~11%)**, then react-dom, zod, dexie, vaul. The heavy libs are already correctly + lazy-chunked (`html5-qrcode`/zxing, `xlsx`, `leaflet`, `html2canvas`, `jspdf`, + `coco-cashu`), so don't chase those. The 1.9 MB entry is mostly framework + the + English dictionary + shared UI; shrinking it means trimming those, not re-adding + `manualChunks` (see the TDZ warning above). ### ⚠️ "This page slammed" on *some* routes in dev (dep optimizer cache) Symptom: in the running dev server a few lazy routes (e.g. `/settings`, `/stash`, `/collections`, `/stats`) show the "This page slammed" error boundary while others work. The browser console shows `Failed to fetch dynamically imported module` and the network tab shows **504** for `node_modules/.vite/deps/*.js?v=…`. @@ -2552,7 +2562,10 @@ provider / your provider) and links to a public **`/trust`** explainer; show/hide-marks toggle (`useTrustDisplay`, Flagged is never hidden) and shows the active provider + relay (`useTrustProvider`), and lets the viewer **publish a kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex + -`wss://` relay, or clear to fall back to the community provider). Still open: a +`wss://` relay, or clear to fall back to the community provider). The local +(non-provider) ladder is computed **once per viewer** by `useViewerGraph` and +shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query. +Still open: 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` / diff --git a/src/hooks/useTrustBucket.ts b/src/hooks/useTrustBucket.ts index fbf4ab4..82812a0 100644 --- a/src/hooks/useTrustBucket.ts +++ b/src/hooks/useTrustBucket.ts @@ -1,4 +1,5 @@ -import { useTrustScore } from './useTrustScore'; +import { useCurrentUser } from './useCurrentUser'; +import { useViewerGraph, deriveLocalScore } from './useViewerGraph'; import { useTrustAssertions } from './useTrustAssertions'; import { bucketFromAssertion, bucketFromLocalScore, type TrustBucket } from '@/lib/trustLadder'; import type { UserTrustAssertion } from '@/lib/nip85'; @@ -18,14 +19,20 @@ export interface TrustBucketResult { * Resolve a viewer-facing trust bucket for one pubkey. Prefers a NIP-85 * assertion (it can both verify and flag); otherwise falls back to the local * web-of-trust ladder (verify only — it has no negative signal). + * + * Both inputs are batched/shared: the assertion read is a shared per-viewer + * batch, and the local graph is computed once per viewer — so a grid of badges + * costs no per-card network work. */ export function useTrustBucket(pubkey: string | undefined): TrustBucketResult { - const { data: localScore, isLoading: localLoading } = useTrustScore(pubkey); + const { user } = useCurrentUser(); + const { data: graph, isLoading: graphLoading } = useViewerGraph(); const { data: assertions, isLoading: assertionsLoading } = useTrustAssertions( pubkey ? [pubkey] : [], ); const assertion = pubkey ? assertions?.get(pubkey) : undefined; + const localScore = deriveLocalScore(graph, pubkey, user?.pubkey); const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore); const directFollow = localScore !== undefined && localScore >= 90; @@ -34,6 +41,6 @@ export function useTrustBucket(pubkey: string | undefined): TrustBucketResult { assertion, localScore, directFollow, - isLoading: localLoading || assertionsLoading, + isLoading: graphLoading || assertionsLoading, }; } diff --git a/src/hooks/useViewerGraph.ts b/src/hooks/useViewerGraph.ts new file mode 100644 index 0000000..71eda2d --- /dev/null +++ b/src/hooks/useViewerGraph.ts @@ -0,0 +1,81 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; + +import { useCurrentUser } from './useCurrentUser'; +import { onlyVerified } from '@/lib/verifyEvents'; + +/** Same house perspective the old per-card trust ladder used for logged-out users. */ +const HOUSE_GRAPH_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21'; + +const MAX_FOLLOWS = 100; + +export interface ViewerGraph { + /** Pubkeys the graph owner follows. */ + following: Set; + /** Friends-of-friends: pubkeys followed by someone the owner follows. */ + fof: Set; +} + +/** + * The viewer's follow graph, computed ONCE and shared by every consumer. + * + * WHY: `useTrustScore` fetched the follow list + up to 100 friends' follow lists + * PER CARD. Since the graph does not depend on the target, one query per viewer + * (deduped by React Query across every badge) replaces N per-card queries. + */ +export function useViewerGraph() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const graphOwner = user?.pubkey || HOUSE_GRAPH_PUBKEY; + + return useQuery({ + queryKey: ['viewer-graph', graphOwner], + staleTime: 60 * 60 * 1000, // 1h — the graph changes slowly + gcTime: 24 * 60 * 60 * 1000, + queryFn: async (c): Promise => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + + const followEvents = onlyVerified(await nostr.query( + [{ kinds: [3], authors: [graphOwner], limit: 1 }], + { signal }, + )); + const following = new Set(); + for (const tag of followEvents[0]?.tags ?? []) { + if (tag[0] === 'p' && tag[1]) following.add(tag[1]); + } + + const fof = new Set(); + if (following.size > 0) { + const fofEvents = onlyVerified(await nostr.query( + [{ kinds: [3], authors: [...following].slice(0, MAX_FOLLOWS), limit: MAX_FOLLOWS }], + { signal }, + )); + for (const event of fofEvents) { + for (const [name, value] of event.tags) { + if (name === 'p' && value) fof.add(value); + } + } + } + + return { following, fof }; + }, + }); +} + +/** + * The same 100 / 90 / 60 / 0 ladder the trust badges used, minus the NIP-05 tier + * (30) which classified as `unknown` anyway. `self` is the real viewer only — + * never the house graph owner. + */ +export function deriveLocalScore( + graph: ViewerGraph | undefined, + pubkey: string | undefined, + viewer: string | undefined, +): number | undefined { + if (!pubkey) return undefined; + if (viewer && pubkey === viewer) return 100; + if (!graph) return undefined; + if (graph.following.has(pubkey)) return 90; + if (graph.fof.has(pubkey)) return 60; + return 0; +}