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