backdrop is the live implementation.
+
+### 68. .skip-nav hardcodes z-index: 9999 instead of its own --z-skip-nav token
+
+- **id:** `skip-nav-z-literal` · **track:** T11-css
+- **severity / effort / regression risk:** low (adjusted from low) / S / low
+- **location:** `src/app/styles/tokens.css:13`
+- **evidence:** tokens.css:13 `.skip-nav { ... z-index: 9999;` while the very same file defines `--z-skip-nav: 9999; /* skip-nav link */` at line 217. If the scale is ever re-numbered the rule silently detaches from the token.
+- **fix:** Change tokens.css:13 to `z-index: var(--z-skip-nav);`. Custom properties resolve at computed-value time, so the later declaration order within the file is irrelevant. Value is identical — zero visual delta.
+
+### 69. Error focus ring hardcodes rgba(185,28,28,.15) — near-invisible in dark mode and a raw-rgba violation
+
+- **id:** `error-focus-ring-rgba` · **track:** T11-css
+- **severity / effort / regression risk:** medium (adjusted from medium) / S / low
+- **location:** `src/app/styles/components.css:127`
+- **evidence:** components.css:127 `.delete-record-dialog__input:focus { ... box-shadow: 0 0 0 2px rgba(185, 28, 28, 0.15); }` and cert-detail.css:2019 `.create-cert__contrib-id-input--invalid:focus { border-color: var(--color-error); box-shadow: 0 0 0 2px rgba(185, 28, 28, 0.15); }`. This violates both the no-raw-rgba rule and dark mode: the dark theme's error color is `--color-error: #f87171` (tokens.css:315) with paired surfaces `--color-error-bg: rgba(248, 113, 113, 0.12)` / `--color-error-border: rgba(248, 113, 113, 0.3)` (tokens.css:318-319), but this ring stays a dark red at 15% alpha on a near-black background — effectively invisible, so the error-focus indication is lost in dark mode. Note the alpha color 185,28,28 (#b91c1c) doesn't even match the light token #ba1a1a.
+- **fix:** Add a paired token in tokens.css next to --color-error-bg/border in BOTH themes: light `--color-error-ring: rgba(186, 26, 26, 0.15);`, dark `--color-error-ring: rgba(248, 113, 113, 0.25);`. Replace both box-shadows with `box-shadow: 0 0 0 2px var(--color-error-ring);` (the `0 0 0 2px var(--token)` focus-ring geometry is the established convention, cf. settings-page.css:137). Light-mode rendering is pixel-identical modulo the 1-unit red-channel correction.
+
+### 70. Three var(--color-error, #b91c1c) fallbacks embed a raw, wrong-shade hex outside tokens.css
+
+- **id:** `color-error-hex-fallbacks` · **track:** T11-css
+- **severity / effort / regression risk:** low (adjusted from low) / S / low
+- **location:** `src/app/styles/components.css:79`
+- **evidence:** components.css:79 `color: var(--color-error, #b91c1c);`, :126 `border-color: var(--color-error, #b91c1c);`, :133 `color: var(--color-error, #b91c1c);`. The fallback is a raw hex outside tokens.css/landing.css (rule 2), is dead code (--color-error is unconditionally defined in tokens.css:130/315, imported first in globals.css), and is a different red (#b91c1c) than the actual token (#ba1a1a) — so if it ever did fire it would render off-palette. These are the only `var(--color-error, ...)` fallbacks in src/app/styles/.
+- **fix:** Drop the fallbacks: `var(--color-error)` in all three places. No visual change.
+
+### 71. tailwind.config.ts defines error/success/warning as raw hex disconnected from tokens; text-error breaks palette + dark mode
+
+- **id:** `tailwind-status-colors-raw-hex` · **track:** T11-css
+- **severity / effort / regression risk:** medium (adjusted from medium) / S / low
+- **location:** `tailwind.config.ts:27`
+- **evidence:** tailwind.config.ts:25-29 `colors: { success: "#2ECC71", warning: "#F5A623", error: "#E74C3C" }`. The one live consumer, ui/checkbox.tsx:105 ``, therefore renders #E74C3C in BOTH themes — a different red than every other error message (`--color-error`: #ba1a1a light / #f87171 dark), so checkbox validation errors are visibly off-palette next to CSS-styled errors and don't participate in the dark-mode error lightening.
+- **fix:** Point the Tailwind semantic colors at the tokens: `error: "var(--color-error)"` (and either delete unused `success`/`warning` — grep shows no `text-success`/`text-warning`/`bg-success` etc. usages — or map them to new `--color-success`/`--color-warning` tokens if kept). checkbox.tsx needs no change; `text-error` then resolves theme-aware. Verify with `npm run lint` + a dark-mode render of a checkbox error.
+
+### 72. Modal chrome shadow 0 24px 64px var(--navy-overlay-30) is a literal repeated in three places instead of a --shadow-* token
+
+- **id:** `modal-shadow-literal-geometry` · **track:** T11-css
+- **severity / effort / regression risk:** low (adjusted from low) / S / low
+- **location:** `src/app/styles/components.css:179`
+- **evidence:** components.css:179 `.feedback-modal { ... box-shadow: 0 24px 64px var(--navy-overlay-30);`, components.css:768 `.domain-modal { ... box-shadow: 0 24px 64px var(--navy-overlay-30);`, and app-dialog.tsx:376 `shadow-[0_24px_64px_var(--navy-overlay-30)]`. Rule 4 says shadows are `var(--shadow-sm|md|lg)`; this is a fourth, unofficial elevation defined by copy-paste. (The color half is already tokenized; only the geometry is literal.)
+- **fix:** Add `--shadow-modal: 0 24px 64px var(--navy-overlay-30);` to tokens.css beside --shadow-lg (single definition, both themes inherit since --navy-overlay-30 is an invariant scrim token), then use `box-shadow: var(--shadow-modal);` in components.css:179/768 and `shadow-[var(--shadow-modal)]` in app-dialog.tsx:376. Pixel-identical, removes the triplication. (Do NOT swap to --shadow-lg — that would visibly change the desktop modal baseline.)
+- **skeptic fix correction:** The fix's claim that --navy-overlay-30 is "an invariant scrim token" is false: tokens.css:84 sets rgba(0,0,0,0.3) in light and tokens.css:274 (inside :root[data-theme="dark"], line 237) sets rgba(0,0,0,0.5) in dark. The single-definition approach still works only because the dark override is on :root itself, so var() substitution on resolves theme-correctly — implement as proposed but do not document the token as invariant (and do not move the --shadow-modal definition into a non-root scope, which would freeze the light value). Optionally also replace the fourth copy at src/app/styles/landing.css:1532 (duplicated .feedback-modal rule); landing.css is an allowed literal zone but leaving it keeps one copy of the literal alive.
+
+### 73. Nine border-radius rules hardcode 2px/1px instead of var(--radius)
+
+- **id:** `hardcoded-2px-radius` · **track:** T11-css
+- **severity / effort / regression risk:** low (adjusted from low) / S / low
+- **location:** `src/app/styles/settings-page.css:557`
+- **evidence:** grep for non-token radii finds: profile-groups.css:270, feed.css:1448, feed.css:1510, layout.css:1323, layout.css:1344, layout.css:1375, layout.css:3358, profile.css:299 — all `border-radius: 2px;` — and settings-page.css:557 `border-radius: 1px;`. 2px happens to equal var(--radius) today so they pass the 4/6/8/12/16/20 grep, but they detach from the token (a future radius change won't propagate) and the 1px value is off-scale entirely.
+- **fix:** Replace all nine with `border-radius: var(--radius);`. The eight 2px cases are pixel-identical; the settings-page.css:557 1px->2px case is a sub-pixel change on (inspect the selector first — it looks like a slider/track detail) with no perceptible impact. Re-run check #1 from CLAUDE.md afterwards.
+
+## Refuted (1)
+
+- `indexer-post-sequential-ratelimit-body` (perf-server-cache): The code matches the quote (route.ts:1716 awaits enforceRateLimit, :1729 awaits request.text(); the limiter is a real Upstash REST incr via getRedis in src/lib/auth/stores.ts), but the claimed impact does not survive scrutiny. The rate-limit result must be awaited before any upstream work either way — the fix keeps `if (rateDenied) return rateDenied` before parsing — so Promise.all changes total latency from `limiter + bodyRead` to `max(limiter, bodyRead)`; the saving is only min(limiter, bodyRead). The finding's own evidence states the ≤32KB body "is already buffered by the platform", making request.text() a sub-millisecond microtask, so the expected saving is ~0ms, not the Upstash round-trip's "full latency" as the title/evidence imply. The Upstash latency stays fully on the critical path after the fix; the framing that serialization "adds its full latency to every feed/explore/profile RPC" attributes an inherent cost of an awaited distributed limiter to instruction ordering. Additionally, the sketched fix hoists request.text() out of the existing try/catch (route.ts:1728-1738, failure → 400 "Invalid JSON"), so a client abort mid-body inside Promise.all would surface as an unhandled 500 unless re-wrapped — a small behavior change bought for no measurable gain.
+
+## Lint triage (all 63 warnings)
+
+Classification: 39 fixable-cleanly, 23 suppress-with-justification, 1 latent-bug.
+
+Exhaustive triage of all 63 lint warnings (54 react-hooks/set-state-in-effect, 2 react-hooks/exhaustive-deps, 6 @next/next/no-img-element, 1 unused eslint-disable). Every site was read in context; evidence and per-group fixes below.
+
+GROUPS (batchable, identical mechanical transform per group):
+
+1) fetch-reset-compare-prev-key (22 warnings, fixable-cleanly, effort S each, regression risk low): the dominant family. Keyed fetch effects that synchronously reset data/loading/error at the top of the effect (null-key branch reset + setLoading(true)/setError(null) before the async call), e.g. use-author-info.ts:43-53 `if (!did) { setInfo(null); setIsLoading(false); setError(null); return } ... setIsLoading(true); setError(null)`. Fix is the React-docs "adjusting state during render" transform: hold `const [prevKey, setPrevKey] = useState(key)`; during render, `if (prevKey !== key) { setPrevKey(key); }`; the effect keeps only the fetch + async-callback setStates. Where a refetch nonce exists (use-activity-funding refreshNonce, use-context-updates reloadKey, use-org-marker refreshTick) the key must include it. Two special cases inside this group: use-session.ts:105 — the module-cache clears (`cachedPromise = null; cachedResult = null`) MUST stay in the effect (render must remain pure); only the four setStates move to the render-time adjust. use-own-certs.ts:35-37 has an adjacent latent bug the fix should also cover: when `sourceDid` is null the effect returns without ever setting isLoading false, so `isLoading` (initial true) is stuck true forever for signed-out consumers.
+
+2) delete-redundant-sync-reset (3, fixable-cleanly, S, risk low): sync setStates that duplicate what the useState initializer already produced, in effects that run once per mount. activity-edit-route.tsx:251-252 (`setRightsLoading(true); setRightsLoadError(null)` in a []-dep effect where initials are already true/null — delete both lines); use-workspace.ts:34-38 (cache-hit branch `setActors(actorsCache); setIsLoading(false)` in a []-dep effect whose initializers already read the same cache — delete the two sets, keep the early return); use-network-counts.ts:72 (`setIsLoading(true)` in a []-dep effect; initial is `!cache` — deleting it also stops flipping the landing stats to aria-busy during a cache-hit background refresh; sole consumer network-stats.tsx:79 only feeds aria-busy).
+
+3) reset-on-prop-change-compare-prev (2, fixable-cleanly, S, low): profile-header.tsx:90-92 (reset bannerFailed when bannerUrl changes) and use-bottom-sheet-drag.ts:46-48 (reset sheetExpanded when isOpen goes false) — replace the effect with the inline compare-previous-value render adjustment.
+
+4) seed-form-once-suppress (3, suppress, S): activity-edit-route.tsx:302-340 and project-edit-route.tsx:153-177 + 184-203 are ref-guarded one-shot seedings of editable form state from an async-loaded record (seededRef/itemsSeededRef prevent re-runs; a snapshot cid is captured for swap-record conflict detection). The clean alternative is a key-remount refactor (extract the form into a child rendered only once the record has loaded, seeding via useState initializers, key={record.uri}) but that restructures two ~500-line edit routes; suppress now with comment "one-shot ref-guarded seeding of editable form state after async record load", track key-remount separately.
+
+5) error-watcher-suppress (2, suppress, S): activity-edit-route.tsx:403-417 and project-edit-route.tsx:268-278 clear the save error when any field changes. setError(null) bails out (no re-render) whenever error is already null, so the effect costs one extra render only in the showing-an-error case, which is the intended UX. Moving it into event handlers means wrapping ~10 setters each.
+
+6) close-on-external-nav-suppress (5, suppress, S): desktop-top-bar.tsx:269-271 + 325-327, navbar.tsx:79-82 + 87-90, tour-context.tsx:71-77 — closing/resetting UI state in response to pathname, breakpoint crossing, or sign-out. All setStates bail out when already closed/reset; event-handler alternatives miss browser back/forward and resize paths. Suppress with one-line justification each.
+
+7) auto-decision-latch-suppress (2, suppress, S): onboarding-context.tsx:139-148 and tour-context.tsx:83-93 — once-per-DID auto-popup/auto-start decisions fired when async gate data settles; a did-keyed latch prevents loops. There is no user event to move them into.
+
+8) derive-during-render (2, fixable-cleanly, S, low): activity-detail.tsx:1828-1845 useRouteRkey stores window.location's last path segment in state via effect — replace the whole hook with `useParams()`/`usePathname()` (available during render incl. SSR) + useMemo decode; deletes the state and effect. tour-context.tsx:100-103 clamps stepIndex when steps.length shrinks — instead derive `const effectiveStepIndex = Math.min(stepIndex, Math.max(0, steps.length - 1))` at the point `step` is computed and delete the effect.
+
+9) mounted-useSyncExternalStore (2, fixable-cleanly, S, low): use-mounted.ts:19-25 becomes `useSyncExternalStore(emptySubscribe, () => true, () => false)` (module-level noop subscribe); theme-toggle.tsx:59-63 duplicates the mounted-flag pattern inline — replace with the shared useMounted() (which the hook's own docstring says it was consolidated for).
+
+10) matchmedia-useSyncExternalStore (1, fixable-cleanly, S, low): tooltip.tsx:71-82 useHoverCapable — subscribe = mq change listener, getSnapshot = mq.matches, getServerSnapshot = false. Textbook case.
+
+11) move-to-event-handler (3, fixable-cleanly, S, low-med): add-to-list-menu.tsx:100-102 — reset `copied` inside the Popover's onOpenChange (line 125) instead of watching `open`. tooltip.tsx:154-156 — move setCoords(null) into hide() (the only path that sets open false); keep the measurement setCoords (legit DOM read). desktop-top-bar.tsx:274-277 — compute the initial createAnchor in the "+" button's open click handler and drop the sync `setCreateAnchor(null)` (gate the portal render on `createOpen && createAnchor`; stale anchor while closed is unobservable); the resize/scroll subscription setStates are already rule-clean.
+
+12) debounce-suggest-clear-suppress (2, suppress, S): location-picker-dialog.tsx:176-194 and profile-overview.tsx:758-782 — debounced geocode-suggestion effects whose sync `setSuggestions([])` clears now-stale suggestions when input drops below 2 chars (or the change was map-originated via lastSourceRef). setState bails when already empty; scattering the clears across input onChange / pickSuggestion / map-click handlers risks missing a path. Suppress with justification.
+
+13) animation-driver / pipeline / cache-peek suppressions (3, suppress, S): network-stats.tsx:122-167 useCountUp (rAF count-up driver; the sync setDisplay covers prefers-reduced-motion and zero-delta terminal states). use-explore.ts:318-322 (generation-ref-guarded multi-input fetch pipeline; a compare-prev key would have to composite ~15 inputs, duplicating the dep list for no behavior change). use-pending-awards-count.ts:51-60 — looks like a useSyncExternalStore candidate (focus-event subscription over a module cache) but is NOT safe to convert: peekCachedReceivedEndorsements returns mergeOverlay(...) (use-received-endorsements.ts:282-293) which builds a fresh array whenever overlays exist, so an uncached getSnapshot would loop. Suppress with exactly that reason.
+
+14) state-to-ref-latch (1, fixable-cleanly, S, low): workspace.tsx:87-96 — `defaulted` is only ever read inside the effect (verified via grep: lines 87/89/96 are its only uses), so it should be a useRef latch, removing the setState entirely; the router.replace is legitimate external-system sync.
+
+15) pressedvalues-usememo (2 exhaustive-deps, fixable-cleanly, S, low): response-buttons.tsx:103 and response-menu.tsx:116 — lint message verified: "The 'pressedValues' conditional could make the dependencies of useCallback Hook change on every render... wrap in its own useMemo()". Wrap the ternary in useMemo keyed on the state flags (or build the Set from `state` inside onValueChange and depend on `state`).
+
+16) img-static-svg-disable (3, suppress, S): desktop-top-bar.tsx:385 + 566 and mobile-sidebar.tsx:123 render same-origin static SVGs (/brand/*.svg). next/image performs no optimization on SVG sources and these are CSS-sized (no CLS); add eslint-disable-next-line with reason "static same-origin SVG; next/image does not optimize SVGs". Matches the existing codebase precedent at profile-header.tsx:108.
+
+17) img-dynamic-origin-disable (3, suppress, S): onboarding-modal.tsx:168 and step-profile.tsx:93 + 104 render blob: object URLs (URL.createObjectURL of user-picked files) and/or arbitrary bsky-CDN/PDS avatar/banner URLs. next.config.ts images.remotePatterns (lines 12-19) allows only `https://**.certified.app`, and blob: URLs are unsupported by next/image entirely — disable with that reason.
+
+18) remove-unused-disable (1, fixable-cleanly — THE one autofixable): app-dialog.tsx:331 "Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps')" — delete the comment line.
+
+LATENT BUG (1): onboarding-modal.tsx:69 — the seed effect's comment (lines 60-62) promises "Subsequent re-opens (banner clicks) keep whatever the user already typed", but lines 83-85 reset seededForDid.current to null whenever isOpen goes false, so the guard at line 67 passes again on every re-open and the seed effect CLOBBERS the user's typed draft with the bsky seed. Trigger: open onboarding, type a display name/description, close (dismiss), click the banner to re-open → draft reset. Fix direction must first decide intent: either delete the lines 83-85 reset (making the comment true) or fix the comment; the structural cleanup is a key-remount child (key={did}) gated on isOpen, seeded via useState initializers, which reproduces whichever semantics is chosen explicitly. Do NOT blindly apply a mechanical fix here.
+
+ADJACENT ISSUES noticed while reading (not among the 63, listed since the cap dropped nothing): (a) onboarding-modal.tsx:152-154 calls URL.createObjectURL during render of the success screen — leaks one object URL per re-render; (b) use-own-certs stuck-isLoading described in group 1; (c) desktop-top-bar/mobile-sidebar hardcode certified_wordmark_black.svg (dark-mode audit already tracks pinned assets). Warning count verified: 54+2+6+1 = 63; no cap applied — all 63 classified.
+
+| file:line | rule | classification | group | fix |
+|---|---|---|---|---|
+| `src/components/landing/sections/network-stats.tsx:135` | set-state-in-effect | suppress-with-justification | animation-driver-suppress | eslint-disable with reason: rAF count-up animation driver; the sync setDisplay writes the terminal value for prefers-reduced-motion and zero-delta cases — the effect IS the animation's external-system sync |
+| `src/lib/onboarding/onboarding-context.tsx:147` | set-state-in-effect | suppress-with-justification | auto-decision-latch-suppress | eslint-disable with reason: once-per-DID auto-popup decision fired when async resolve-did gate data settles; autoPoppedDid latch prevents re-fire; no user event exists to host this |
+| `src/lib/tour/tour-context.tsx:87` | set-state-in-effect | suppress-with-justification | auto-decision-latch-suppress | eslint-disable with reason: once-per-DID auto-start decision (pending-flag check + clear) on auth/org settle; autoCheckedDid latch prevents re-fire |
+| `src/components/layout/desktop-top-bar.tsx:270` | set-state-in-effect | suppress-with-justification | close-on-external-nav-suppress | eslint-disable with reason: close switcher on route change (external router input); covers back/forward nav that no click handler sees; setState bails out when already closed |
+| `src/components/layout/desktop-top-bar.tsx:326` | set-state-in-effect | suppress-with-justification | close-on-external-nav-suppress | eslint-disable with reason: close create-menu on route change; same rationale as the switcher close |
+| `src/components/layout/navbar.tsx:80` | set-state-in-effect | suppress-with-justification | close-on-external-nav-suppress | eslint-disable with reason: close dropdown/switcher on route change (external router input); both setStates bail out when already false |
+| `src/components/layout/navbar.tsx:88` | set-state-in-effect | suppress-with-justification | close-on-external-nav-suppress | eslint-disable with reason: clear sheet/sidebar state when crossing the 800px mobile-desktop boundary (external matchMedia input) so leftover open-state can't re-open the drawer on resize back down; bails out when already false |
+| `src/lib/tour/tour-context.tsx:73` | set-state-in-effect | suppress-with-justification | close-on-external-nav-suppress | eslint-disable with reason: reset tour state on sign-out (external auth input); all three setStates bail out when already reset |
+| `src/components/create/location-picker-dialog.tsx:180` | set-state-in-effect | suppress-with-justification | debounce-suggest-clear-suppress | eslint-disable with reason: debounced geocode effect keyed on typed name; the sync setSuggestions([]) clears now-stale suggestions when input drops below 2 chars and bails out when already empty; moving it into onChange/pick/map handlers risks missing a write path |
+| `src/components/profile/profile-overview.tsx:761` | set-state-in-effect | suppress-with-justification | debounce-suggest-clear-suppress | eslint-disable with reason: same debounced-suggestions clear, plus the lastSourceRef map-originated skip branch; both clears bail out when suggestions already empty |
+| `src/components/feed/activity-edit-route.tsx:251` | set-state-in-effect | fixable-cleanly | delete-redundant-sync-reset | delete lines 251-252 (`setRightsLoading(true); setRightsLoadError(null)`) — the effect has [] deps and the useState initializers (lines 225-226) are already true/null; the sync sets are pure no-ops |
+| `src/hooks/use-workspace.ts:35` | set-state-in-effect | fixable-cleanly | delete-redundant-sync-reset | delete the cache-hit branch's `setActors(actorsCache); setIsLoading(false)` (lines 35-36), keep the early return — the []-dep effect runs immediately after mount and the initializers (lines 28-31) already read the same cache; both sets bail out as no-ops |
+| `src/hooks/use-network-counts.ts:72` | set-state-in-effect | fixable-cleanly | delete-redundant-sync-reset | delete `setIsLoading(true)` — []-dep effect, initial state is already `!cache`; deletion also stops flipping isLoading true during a cache-hit background refresh (sole consumer network-stats.tsx line 79 only feeds aria-busy, so this is a strict improvement) |
+| `src/components/feed/activity-detail.tsx:1835` | set-state-in-effect | fixable-cleanly | derive-during-render | derive-during-render: replace useRouteRkey's window.location + state + effect with next/navigation useParams()/usePathname() and a useMemo'd decodeURIComponent of the last segment; deletes the state and effect entirely (matches how the page itself already normalises) |
+| `src/lib/tour/tour-context.tsx:102` | set-state-in-effect | fixable-cleanly | derive-during-render | derive-during-render: delete the clamp effect and compute `const effectiveStepIndex = Math.min(stepIndex, Math.max(0, steps.length - 1))` where `step` is derived; stored stepIndex no longer needs clamping |
+| `src/components/feed/activity-edit-route.tsx:404` | set-state-in-effect | suppress-with-justification | error-watcher-suppress | eslint-disable with reason: deliberate cross-field watcher clearing the save error on any edit; setError(null) bails out (no render) whenever error is already null, so cost is one render only while an error is showing |
+| `src/components/project/project-edit-route.tsx:269` | set-state-in-effect | suppress-with-justification | error-watcher-suppress | eslint-disable with reason: same deliberate clear-error-on-edit watcher as activity-edit-route; bails out when error is already null |
+| `src/hooks/use-explore.ts:322` | set-state-in-effect | suppress-with-justification | explore-pipeline-suppress | eslint-disable with reason: generation-ref-guarded multi-input fetch pipeline; the sync setState({...EMPTY, isLoading:true}) is the keyed loading flip and a compare-prev key would have to composite ~15 filter inputs, duplicating the dep list for no behavior change |
+| `src/hooks/use-pending-awards-count.ts:52` | set-state-in-effect | suppress-with-justification | external-cache-peek-suppress | eslint-disable with reason: focus-subscription peek of a module cache; do NOT convert to useSyncExternalStore — peekCachedReceivedEndorsements returns mergeOverlay(...) (use-received-endorsements.ts:282-293) which allocates a fresh array whenever overlays exist, so an uncached getSnapshot would infinite-loop |
+| `src/app/[actor]/[type]/[rkey]/update/[updateRkey]/edit/page.tsx:60` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline: reset loading/loadError during render when `${did}\|${updateRkey}` changes (React docs adjusting-state-during-render); keep only the getContextAttachment async setStates in the effect |
+| `src/app/[actor]/[type]/[rkey]/update/new/page.tsx:53` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline: flip cidResolving during render when subjectUri changes; effect keeps only resolveRecordCid + async callbacks |
+| `src/app/settings/edit-profile/page.tsx:106` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on did: render-time reset of orgMarker/isOrg/orgLoaded for the null-did branch; fetchOwnOrgMarker callbacks stay in the effect |
+| `src/components/create/location-picker-dialog.tsx:104` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on ownDid: render-time setMyLocationsLoading(true)/setMyLocationsError(null) when the key changes; listRecords callbacks stay in the effect |
+| `src/hooks/use-activity-funding.ts:37` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline with key = `${did}\|${rkey}\|${first}\|${refreshNonce}` (nonce included so refetch() still flips isLoading); null-key branch resets move to the render adjust |
+| `src/hooks/use-author-info.ts:45` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on did: render-time reset of info/isLoading/error mirroring the initializers; fetchAuthor callbacks stay in the effect |
+| `src/hooks/use-cert-projects.ts:46` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on `${did}\|${rkey}`: render-time reset of projects/isLoading/error; indexer fetch callbacks stay in the effect |
+| `src/hooks/use-context-updates.ts:58` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline with key = `${subjectUri}\|${reloadKey}` covering both null-subject and unparseable-URI reset branches; fetchContextUpdates callbacks stay in the effect |
+| `src/hooks/use-contributor-info.ts:78` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on `${trimmed}\|${isAtproto}`: render-time reset of info/isLoading (isLoading initializer already computes from isAtproto) |
+| `src/hooks/use-contributor-information-record.ts:87` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on uri/enabled mirroring the initializers; fetchByUri callback stays in the effect |
+| `src/hooks/use-evaluator-endorsements.ts:28` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on selectedKey (already a memoized string): render-time reset for empty-selection + loading flip; fetch callbacks stay in the effect |
+| `src/hooks/use-location.ts:57` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on uri: render-time setIsLoading(true) when uri changes (initial state already true for first mount) |
+| `src/hooks/use-org-marker.ts:161` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on `${did}\|${refreshTick}`: render adjust re-runs the existing cache-aware initializer expressions (cache.get/has); cache eviction on refreshTick>0 and fetchOrgMarker stay in the effect (module-map mutation must not happen during render) |
+| `src/hooks/use-own-certs.ts:39` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on sourceDid (activeOrg?.groupDid ?? did); ALSO fix adjacent latent bug: the `if (!sourceDid) return` path (line 37) never sets isLoading false, leaving it stuck true from its initial value — the render adjust should set isLoading = !!sourceDid |
+| `src/hooks/use-profile-pds.ts:31` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on did re-running the cache-aware initializer expressions; inflight-map bookkeeping and resolvePdsUrl stay in the effect |
+| `src/hooks/use-rights.ts:73` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on uri: render-time reset of record/isLoading mirroring initializers; fetchRights callback stays in the effect |
+| `src/hooks/use-session.ts:105` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on isAuthenticated for the four setStates ONLY; the module-cache clears (cachedPromise/cachedResult = null, lines 98-99) MUST remain in the effect — mutating module state during render is impure. Cached-result fast path (lines 113-118) also folds into the render adjust |
+| `src/hooks/use-workspace.ts:77` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on did: render-time reset to EMPTY_COUNTS + isLoading flip; fetchActorWorkspaceCounts callbacks stay in the effect |
+| `src/lib/groups/use-org-limit.ts:22` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on `${did}\|${orgsLoading}`: render-time isChecking adjust for the settled-but-signed-out branch and the fetch-start flip; getSelfCreatedOrgCount callbacks stay in the effect |
+| `src/lib/onboarding/onboarding-context.tsx:103` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on `${isAuthenticated}\|${did}`: render-time reset of state/isOpen/autoPoppedDid for the signed-out branch; the resolve-did fetch stays in the effect |
+| `src/components/project/project-edit-route.tsx:212` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on project identity: the no-location `setLocation(null)` branch moves to the render adjust; the getRecord hydration callbacks stay in the effect |
+| `src/components/feed/activity-edit-route.tsx:351` | set-state-in-effect | fixable-cleanly | fetch-reset-compare-prev-key | compare-prev-key-inline on activity identity: the refs-empty `setLocations([])` branch moves to the render adjust (initial state is already []); the Promise.all name hydration stays in the effect |
+| `src/components/onboarding/onboarding-modal.tsx:168` | no-img-element | suppress-with-justification | img-dynamic-origin-disable | eslint-disable with reason: previewUrl is either a blob: object URL (URL.createObjectURL of the picked file) or an arbitrary bsky-CDN avatar; next/image supports neither — blob: is unsupported outright and next.config.ts remotePatterns (lines 12-19) allows only **.certified.app. (Adjacent: the createObjectURL at line 152-154 runs during render and leaks per re-render — worth fixing while there) |
+| `src/components/onboarding/steps/step-profile.tsx:93` | no-img-element | suppress-with-justification | img-dynamic-origin-disable | eslint-disable with reason: previewBannerUrl is a blob: object URL or arbitrary remote bsky banner; unsupported by next/image with remotePatterns limited to **.certified.app |
+| `src/components/onboarding/steps/step-profile.tsx:104` | no-img-element | suppress-with-justification | img-dynamic-origin-disable | eslint-disable with reason: previewAvatarUrl is a blob: object URL or arbitrary remote bsky avatar; same remotePatterns constraint |
+| `src/components/layout/desktop-top-bar.tsx:385` | no-img-element | suppress-with-justification | img-static-svg-disable | eslint-disable with reason: static same-origin SVG (/brand/wordmark/certified_wordmark_black.svg); next/image performs no optimization on SVG sources and the element is CSS-sized (no CLS). Matches existing precedent at profile-header.tsx:108 |
+| `src/components/layout/desktop-top-bar.tsx:566` | no-img-element | suppress-with-justification | img-static-svg-disable | eslint-disable with reason: static same-origin SVG sign-in glyph (/brand/signin/certified_signin_black.svg); no optimization benefit from next/image |
+| `src/components/layout/mobile-sidebar.tsx:123` | no-img-element | suppress-with-justification | img-static-svg-disable | eslint-disable with reason: static same-origin SVG wordmark; no optimization benefit from next/image |
+| `src/components/ui/tooltip.tsx:76` | set-state-in-effect | fixable-cleanly | matchmedia-useSyncExternalStore | useSyncExternalStore for useHoverCapable: subscribe = mq 'change' listener, getSnapshot = () => globalThis.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? false, getServerSnapshot = () => false; boolean snapshot so identity is stable |
+| `src/hooks/use-mounted.ts:22` | set-state-in-effect | fixable-cleanly | mounted-useSyncExternalStore | useSyncExternalStore: `return useSyncExternalStore(emptySubscribe, () => true, () => false)` with a module-level `const emptySubscribe = () => () => {}`; identical hydration semantics (false on server/first client render, true after) |
+| `src/components/ui/theme-toggle.tsx:62` | set-state-in-effect | fixable-cleanly | mounted-useSyncExternalStore | replace the inline mounted flag (lines 59-63) with the shared useMounted() from src/hooks/use-mounted.ts (whose docstring says it exists to consolidate exactly this pattern); depends on the use-mounted.ts fix |
+| `src/components/lists/add-to-list-menu.tsx:101` | set-state-in-effect | fixable-cleanly | move-to-event-handler | move-to-event-handler: delete the effect (lines 100-102) and reset copied in the Popover's onOpenChange (line 125): `onOpenChange={(o) => { setOpen(o); if (o) setCopied(null) }}` |
+| `src/components/ui/tooltip.tsx:155` | set-state-in-effect | fixable-cleanly | move-to-event-handler | move-to-event-handler: move setCoords(null) into hide() (the only setter of open=false); the layout effect keeps `if (!open) return` and its measurement-driven setCoords calls (legit DOM sync) |
+| `src/components/layout/desktop-top-bar.tsx:276` | set-state-in-effect | fixable-cleanly | move-to-event-handler | move-to-event-handler: compute the initial createAnchor in the '+' trigger's onClick when opening; drop the sync setCreateAnchor(null) and initial compute() from the effect, gating the portal on `createOpen && createAnchor` (stale anchor while closed is unobservable); keep the resize/scroll listeners whose callback setStates are already rule-clean |
+| `src/components/onboarding/onboarding-modal.tsx:69` | set-state-in-effect | latent-bug | onboarding-seed-latent | comment/code contradiction: lines 60-62 claim re-opens keep what the user typed, but lines 83-85 null seededForDid.current whenever isOpen goes false, so the seed effect re-runs on every re-open and clobbers the typed draft with the bsky seed (trigger: open onboarding, type displayName, dismiss, click banner to re-open -> draft reset). Decide intent first: delete the lines 83-85 reset (comment becomes true) or fix the comment; the structural cleanup afterwards is a key-remount child (key=did, gated on isOpen, useState-initializer seeding). Do not apply a blind mechanical fix |
+| `src/components/badges/response-buttons.tsx:103` | exhaustive-deps | fixable-cleanly | pressedvalues-usememo | wrap pressedValues in useMemo keyed on [isAccepted, isRejected] (lint's own suggestion, message verified), or build the prev Set from `state` inside onValueChange and depend on state — stabilises the useCallback at line 113/126 |
+| `src/components/badges/response-menu.tsx:116` | exhaustive-deps | fixable-cleanly | pressedvalues-usememo | same as response-buttons: useMemo the pressedValues ternary on [state], stabilising the onValueChange useCallback at line 123/135 |
+| `src/components/ui/app-dialog.tsx:331` | unused-disable-directive | fixable-cleanly | remove-unused-disable | delete the now-unused `// eslint-disable-next-line react-hooks/exhaustive-deps` comment (message: no problems reported from that rule); this is the single --fix-able warning |
+| `src/components/profile/profile-header.tsx:91` | set-state-in-effect | fixable-cleanly | reset-on-prop-change-compare-prev | compare-previous-value-inline: `const [prevBannerUrl, setPrevBannerUrl] = useState(bannerUrl); if (prevBannerUrl !== bannerUrl) { setPrevBannerUrl(bannerUrl); setBannerFailed(false) }` during render; delete the effect |
+| `src/hooks/use-bottom-sheet-drag.ts:47` | set-state-in-effect | fixable-cleanly | reset-on-prop-change-compare-prev | compare-previous-value-inline: during render, `if (!isOpen && sheetExpanded) setSheetExpanded(false)`; delete the reset effect (lines 46-48) |
+| `src/components/feed/activity-edit-route.tsx:306` | set-state-in-effect | suppress-with-justification | seed-form-once-suppress | eslint-disable with reason: one-shot ref-guarded (seededRef) seeding of editable form state + swap-record cid baseline after async record load; re-running would clobber user edits. Long-term fix is key-remount (form child rendered once activity loads, useState initializers, key=activity.uri) — an M restructure, track separately |
+| `src/components/project/project-edit-route.tsx:157` | set-state-in-effect | suppress-with-justification | seed-form-once-suppress | eslint-disable with reason: one-shot ref-guarded (seededRef) seeding of editable form state + swap baseline from the loaded project record; key-remount refactor tracked separately |
+| `src/components/project/project-edit-route.tsx:201` | set-state-in-effect | suppress-with-justification | seed-form-once-suppress | eslint-disable with reason: one-shot ref-guarded (itemsSeededRef) hydration of the editable items list once useProjectItems resolutions settle; re-running would stomp user reordering/removal |
+| `src/components/workspace/workspace.tsx:92` | set-state-in-effect | fixable-cleanly | state-to-ref-latch | state-to-ref latch: `defaulted` is only read inside this effect (grep-verified: lines 87/89/96 are its only uses) — replace useState with `const defaultedRef = useRef(false)`; the router.replace URL-default write is legitimate external-system sync and stays |
+
+### Lint-audit corrections
+
+- `src/lib/tour/tour-context.tsx:102` — The proposed fix computes effectiveStepIndex only 'where step is derived', but the context value exposes raw stepIndex (tour-context.tsx:134) and product-tour.tsx consumes it directly: isLast = stepIndex === totalSteps - 1 (line 172), progress dots i === stepIndex (507), aria-label 'Step N of M' (502), and keyboard back-gating (361). After a mid-tour resize across the 800px boundary to a shorter steps array (the exact scenario the deleted clamp effect handles, per its own comment), the unclamped stored index would render 'Step 8 of 5', no active dot, 'Next' instead of 'Finish' on the actual last step, and a Back button that appears dead for several clicks (back computes Math.max(0, i - 1) from the stale stored index while the derived step stays pinned at the end).
+ - **corrected fix:** Classification stays fixable-cleanly / derive-during-render, but amend the fix: (a) in the value useMemo, compute `const effectiveStepIndex = Math.min(stepIndex, Math.max(0, steps.length - 1))`, derive `step` from it, AND return it as the context's `stepIndex` so all product-tour.tsx consumers see the clamped value (matching today's post-clamp behavior, minus the extra render); (b) clamp inside back(): `setStepIndex((i) => Math.max(0, Math.min(i, steps.length - 1) - 1))` and add steps.length to back's useCallback deps (currently []). next() already self-heals via Math.min(steps.length - 1, i + 1) and needs no change. Then delete the clamp effect.
diff --git a/docs/perf-quality-2026-07-12/followups-probe.md b/docs/perf-quality-2026-07-12/followups-probe.md
new file mode 100644
index 00000000..ffbd2801
--- /dev/null
+++ b/docs/perf-quality-2026-07-12/followups-probe.md
@@ -0,0 +1,28 @@
+# Cross-repo follow-ups: dissolved by live schema probing (2026-07-13)
+
+The pass recorded three "needs magic-indexer changes" follow-ups. Probing the
+deployed prod indexer (`magic-indexer-prod.up.railway.app/graphql`; introspection
+is disabled, so probed with real queries) showed none of them need upstream work:
+
+1. **`where: { uri: { in } }` on `orgHypercertsCollection`** — works today: a
+ probe with a real collection URI returned the exact node. Corroborated by
+ `ActivitiesByUris`, which has shipped on the same filter shape all along.
+ The Ma Earth loader's PDS fallback is kept as defense-in-depth for dev /
+ self-hosted indexers; its "may not be supported" comment was corrected.
+2. **`avatar` on collection nodes** — already in the deployed schema (validates
+ and resolves; magic-indexer builds its GraphQL schema from lexicon
+ definitions, so record fields flow through generically). 0/100 sampled
+ collections have avatars because the *records* don't (verified against the
+ PDS for 30 of them) — not an ingestion gap. The real gap was app-side:
+ `Projects` / `UserProjects` / `ProjectsContainingCert` didn't *select* the
+ field. Fixed in this branch — all collection ops now select `avatar`, so
+ `projectImage`'s avatar-first thumb precedence takes effect on indexer-fed
+ surfaces as soon as records carry avatars.
+3. **`authorLabels` on `CollectionsByUris`** — the argument validates on the
+ same connection (and `ActivitiesByUris` combines `uri:{in}` with label args
+ in production). Wiring it is app-side; whether the Ma Earth curated list
+ *should* respect the org-quality filter is a product decision, left with
+ the documented "ignored by design" behavior.
+
+Net: no magic-indexer issues filed; one app-side commit
+(`perf(projects): select avatar in all collection ops`) closes item 2.
diff --git a/docs/perf-quality-2026-07-12/plan.md b/docs/perf-quality-2026-07-12/plan.md
new file mode 100644
index 00000000..69007edb
--- /dev/null
+++ b/docs/perf-quality-2026-07-12/plan.md
@@ -0,0 +1,128 @@
+# Implementation plan — performance & code-quality pass (2026-07-12)
+
+Executes every confirmed finding from [findings.md](./findings.md) — 73 findings +
+63 triaged lint warnings. **Nothing is deferred.** Where a skeptic issued a fix
+correction, the corrected fix is the spec.
+
+## Ground rules
+
+- Branch: `perf/quality-pass-2026-07-12` (off `staging` @ `6dd73dd`). Draft PR into
+ `staging` at the end. Never merged by the agent.
+- **Disjoint file ownership.** Phase-1 tracks run in parallel in one working tree;
+ every file belongs to exactly one track (table below). A track may create new
+ files only inside its area and owns the `__tests__` files colocated with its
+ sources. If tsc shows errors in files a track does not own, that is another track
+ mid-flight — ignore them.
+- Phase-2 tracks are cross-cutting (shared helpers with 15–22 call sites); they run
+ **sequentially after all phase-1 commits**, so they see the final file layout.
+- Implementers do not run git commands; the orchestrator stages per-track file sets
+ and commits (one commit per track, conventional scope tag, no emojis).
+- Tests may change only when an internal API they exercise changes shape; never
+ weaken an assertion. New shared helpers get unit tests.
+- Baselines @ `6dd73dd`: tsc clean, typecheck:test clean, lint 63 warnings/0 errors,
+ 1024/1024 tests, build green, client chunks 3.2 MB.
+
+## Gates (after phase 1, after each phase-2 track, and finally)
+
+`npx tsc --noEmit` clean · `npm run typecheck:test` clean · `npm test` ≥1024 pass /
+0 fail · `npm run lint` **≤2 warnings (expected 0) and 0 unused-disable-directive
+warnings** — the 23 suppression sites carry inline eslint-disable + justification ·
+`npm run build` green · five CLAUDE.md grep checks silent.
+
+## Phase 0 — shared helpers (sequential, before phase 1)
+
+`postIndexer` (rich `{ok,status,data,errors[]}` shape) added to
+`src/lib/atproto/indexer.ts`; `deriveIdentity` (options bag) added as
+`src/lib/utils/identity.ts`; both with unit tests. T6 builds on postIndexer, T5 on
+deriveIdentity; P2a/P2b later migrate only the remaining legacy sites.
+Commit: `feat(lib): add postIndexer and deriveIdentity shared helpers`
+
+## Phase 1 — parallel tracks (disjoint ownership)
+
+### T1-home — feed row memoization + extraction
+Files: `src/components/home/home-feed.tsx`; new `src/components/home/home-feed-rows.tsx`.
+Findings: home-feed-rows-not-memoized (element-wise comparator per skeptic), endorsement-group-expand-unbounded, home-feed-rows-extract.
+Commit: `perf(home): memoize feed rows, bound group expansion; extract row layer`
+
+### T2-explore — explore render + data + decomposition
+Files: `src/components/explore-page/explore.tsx`, `src/hooks/use-explore.ts`; new `src/components/explore-page/{explore-search-field,quality-filters,results-area}.tsx`, new loader module (e.g. `src/hooks/use-explore-loaders.ts`).
+Findings: explore-search-state-too-high, all-view-overfetches-4x50-for-5, explore-quality-filters-extract, explore-results-area-extract, use-explore-loader-layer-split. Lint: use-explore.ts:322 suppress (explore-pipeline). Also owns `src/components/explore-page/explore-types.ts`; extraction named `explore-results.tsx`. (ma-earth-projects-pds-fanout moved to phase 1.5.)
+Commit: `perf(explore): isolate search keystrokes, right-size All view, batch Ma Earth projects; split loaders/filters/results`
+
+### T3-detail — activity/project detail
+Files: `src/components/feed/activity-detail.tsx`, `src/components/project/project-detail.tsx`, `src/hooks/use-context-updates.ts`, `src/lib/utils/swap-drafts.ts` (loadDraft/saveDraft/clearDraft + the two detail call sites only; `clearAllDraftsForViewer` and auth-context.tsx untouchable), `src/components/context/update-form.tsx`; new extraction file(s) under `src/components/feed/`, new shared contributor-helpers module under `src/lib/atproto/`.
+Findings: context-updates-duplicate-fetch, route-rkey-effect-double-render, activity-detail-trailing-components-extract, contributor-helpers-dedupe. Plus the swap-drafts decision from dead-exports-sweep: investigate the conflict-banner flow; default = remove the never-read draft plumbing (loadDraft + saveDraft/clearDraft call sites) in these two files, or keep + wire nothing if actually referenced. Lint: activity-detail.tsx:1835 derive-during-render (same change as route-rkey), use-context-updates.ts:58 fetch-reset.
+Commit: `perf(detail): cache context updates, derive route rkey; extract trailing components; drop dead draft plumbing`
+
+### T4-graph — endorsement graph
+Files: `src/components/visualization/endorsement-graph.tsx`, `src/hooks/use-endorsement-graph.ts`.
+Findings: force-graph-perma-redraw-loop (per skeptic correction), force-graph-reheat-on-resize, endorsement-graph-sequential-chunks. Plus: switch the AllEndorsements loader in use-endorsement-graph.ts to the phase-fixed indexer GET contract (from finding 22; T7 builds the endpoint concurrently).
+Commit: `perf(graph): pause redraw when idle, stop reheat on resize, parallelize chunk fetch`
+
+### T5-profile — profile surfaces
+Files: `src/components/profile/{profile-endorsements,profile-lists,endorsement-lists}.tsx`, `src/components/endorsements/endorsement-row.tsx`; new `src/components/endorsements/endorsement-subject-row.tsx`, new extraction files under `src/components/profile/`.
+Findings: given-endorsements-duplicate-hook-call, endorsement-row-inline-ontoggle-defeats-memo, profile-endorsements-below-fold-split, profile-lists-modals-extract, endorsement-subject-row-triplicated (info+isLoading props per skeptic — hydration stays in callers; built on phase-0 deriveIdentity; EndorsementRow keeps its did-based public props). Adds a render test for the shared row. Must NOT touch use-endorsements.ts (T6's).
+Commit: `refactor(profile): shared endorsement subject row, single hook call, stable callbacks; split below-fold + modals`
+
+### T6-hooks — data-hook caching family
+Files: `src/hooks/{use-received-endorsements,use-followers,use-following,use-endorsements,use-endorsement-lists,use-cgs-memberships,use-hyperboard}.ts`, `src/lib/atproto/hyperboard.ts`; new `src/hooks/create-cached-did-resource.ts` + unit test.
+Findings: received-endorsements-no-singleflight, followers-no-singleflight, following-no-singleflight, given-endorsements-no-cache, endorsement-lists-permanent-force, cgs-memberships-resolve-n-plus-1, hyperboard-displayprofile-waterfall, displayprofile-board-no-inflight, stale-cache-hook-skeleton (factory; migrate followers+following, others where clean). given-endorsements-no-cache scope: in-flight single-flight map inside use-endorsements.ts ONLY — the call-site dedup in profile-endorsements.tsx is T5's. Rewritten fetchers use phase-0 postIndexer.
+Commit: `perf(hooks): single-flight caches for endorsements/followers/following, batch CGS resolution, collapse hyperboard waterfall; shared cached-resource factory`
+
+### T7-server — API routes
+Files: `src/app/api/indexer/route.ts` (+ new sibling modules for operations/validation), `src/app/api/xrpc/[...method]/route.ts`, `src/app/api/groups/register/route.ts`, `src/app/api/groups/[groupDid]/{profile,metadata}/route.ts`, `src/app/api/groups/memberships/route.ts`, colocated tests.
+Also adds the `CollectionsByUris` op + buildVariables case (server side of finding 19) and the new tests from plan review: xrpc lazy-restore suite, indexer GET allowlist/cache-header suite. GET contract: `GET /api/indexer?op=[&first=][&after=][&badgeType=]` → same body as POST; 400 outside CACHEABLE_OPS; counts get `s-maxage=300, stale-while-revalidate=86400`, AllEndorsements `s-maxage=60, stale-while-revalidate=600`; no Cache-Control on upstream non-200 or `errors` body. Lands as 3 commits (indexer files / xrpc file / groups files).
+Findings: indexer-route-three-module-split (first), indexer-cacheable-get-variant, foreign-blob-no-smaxage, xrpc-get-eager-oauth-restore, xrpc-get-sequential-upstash-roundtrips, same-session-blob-no-cache-header, register-org-limit-walk-no-early-exit, register-org-limit-fail-open, groups-profile-metadata-no-cache-headers, cgs-fetches-missing-timeout.
+Commit: `perf(api): cacheable indexer GET for public ops, blob/profile cache headers, lazy OAuth restore, CGS timeouts; split indexer route modules; close org-limit fail-open`
+
+### T8-next — Next.js architecture
+Files: `src/app/[actor]/page.tsx`, `src/components/settings/settings-panel.tsx`, `src/components/landing/sections/network-stats.tsx`, `src/components/landing/landing-page.tsx`, `src/hooks/use-network-counts.ts`, new server counts helper in `src/lib/atproto/`, `next.config.ts`, title-suffix fixes in the three actual offending pages (`src/app/workspace/page.tsx` confirmed; re-grep for the others), `src/app/sitemap.ts`, `src/app/apps/page.tsx`, `src/app/home/page.tsx`, `src/app/project/new/page.tsx`.
+Findings: profile-route-bundles-settings-and-all-tabs, welcome-stats-client-fetch (server helper named `network-counts-server.ts`, queries inlined, fail-soft unit test), no-staletimes-router-cache, double-certified-title-suffix, sitemap-dead-about-url, apps-page-client-for-static-grid, missing-titles-home-project-new. Lint: use-network-counts.ts:72 + network-stats.tsx:135 suppression.
+Commit: `perf(next): lazy profile tabs + settings, server-render landing stats, router staleTimes; metadata fixes`
+
+### T9-lint-dead — lint sweep + dead code
+Files: the 40 lint-warning files not owned by T2/T3/T8 (list in track brief), `src/lib/tour/tour-context.tsx` (audit-corrected clamp fix), dead hook files + their tests (use-display-profile, use-pending-awards-count, use-user-activities), dead exports (account-email readEmail, app-passwords lockAppPasswords, location parseLocationCoords de-export, urls.ts + activity-uri.ts wrappers + their test references), clipboard adoption (`add-to-list-menu.tsx`, `share-embed-dialog.tsx`, `custom-domain-modal.tsx`), onboarding-modal latent bug.
+Lint budget: 39 fixable fixed, 23 suppressed with inline justification comments, 1 latent bug fixed.
+app-dialog.tsx moved to T11. Onboarding-modal: prefer key-remount (clears the warning); fallback bug-fix + suppression #24. Adds tour-context shrink-clamp test.
+Lands as 3 commits: lint-mechanical / dead-code deletion / behavioral fixes (onboarding-modal, tour-context).
+
+### T10-types — boundary validation
+Files: `src/hooks/use-activity.ts`, `src/lib/groups/org-context.tsx`, `src/lib/auth/auth-context.tsx`.
+Findings: unvalidated-claim-activity-cast, org-context-unvalidated-group-parse, auth-login-error-body-unguarded-json.
+Commit: `fix(types): validate activity records, org-context storage, auth error bodies at boundaries`
+
+### T11-css — tokens + dead CSS + containment
+Files: `src/app/styles/*.css` (all), `tailwind.config.ts`, `src/components/ui/app-dialog.tsx` (shadow swap :376 + unused-disable removal :331), `src/components/ui/bottom-sheet.tsx` + `src/components/ui/checkbox.tsx` (arbitrary z-index classes only). No other TSX edits.
+Findings: all 11 ds-conformance (z-index tokenization with side-by-side stacking table, error focus ring, status-color tokens, radius literals, modal shadow token, dead domain-modal backdrop), dead-css-blocks (respecting the live compound-selector carve-outs), no-content-visibility-long-lists (home + explore lists, with scroll-restoration check).
+Commit: `style(css): tokenize z-index/shadows/status colors, remove ~640 lines dead CSS, add content-visibility to long lists`
+
+## Phase 1.5 — ma-earth batch fetch (sequential, after phase-1 commits)
+
+Files: `src/lib/atproto/indexer.ts` (add `fetchIndexerProjectsByUris`), `src/hooks/use-explore.ts` (or its post-split loader module).
+Finding: ma-earth-projects-pds-fanout — batch via T7's new CollectionsByUris op, **fail-soft fallback to the existing per-URI PDS path** if the indexer rejects the filter (no cross-repo blocking).
+Commit: `perf(explore): batch Ma Earth project fetch via indexer with PDS fallback`
+
+## Phase 2 — sequential cross-cutting tracks (after phase 1.5)
+
+Ground rule: re-enumerate every call site by grep at track start — findings line
+refs are advisory (phase 1 moved code). Phase 0 already created the helpers; these
+tracks migrate the remaining legacy sites.
+
+### P2a-indexer — indexer client consolidation
+Files: `src/lib/atproto/indexer.ts` (+ new domain modules with re-export shim), all 22 `postIndexer` call sites across lib/hooks/components, colocated tests.
+Findings: indexer-post-wrapper-duplicated (helper exists from phase 0 — migrate remaining sites), lib-indexer-domain-split, indexer-fail-soft-swallows-http-errors. Plus the fetchCount POST→GET switch (finding 22 remainder).
+Commit: `refactor(indexer): single postIndexer helper across 22 sites, domain-split lib, surface HTTP errors`
+
+### P2b-helpers — shared derivation helpers
+Files: `src/lib/urls.ts`, `src/lib/utils/format-date.ts`, `src/lib/atproto/collection.ts`, new `deriveIdentity` helper + the ~20 identity call sites, ~14 rkey call sites, 4 formatTimePeriod sites, 6+ projectPresentation sites (slot-aware `thumb|banner` per skeptic).
+Findings: identity-fallback-chain-duplicated, rkey-extraction-five-implementations, format-time-period-four-copies, project-image-precedence-drift. Helpers get unit tests.
+Commit: `refactor(shared): deriveIdentity/rkeyFromUri/formatTimePeriod/projectImage helpers replace 50+ duplicated sites`
+
+## Verification & rollback
+
+- After phase 1: full gates BEFORE any commit; orchestrator fixes integration friction; per-track commits staged by file list in dependency order. (Recorded limitation: phase-1 commits are logical units from the end-state tree, not individually CI-verified snapshots.)
+- Canary check as soon as T1/T2/T3/T5 report done: tsc + use-endorsement-lists-stale-closure + use-given-endorsements-dedup tests.
+- After each phase-2 track: full gates; commit.
+- Implementation review round (functional correctness / code quality / perf-regression lenses); accepted fixes land as `fix(review): ...`; decisions recorded in review-round-1.md.
+- Final: build + bundle-size delta vs 3.2 MB baseline; CLAUDE.md grep checks; Draft PR.
+- Rollback: every track is one revertible commit on a feature branch; the PR is Draft and never auto-merged.
diff --git a/docs/perf-quality-2026-07-12/prompt.md b/docs/perf-quality-2026-07-12/prompt.md
new file mode 100644
index 00000000..be889eea
--- /dev/null
+++ b/docs/perf-quality-2026-07-12/prompt.md
@@ -0,0 +1,124 @@
+# Performance & code-quality pass — certified-app (2026-07-12)
+
+Rewritten, executable version of: *"do a full review and refactor of the staging
+branch of certified-app. you define what's important to improve, but focus mainly on
+performance and code-quality. open a PR to staging with all improvements (don't defer
+anything) and make sure the CI is green. You have 8 hours."*
+
+## Mission
+
+Review the certified-app codebase with **performance and code quality as the primary
+lenses**, then implement **every confirmed finding** — the previous pass's "defer and
+document" escape hatch is explicitly off the table. Work lands on branch
+`perf/quality-pass-2026-07-12` (cut from `staging` @ `6dd73dd`), one commit per
+logical track, and ships as a **Draft PR into `staging`** with all CI checks green.
+Time budget: 8 hours wall clock.
+
+## What is already done (do NOT re-find)
+
+The 2026-06-16 quality pass and the 2026-07-02 full review already landed (see
+`docs/full-review-2026-07/`): TipTap behind `next/dynamic`
+(`leaflet-editor-dynamic.tsx`), force-graph/map/org-settings/settings-panel lazy,
+explore + endorsement + activity row memoization, navbar context split
+(values/setters), project-item batch fetch via indexer, stale `loadMore` aborts,
+paginated-activity dedupe, SSRF hardening + redirect blocking, per-target unlock
+throttle, `/embed` frame-header carve-out, OG metadata caching, ENS rate limiting,
+`useCopyToClipboard` extraction, `useActivity` cache+coalescer, rgba→token sweep of
+scrims, dead-export removal.
+
+## Verified baselines (branch @ 6dd73dd)
+
+- `npx tsc --noEmit` — clean. `npm run typecheck:test` — clean.
+- `npm run lint` — **63 warnings, 0 errors**: 54× `react-hooks/set-state-in-effect`,
+ 6× `@next/next/no-img-element`, 2× `react-hooks/exhaustive-deps`, 1 autofixable.
+- `npm test` — **1024/1024 pass** (119 files, ~20 s).
+- CI (`.github/workflows/ci.yml`): lint → tsc → typecheck:test → vitest. No build
+ step in CI, but `npm run build` must also stay green (Vercel deploys it).
+- Largest files: `activity-detail.tsx` 2307, `explore.tsx` 2131,
+ `api/indexer/route.ts` 1817, `use-explore.ts` 1586, `project-detail.tsx` 1504,
+ `profile-endorsements.tsx` 1477, `profile-lists.tsx` 1433, `atproto/indexer.ts` 1385,
+ `home-feed.tsx` 1372, `activity-edit-route.tsx` 1309.
+
+## Explicitly in scope (inherited debt — the previous pass deferred these; we don't)
+
+1. **`waterfall-hyperboard-displayprofile`** — serial two-stage resolver round-trip.
+2. **`cache-indexer-proxy-no-shared-cache`** — cacheable GET variant / `s-maxage`
+ for hot indexer-proxy reads (design a safe, read-only subset).
+3. **`ds-zindex-literals`** — re-tokenize the ~18 literal z-index rules (`--z-*`).
+4. **`ds-error-focus-ring-rgba`** — tokenize the error focus ring rgba.
+
+## Review dimensions (one reviewer each, read-only, file:line evidence, concrete fix,
+severity high/med/low, effort S/M/L, regression risk low/med/high; cap 14/dimension)
+
+1. **perf-render-explore** — `explore.tsx`, `use-explore.ts`, `home-feed.tsx`,
+ explore-page components: re-render hotspots, unstable props/identities, sort/filter
+ in render body, list growth without virtualization, state colocations.
+2. **perf-render-detail** — `activity-detail.tsx`, `project-detail.tsx`,
+ `profile-*.tsx`, `endorsement-lists.tsx`: same lens on the detail/profile surfaces.
+3. **perf-data** — `src/hooks/*`, `src/lib/atproto/*`: N+1s, request waterfalls,
+ missing dedupe/coalescing, cache misuse, refetch storms, the hyperboard waterfall.
+4. **perf-server-cache** — `src/app/api/**` (esp. `indexer/route.ts` 1817 lines,
+ `xrpc/[...method]`): per-request recomputation, missing cache headers on safe
+ public GETs, sequential upstream calls that could be parallel, response-size waste.
+5. **lint-triage** — ALL 63 warnings, exhaustively: for each, classify
+ {latent-bug | fixable-cleanly | legitimately-suppress-with-comment} and give the
+ concrete fix (derive-during-render, key-remount, event-handler move, effect merge).
+ No cap. The goal is lint = 0 warnings or each survivor individually justified.
+6. **cq-duplication** — copy-pasted fetch/parse/format/state logic across
+ components+hooks that should be shared helpers; near-identical components that
+ should collapse into `src/components/ui/` primitives; dead code/exports/CSS.
+7. **cq-decomposition** — the 10 giant files: find the *clean seams only* (extract
+ child components with narrow props, split route handler by concern, hook fission
+ where state clusters are independent). Decomposition must be
+ behavior-preserving and mechanically verifiable; no speculative rewrites.
+8. **cq-types-errors** — `any`/unsafe casts on external-data boundaries
+ (indexer/PDS/CGS responses), swallowed errors (`catch {}`), unguarded
+ `JSON.parse`, missing narrow types on shared helpers.
+9. **next-arch** — client/server boundaries: pages that are `"use client"` but could
+ be RSC, data fetched client-side that a server component/route could deliver,
+ metadata gaps, route-handler validation/error-shape consistency, `next.config.ts`.
+10. **ds-conformance** — the five CLAUDE.md grep checks + z-index literals +
+ remaining raw colors outside sanctioned files + shadow/radius/breakpoint drift.
+
+## Hard constraints (violations are findings; do not introduce new ones)
+
+- `border-radius`: only `var(--radius)` / `999px` / `50%`. Breakpoints 800/1100/1300.
+ No raw hex/rgb outside `tokens.css`+`landing.css`. Shadows/z via tokens. Headings
+ `text-h1..h4` + `font-headline`. Modals via ``. Icon buttons
+ ``. Dark mode must keep working. Desktop ≥1300 px
+ visual baseline unchanged unless fixing a confirmed defect.
+
+## Verification protocol
+
+Every finding goes to an adversarial skeptic (high severity: two skeptics —
+reachability + impact; med/low: one). The skeptic reads the actual code at the cited
+lines and tries to refute existence, reachability, or worth. Only confirmed findings
+are implemented. For decomposition findings the skeptic instead validates the seam:
+props countable, no hidden shared mutable state, split testable.
+
+## Implementation rules
+
+- **Nothing confirmed gets deferred.** If a finding is confirmed but the proposed fix
+ is wrong, fix the fix — not the scope.
+- Tracks with **disjoint file ownership**; one commit per track; conventional scope
+ tags; no emojis anywhere.
+- Refactors of tested code keep tests green *unmodified* where possible; tests may
+ only change when an internal API they touch changes shape, never to weaken an
+ assertion. Behavior-preserving decomposition must not change rendered output.
+- New shared helpers get unit tests. Lint-warning fixes must not trade a warning for
+ a behavior change (each fix category verified by the existing suite + targeted
+ manual reasoning recorded in the plan).
+
+## Gates (before every commit, and finally in CI)
+
+`npx tsc --noEmit` clean · `npm run typecheck:test` clean · `npm run lint` **strictly
+fewer than 63 warnings, zero new rules** · `npm test` ≥1024 passing, 0 failing ·
+`npm run build` green · five CLAUDE.md grep checks silent.
+
+## Deliverables
+
+1. `docs/perf-quality-2026-07-12/prompt.md` — this document.
+2. `docs/perf-quality-2026-07-12/findings.md` — all findings + verdicts.
+3. `docs/perf-quality-2026-07-12/plan.md` — executed track plan (ownership, commits).
+4. `docs/perf-quality-2026-07-12/review-round-1.md` — plan/impl review decisions.
+5. Draft PR `perf/quality-pass-2026-07-12` → `staging`, CI green, not merged.
diff --git a/docs/perf-quality-2026-07-12/review-round-1.md b/docs/perf-quality-2026-07-12/review-round-1.md
new file mode 100644
index 00000000..85f14320
--- /dev/null
+++ b/docs/perf-quality-2026-07-12/review-round-1.md
@@ -0,0 +1,91 @@
+# Plan review round 1 — decisions (2026-07-12)
+
+Two reviewers: (A) file-ownership disjointness & hidden coupling; (B) sequencing,
+risk & test strategy. Every item accepted/rejected with rationale; the plan and
+track briefs are updated accordingly.
+
+## Accepted
+
+- **C1 (A) app-dialog.tsx double ownership (T9 lint :331 vs T11 shadow :376).**
+ app-dialog.tsx moves wholly to T11 (does both edits). T11's TSX carve-out widened
+ to: arbitrary z-index classes (`ui/bottom-sheet.tsx` z-[71]/z-[70],
+ `ui/checkbox.tsx` z-[1]) + the app-dialog shadow swap. Nothing else.
+- **C2 (A) finding 9/15 duplicate spec (T5 vs T6).** The call-site dedup in
+ profile-endorsements.tsx is T5's. T6's scope for `given-endorsements-no-cache` is
+ ONLY an in-flight single-flight map inside use-endorsements.ts (skeptic already
+ rejected the TTL cache). T5 must not add caching to the hook.
+- **C3 (A) + item 1b/2 (B) ma-earth finding 19 collision + cross-repo gate.**
+ Split three ways: T7 (owns the route + split) adds the `CollectionsByUris` proxy
+ op + buildVariables case; a new sequential **phase 1.5** (after phase-1 commits)
+ adds `fetchIndexerProjectsByUris` to lib and swaps use-explore.ts to it **with a
+ fail-soft fallback to the existing per-URI PDS path** — so the change ships even
+ if magic-indexer lacks `where:{uri:{in}}` (no cross-repo blocking, honoring
+ no-deferral safely).
+- **C4 (A) finding 22 client switches unassigned.** T7 ships the GET handler only.
+ GET contract fixed now so parallel tracks code against it:
+ `GET /api/indexer?op=[&first=][&after=][&badgeType=]` →
+ same body as POST; 400 outside CACHEABLE_OPS; `Cache-Control: public,
+ s-maxage=300, stale-while-revalidate=86400` for zero-variable count ops,
+ `s-maxage=60, stale-while-revalidate=600` for AllEndorsements pages; no
+ Cache-Control when upstream non-200 or body has `errors`. AllEndorsements client
+ switch → T4 (its file). fetchCount switch → P2a.
+- **G1 (A).** T8's double-suffix file is `src/app/workspace/page.tsx` (not
+ endorsements). T5 constrained: EndorsementRow keeps its did-based public props;
+ hydration stays inside it as a caller of the shared row.
+- **G2 (A).** swap-drafts is `src/lib/utils/swap-drafts.ts`; T3 scope =
+ loadDraft/saveDraft/clearDraft + the two detail-file call sites + colocated test;
+ `clearAllDraftsForViewer` and auth-context.tsx are untouchable (T10's file).
+- **G3 (A).** T3 gains `src/components/context/update-form.tsx` (invalidate
+ context-updates cache on save).
+- **G4 (A).** T10 gains `src/hooks/use-project-items.ts` (+ test) — same
+ `coerceClaimActivityValue` guard as use-activity.ts.
+- **G5 (A).** T2 gains `src/components/explore-page/explore-types.ts`; extraction
+ file name fixed to `explore-results.tsx`.
+- **G6 (A).** network-stats.tsx:135 animation-driver suppression → T8's brief.
+- **G7 (A).** T8's server helper named `network-counts-server.ts` (P2a reserves
+ `indexer-counts.ts`); T8 inlines its five count queries, no imports from T7's
+ in-flight modules.
+- **1 (B) Phase 0.** New sequential pre-phase: create `postIndexer` (rich
+ `{ok,status,data,errors[]}` shape, in lib/atproto/indexer.ts) and
+ `deriveIdentity` (options bag, in `src/lib/utils/identity.ts`) + unit tests,
+ committed before phase 1. T6 builds its rewritten fetchers on postIndexer; T5
+ builds the shared subject row on deriveIdentity. P2a/P2b then only migrate the
+ remaining legacy sites (re-enumerated by grep — see next).
+- **3 (B).** P2 ground rule added: re-enumerate all call sites by grep at track
+ start; findings line refs are advisory (phase 1 moved code).
+- **4 (B) lint gate restated.** Suppressions are eslint-disable directives and
+ vanish from output: final gate is **≤2 warnings** (expected 0), plus **0
+ unused-disable-directive warnings**; the 23 suppression sites carry inline
+ justifications. Onboarding-modal: T9 prefers the key-remount restructure (fix
+ clears the warning); falls back to bug-fix + suppression #24 if the restructure
+ isn't clean.
+- **5 (B) missing tests added to briefs.** T7: xrpc lazy-restore (foreign GET with
+ cookie never calls getOAuthClient; failed restore → 401 + deleteSession;
+ same-repo restore-failure falls back public) + indexer GET allowlist/cache-header
+ suite. T8: server-counts fail-soft unit test. T5: shared subject-row render test.
+ T9: tour-context shrink-clamp test.
+- **7 (B) commit splits** where file-disjoint from end state: T9 → 3 commits
+ (lint-mechanical / dead-code / behavioral onboarding+tour). T7 → 3 commits
+ (indexer route files / xrpc file / groups files). Finding 19 already its own
+ phase-1.5 commit. T11 stays one commit — content-visibility edits share files
+ with tokenization, so an end-state file split is unreliable; the hunk is small
+ and independently revertible.
+
+## Partially accepted
+
+- **6 (B) mid-flight gates.** Full per-track-commit compile verification is
+ unattainable in a shared working tree without serializing the tracks (commits are
+ staged from the end state). Accepted instead: gates run once after phase 1
+ before any commit; commits ordered by dependency; the two canary tests
+ (use-endorsement-lists-stale-closure, use-given-endorsements-dedup) plus tsc run
+ as soon as the extraction-heavy tracks (T1/T2/T3/T5) report done. Recorded
+ limitation: individual phase-1 commits are logical units, not individually
+ CI-verified snapshots (same trade the 2026-07-02 pass made).
+
+## Rejected
+
+- **(B) treating manual browser checks as blocking gates.** Local dev against real
+ data isn't available mid-pass; the ≥1300px visual checks (subject-row surfaces,
+ explore scroll restoration, home back-nav) go into the PR test-plan checklist for
+ staging-preview verification instead. Rationale: CI gates cover compile/tests;
+ visual parity items are enumerated, not silently dropped.
diff --git a/docs/perf-quality-2026-07-12/review-round-2.md b/docs/perf-quality-2026-07-12/review-round-2.md
new file mode 100644
index 00000000..ba00a72c
--- /dev/null
+++ b/docs/perf-quality-2026-07-12/review-round-2.md
@@ -0,0 +1,54 @@
+# Implementation review round 2 — decisions (2026-07-13)
+
+Three reviewers over `staging...HEAD` (functional correctness of the riskiest
+behavior changes; code quality of the new/moved modules; perf-regression +
+design-system conformance of the diff). Every finding skeptic-verified before
+acceptance: **11 confirmed, 1 refuted**.
+
+## Accepted — fixed in `fix(review)` commit
+
+1. Indexer op allowlists: own-key guards (`Object.hasOwn`) on the GET
+ CACHEABLE_OPS and POST OPERATIONS lookups so prototype keys (`op=constructor`)
+ die at the first gate; unknown-op tests extended to pin it. (correctness, nit —
+ defense-in-depth: buildVariables' switch default already stopped the request.)
+2. Barrel/domain import cycle in the indexer split: shared plumbing
+ (postIndexer, INDEXER_PROXY_URL, chunkArray) moved to a leaf
+ `indexer-client.ts`; strictly one-way imports now, barrel compat preserved.
+ (quality, should-fix.)
+3. CertPreview compat re-export dropped; the one consuming test imports from
+ `home-feed-rows` directly. (quality, should-fix.)
+4. Six comment pointers updated from `api/indexer/route.ts` to `operations.ts`
+ (incl. the load-bearing keep-in-sync note in network-counts-server.ts).
+ (quality, should-fix.)
+5. Three carried-over `no-img-element` suppressions got the one-line
+ justification suffix the pass standardized on. (quality, nit.)
+6. Zero-consumer exports de-exported in explore-results.tsx (type interfaces
+ stay exported per convention). (quality, nit.)
+7. use-explore-loaders bounded-map: comment states the recency-refresh
+ requirement that rules out `createBoundedCache` (reuse rejected — behavior
+ difference is real even if immaterial at size 8; honesty beats false DRY).
+ (quality, nit.)
+8. ExploreSearchField debounce read stale `onCommit`/`search` through the timer
+ closure and could revert a URL change made mid-window; latest-ref pattern
+ (effect-synced, not render-written) makes the in-code comment true.
+ (perf-ds, should-fix.)
+9. Endorsement-graph repaintEpoch bumps coalesced to one per animation frame
+ (was one re-render per avatar load in a burst). (perf-ds, nit.)
+10. Explore content-visibility rule scoped to the three list variants where it
+ can apply; funding's `display:contents` rows documented as the exclusion.
+ (perf-ds, nit.)
+
+## Accepted — as a PR test-plan item (its fix IS a deployed-build retest)
+
+11. `staleTimes.dynamic=30` must be retested against the preview deployment on
+ /explore (warm router cache → filter/sort clicks must still change URL +
+ results; the route's `force-dynamic` exists to defeat client segment-cache
+ reuse). Added as a checked item in the PR test plan; revert path is a
+ one-line config removal.
+
+## Refuted
+
+- "bodyHasErrors treats a no-`data` JSON body as cacheable, pinning a useless
+ 200" — accurate code reading, but requires a spec-nonconforming upstream
+ response no real GraphQL server emits, and s-maxage=300 bounds the blast
+ radius. Recorded, not actioned.
diff --git a/next.config.ts b/next.config.ts
index a2236e72..e68620d9 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,6 +3,17 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
poweredByHeader: false,
serverExternalPackages: ["@atproto/oauth-client-node"],
+ // Client router cache: dynamic segments (/explore, /groups, profile
+ // and record-detail routes) default to staleTime 0 in Next 15/16,
+ // so every repeat Link/bottom-nav navigation refetches an RSC shell
+ // whose real data comes from client hooks with their own caches.
+ // 30s lets a just-visited shell be reused. Sign-in goes through a
+ // full reload (oauth/callback), but signOut is client-side — the
+ // cached / redirect can replay to /home within 30s, where the
+ // session re-check bounces anonymous viewers back out.
+ experimental: {
+ staleTimes: { dynamic: 30 },
+ },
// Next 16 dev blocks /_next/* requests (including the HMR WebSocket)
// when the request Origin doesn't match the canonical localhost form.
// Our PUBLIC_URL convention is 127.0.0.1 (for OAuth + cookie reasons),
diff --git a/src/app/[actor]/[type]/[rkey]/update/[updateRkey]/edit/page.tsx b/src/app/[actor]/[type]/[rkey]/update/[updateRkey]/edit/page.tsx
index c4181196..2130b9cb 100644
--- a/src/app/[actor]/[type]/[rkey]/update/[updateRkey]/edit/page.tsx
+++ b/src/app/[actor]/[type]/[rkey]/update/[updateRkey]/edit/page.tsx
@@ -54,11 +54,20 @@ export default function EditUpdatePage() {
const [record, setRecord] = useState(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
+
+ // Adjust state during render when the load target changes, so the
+ // effect holds only the getContextAttachment lifecycle.
+ const loadKey = `${did}|${updateRkey}`
+ const [prevLoadKey, setPrevLoadKey] = useState(loadKey)
+ if (prevLoadKey !== loadKey) {
+ setPrevLoadKey(loadKey)
+ setLoading(true)
+ setLoadError(false)
+ }
+
useEffect(() => {
if (!did || !updateRkey) return
let cancelled = false
- setLoading(true)
- setLoadError(false)
getContextAttachment(did, updateRkey)
.then((r) => {
if (cancelled) return
diff --git a/src/app/[actor]/[type]/[rkey]/update/new/page.tsx b/src/app/[actor]/[type]/[rkey]/update/new/page.tsx
index bf536f60..a334af22 100644
--- a/src/app/[actor]/[type]/[rkey]/update/new/page.tsx
+++ b/src/app/[actor]/[type]/[rkey]/update/new/page.tsx
@@ -47,10 +47,18 @@ export default function NewUpdatePage() {
const [subjectCid, setSubjectCid] = useState(null)
const [cidResolving, setCidResolving] = useState(true)
+
+ // Adjust state during render when the subject changes, so the effect
+ // holds only the resolveRecordCid lifecycle.
+ const [prevSubjectUri, setPrevSubjectUri] = useState(subjectUri)
+ if (prevSubjectUri !== subjectUri) {
+ setPrevSubjectUri(subjectUri)
+ setCidResolving(true)
+ }
+
useEffect(() => {
if (!subjectUri) return
let cancelled = false
- setCidResolving(true)
resolveRecordCid(subjectUri)
.then((cid) => {
if (!cancelled) setSubjectCid(cid)
diff --git a/src/app/[actor]/page.tsx b/src/app/[actor]/page.tsx
index 4429af3a..c5059542 100644
--- a/src/app/[actor]/page.tsx
+++ b/src/app/[actor]/page.tsx
@@ -19,16 +19,13 @@ import { useOrgMarker } from "@/hooks/use-org-marker"
import { useOrg } from "@/lib/groups/org-context"
import { useAuth } from "@/lib/auth/auth-context"
import { useProfileInlineEdit } from "@/hooks/use-profile-inline-edit"
+import dynamic from "next/dynamic"
import ProfileHeader from "@/components/profile/profile-header"
import ProfileSidebar from "@/components/profile/profile-sidebar"
import ProfileOverview from "@/components/profile/profile-overview"
-import ProfileEndorsements from "@/components/profile/profile-endorsements"
-import ProfileFollowers from "@/components/profile/profile-followers"
-import ProfileLists from "@/components/profile/profile-lists"
import ProfileProjects from "@/components/profile/profile-projects"
import ProfileCerts from "@/components/profile/profile-certs"
import ProfileGroups from "@/components/profile/profile-groups"
-import SettingsPanel from "@/components/settings/settings-panel"
import LeafletDocument from "@/components/leaflet/leaflet-document"
import LeafletEditor from "@/components/leaflet/leaflet-editor-dynamic"
import type { LinearDocument } from "@/lib/leaflet/types"
@@ -40,6 +37,29 @@ import OnboardingBanner from "@/components/onboarding/onboarding-banner"
import { AlignLeft, UserX } from "lucide-react"
import { trackRecentlyViewed } from "@/lib/utils/recently-viewed"
+// Non-default tab panels + the own-profile settings surface are
+// code-split out of the route's first-load chunk: profiles are the
+// most-shared public URL, and anonymous visitors land on the static
+// Overview tab — they should not download the endorsement/list/
+// follower panels or the settings + org-admin subtree up front.
+// Overview, header, and sidebar stay static (default-tab paint).
+const ProfileEndorsements = dynamic(
+ () => import("@/components/profile/profile-endorsements"),
+ { loading: () => },
+)
+const ProfileFollowers = dynamic(
+ () => import("@/components/profile/profile-followers"),
+ { loading: () => },
+)
+const ProfileLists = dynamic(
+ () => import("@/components/profile/profile-lists"),
+ { loading: () => },
+)
+const SettingsPanel = dynamic(
+ () => import("@/components/settings/settings-panel"),
+ { loading: () => },
+)
+
type TabKey =
| "overview"
| "about"
diff --git a/src/app/api/groups/[groupDid]/metadata/route.ts b/src/app/api/groups/[groupDid]/metadata/route.ts
index a3aa41d9..2e882f56 100644
--- a/src/app/api/groups/[groupDid]/metadata/route.ts
+++ b/src/app/api/groups/[groupDid]/metadata/route.ts
@@ -17,6 +17,17 @@ const METADATA_FIELDS = [
"createdAt",
] as const
+/**
+ * Short Cache-Control for the public GET read below — mirrors the
+ * profile route's GROUP_READ_CACHE_HEADERS rationale: `private` so
+ * the browser cache is invalidated by the same-URL PUT on save,
+ * which a shared edge cache would not be. Error paths (including
+ * the 404 absent case) stay uncached.
+ */
+const GROUP_READ_CACHE_HEADERS = {
+ "Cache-Control": "private, max-age=30",
+} as const
+
/**
* GET /api/groups/[groupDid]/metadata
* Read the org's app.certified.actor.organization record.
@@ -50,7 +61,7 @@ export async function GET(
}
const data = await res.json()
- return NextResponse.json(data.value)
+ return NextResponse.json(data.value, { headers: GROUP_READ_CACHE_HEADERS })
} catch (err: unknown) {
// extractRouteError calls logSafe internally; bare console.error
// duplicated the log and skipped the redactSecrets pass.
diff --git a/src/app/api/groups/[groupDid]/profile/route.ts b/src/app/api/groups/[groupDid]/profile/route.ts
index 8e134c4f..7fd6ea73 100644
--- a/src/app/api/groups/[groupDid]/profile/route.ts
+++ b/src/app/api/groups/[groupDid]/profile/route.ts
@@ -11,6 +11,19 @@ import { logSafe } from "@/lib/utils/log-safe"
const PROFILE_FIELDS = ["displayName", "description", "pronouns", "website", "avatar", "banner", "createdAt"] as const
+/**
+ * Short Cache-Control for the public GET reads below — same tradeoff
+ * as the xrpc proxy's FOREIGN_READ_CACHE_HEADERS: org rows re-fetch
+ * the same PDS record on every mount, and a 30s window collapses
+ * that. `private` (not s-maxage) on purpose: the org-settings PUT
+ * targets the same URL, and a browser cache invalidates it on the
+ * successful PUT (RFC 9111 4.4) — a shared edge cache would not,
+ * breaking the save-then-refetch flow. Error paths stay uncached.
+ */
+const GROUP_READ_CACHE_HEADERS = {
+ "Cache-Control": "private, max-age=30",
+} as const
+
/**
* GET /api/groups/[groupDid]/profile
* Read the org's app.certified.actor.profile record.
@@ -38,7 +51,12 @@ export async function GET(
// "broken" stay distinguishable.
const pdsUrl = await resolvePdsUrl(groupDid)
if (!pdsUrl) {
- return NextResponse.json(null, { status: 200 })
+ // Cache the absent case too — it's the hot expected path for
+ // gone-group rows (see the comment above).
+ return NextResponse.json(null, {
+ status: 200,
+ headers: GROUP_READ_CACHE_HEADERS,
+ })
}
// Fetch directly from the group's PDS (unauthenticated — reads are public)
@@ -52,13 +70,16 @@ export async function GET(
// doesn't exist (RecordNotFound) — the expected absent case, same
// as an unresolvable PDS above. 200 + null, not 404.
if (res.status === 400 || res.status === 404) {
- return NextResponse.json(null, { status: 200 })
+ return NextResponse.json(null, {
+ status: 200,
+ headers: GROUP_READ_CACHE_HEADERS,
+ })
}
throw new Error(`PDS returned ${res.status}`)
}
const data = await res.json()
- return NextResponse.json(data.value)
+ return NextResponse.json(data.value, { headers: GROUP_READ_CACHE_HEADERS })
} catch (err: unknown) {
logSafe("[groups/profile] GET error", err)
const { status, message } = extractRouteError(err)
diff --git a/src/app/api/groups/memberships/route.ts b/src/app/api/groups/memberships/route.ts
index 016fc5fc..6433aae7 100644
--- a/src/app/api/groups/memberships/route.ts
+++ b/src/app/api/groups/memberships/route.ts
@@ -30,11 +30,15 @@ export async function GET(request: NextRequest) {
url.searchParams.set("cursor", cursor)
}
- // Fetch from group service with service auth
+ // Fetch from group service with service auth. Bound like every
+ // other CGS upstream (groupServiceFetch uses the same 15s) — a
+ // hung group service must not pin the invocation to the platform
+ // ceiling.
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
+ signal: AbortSignal.timeout(15_000),
})
if (!res.ok) {
diff --git a/src/app/api/groups/register/__tests__/org-limit-fail-closed.test.ts b/src/app/api/groups/register/__tests__/org-limit-fail-closed.test.ts
new file mode 100644
index 00000000..13b23c0f
--- /dev/null
+++ b/src/app/api/groups/register/__tests__/org-limit-fail-closed.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+
+/**
+ * Tests for the groups/register org-limit fail-closed contract.
+ *
+ * The limit check paginates CGS membership.list; a non-ok page used to be
+ * treated as end-of-pagination, silently truncating the list, zeroing the
+ * self-created count, and waving a user already at the cap through while
+ * group.register still succeeded (fail-open). The route must instead fail
+ * CLOSED — same 503 the surrounding catch returns when the check throws —
+ * and never reach the register call.
+ *
+ * The route pulls in server-only deps (OAuth client, session, atproto
+ * Agent) at import time, so we mock those, mirroring the sibling
+ * org-limit test files.
+ */
+
+vi.mock("@/lib/auth/csrf", () => ({ checkCsrf: vi.fn(() => null) }))
+vi.mock("@/lib/auth/rate-limit", () => ({
+ enforceRateLimit: vi.fn(async () => null),
+ makeLimiter: vi.fn(() => ({})),
+}))
+vi.mock("@/lib/groups/proxy-agent", () => ({
+ getAuthenticatedAgent: vi.fn(),
+ getServiceAuthToken: vi.fn(async () => "register-token"),
+ createGroupClient: vi.fn(),
+}))
+
+import { getAuthenticatedAgent } from "@/lib/groups/proxy-agent"
+
+const OWNER_DID = "did:plc:owner"
+
+function makeRequest() {
+ return new Request("https://example.test/api/groups/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ handle: "owner.test", ownerDid: OWNER_DID }),
+ })
+}
+
+function makeAuth() {
+ return {
+ did: OWNER_DID,
+ agent: {
+ com: {
+ atproto: {
+ server: {
+ getServiceAuth: vi.fn(async () => ({
+ data: { token: "membership-token" },
+ })),
+ },
+ },
+ },
+ },
+ } as unknown as Awaited>
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ // The 503 path logs via logSafe (console.error); keep output clean.
+ vi.spyOn(console, "error").mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+describe("groups/register org-limit check fails closed on a non-ok membership.list", () => {
+ it("returns 503 and never calls group.register when membership.list 500s", async () => {
+ vi.mocked(getAuthenticatedAgent).mockResolvedValue(makeAuth())
+
+ const registerCalls: string[] = []
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : input.toString()
+ if (url.includes("membership.list")) {
+ return new Response("upstream down", { status: 500 })
+ }
+ if (url.includes("group.register")) {
+ registerCalls.push(url)
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
+ }
+ throw new Error(`unexpected fetch: ${url}`)
+ })
+ )
+
+ const { POST } = await import("../route")
+ const res = await POST(makeRequest() as never)
+
+ expect(res.status).toBe(503)
+ const body = await res.json()
+ expect(body.error).toContain("Unable to verify group creation limit")
+ expect(registerCalls).toHaveLength(0)
+ })
+
+ it("still registers when membership.list paginates cleanly", async () => {
+ vi.mocked(getAuthenticatedAgent).mockResolvedValue(makeAuth())
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : input.toString()
+ if (url.includes("membership.list")) {
+ return new Response(JSON.stringify({ groups: [] }), { status: 200 })
+ }
+ if (url.includes("group.register")) {
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
+ }
+ throw new Error(`unexpected fetch: ${url}`)
+ })
+ )
+
+ const { POST } = await import("../route")
+ const res = await POST(makeRequest() as never)
+
+ expect(res.status).toBe(200)
+ })
+})
diff --git a/src/app/api/groups/register/__tests__/org-limit-member-walk.test.ts b/src/app/api/groups/register/__tests__/org-limit-member-walk.test.ts
index 2e6da709..d2ddaa54 100644
--- a/src/app/api/groups/register/__tests__/org-limit-member-walk.test.ts
+++ b/src/app/api/groups/register/__tests__/org-limit-member-walk.test.ts
@@ -17,6 +17,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
* asserting the second page is never fetched, while the limit decision is
* unchanged.
*
+ * A second guard skips the walk outright when the user belongs to fewer than
+ * MAX_SELF_CREATED_ORGS groups — selfCreatedCount <= allGroups.length, so the
+ * cap is unreachable and the per-group fan-out is provably wasted latency.
+ *
* The route pulls in server-only deps (OAuth client, session, atproto Agent)
* at import time, so we mock those and drive the POST handler through the
* org-limit check via a mocked `createGroupClient`.
@@ -63,17 +67,17 @@ function makeAuth() {
}
/**
- * Global fetch returning a single group for the membership.list call and a
- * 200 success for the final register call.
+ * Global fetch returning the given groups for the membership.list call and
+ * a 200 success for the final register call.
*/
-function installFetch() {
+function installFetch(groupDids: string[]) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString()
if (url.includes("membership.list")) {
return new Response(
- JSON.stringify({ groups: [{ groupDid: "did:plc:groupA" }] }),
+ JSON.stringify({ groups: groupDids.map((groupDid) => ({ groupDid })) }),
{ status: 200 }
)
}
@@ -96,27 +100,38 @@ afterEach(() => {
describe("groups/register org-limit member-walk early exit (quality-056-authz-repo-4)", () => {
it("stops paginating a group's member list once the self-added entry is found", async () => {
vi.mocked(getAuthenticatedAgent).mockResolvedValue(makeAuth())
- installFetch()
+ // Five memberships — at MAX_SELF_CREATED_ORGS, so the walk runs (below
+ // the cap it is skipped entirely; see the next test).
+ installFetch([
+ "did:plc:groupA",
+ "did:plc:groupB",
+ "did:plc:groupC",
+ "did:plc:groupD",
+ "did:plc:groupE",
+ ])
- // member.list mock: page 1 contains the owner's self-added entry AND a
- // cursor pointing at a (nonexistent-in-spirit) page 2. The pre-fix walk
- // would follow the cursor and call again; the fixed walk must stop.
- const memberListCall = vi.fn(async (_lxm: string, params: { cursor?: string }) => {
- if (!params.cursor) {
+ // member.list mock: groupA's page 1 contains the owner's self-added
+ // entry AND a cursor pointing at a (nonexistent-in-spirit) page 2. The
+ // pre-fix walk would follow the cursor and call again; the fixed walk
+ // must stop. The other groups return a single page without the entry.
+ const memberListCall = vi.fn(
+ async (_lxm: string, params: { repo?: string; cursor?: string }) => {
+ if (params.repo === "did:plc:groupA" && !params.cursor) {
+ return {
+ data: {
+ members: [{ did: OWNER_DID, addedBy: OWNER_DID }],
+ cursor: "page2",
+ },
+ }
+ }
return {
data: {
- members: [{ did: OWNER_DID, addedBy: OWNER_DID }],
- cursor: "page2",
+ members: [{ did: "did:plc:other", addedBy: "did:plc:other" }],
+ cursor: undefined,
},
}
}
- return {
- data: {
- members: [{ did: "did:plc:other", addedBy: "did:plc:other" }],
- cursor: undefined,
- },
- }
- })
+ )
vi.mocked(createGroupClient).mockReturnValue({
call: memberListCall,
} as never)
@@ -130,8 +145,33 @@ describe("groups/register org-limit member-walk early exit (quality-056-authz-re
// proceeds — the limit decision is unchanged by the early exit.
expect(res.status).toBe(200)
- // Early exit: the matching entry was on page 1, so page 2 must never be
- // requested. Pre-fix code calls member.list twice (page 1 + page 2).
- expect(memberListCall).toHaveBeenCalledTimes(1)
+ // Page-level early exit: groupA's matching entry was on page 1, so its
+ // page 2 must never be requested — every group contributes exactly one
+ // call. Pre-fix code follows groupA's cursor for a sixth call.
+ expect(memberListCall).toHaveBeenCalledTimes(5)
+ const cursors = memberListCall.mock.calls.map(([, params]) => params.cursor)
+ expect(cursors.every((c) => c === undefined)).toBe(true)
+ })
+
+ it("skips the member walk entirely below MAX_SELF_CREATED_ORGS memberships", async () => {
+ vi.mocked(getAuthenticatedAgent).mockResolvedValue(makeAuth())
+ // One membership: selfCreatedCount <= allGroups.length < 5, so the cap
+ // is unreachable and the per-group walk (a service-auth mint + CGS
+ // call per group) must not run at all.
+ installFetch(["did:plc:groupA"])
+
+ const memberListCall = vi.fn()
+ vi.mocked(createGroupClient).mockReturnValue({
+ call: memberListCall,
+ } as never)
+
+ const { POST } = await import("../route")
+ const res = await POST(
+ makeRequest({ handle: "owner.test", ownerDid: OWNER_DID }) as never
+ )
+
+ expect(res.status).toBe(200)
+ expect(createGroupClient).not.toHaveBeenCalled()
+ expect(memberListCall).not.toHaveBeenCalled()
})
})
diff --git a/src/app/api/groups/register/route.ts b/src/app/api/groups/register/route.ts
index c42d4114..9ef448b0 100644
--- a/src/app/api/groups/register/route.ts
+++ b/src/app/api/groups/register/route.ts
@@ -101,55 +101,68 @@ export async function POST(request: NextRequest) {
const membershipsRes = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${membershipToken}` },
+ signal: AbortSignal.timeout(15_000),
})
if (membershipsRes.ok) {
const data = await membershipsRes.json()
- allGroups.push(...(data.groups || []))
+ if (Array.isArray(data.groups)) allGroups.push(...data.groups)
cursor = data.cursor
} else {
- cursor = undefined
+ // Fail CLOSED — treating a non-ok page as end-of-pagination
+ // would truncate the list, zero the self-created count, and
+ // wave a user already at the cap through while group.register
+ // still succeeds. Throw into the catch below (503), matching
+ // how a thrown getServiceAuth/network error is handled.
+ throw new Error(`membership.list returned ${membershipsRes.status}`)
}
} while (cursor)
// For each group, check if the user's member entry has addedBy === ownerDid
- // Process in batches of 5 with early exit once the limit is reached
+ // Process in batches of 5 with early exit once the limit is reached.
+ // Skipped entirely when the user belongs to fewer groups than the
+ // cap: selfCreatedCount can never exceed allGroups.length, so the
+ // limit is unreachable and the walk — one service-auth mint + CGS
+ // member.list (possibly paginated) per group — is pure wasted
+ // latency on the typical registrant's critical path.
let selfCreatedCount = 0
const BATCH_SIZE = 5
- for (let i = 0; i < allGroups.length; i += BATCH_SIZE) {
- if (selfCreatedCount >= MAX_SELF_CREATED_ORGS) break
- const batch = allGroups.slice(i, i + BATCH_SIZE)
- const results = await Promise.all(
- batch.map(async (g) => {
- try {
- const groupAgent = createGroupClient(auth.agent, g.groupDid)
- // We only need to know whether the caller's OWN entry was
- // self-added, so stop paginating the member list as soon as that
- // entry is found — no later page can change the boolean answer.
- let memberCursor: string | undefined
- do {
- const params: Record = {
- repo: g.groupDid,
- limit: 100,
- }
- if (memberCursor) params.cursor = memberCursor
- const { data } = await groupAgent.call(
- "app.certified.group.member.list",
- params
- )
- const page = data as { members?: { did: string; addedBy: string }[]; cursor?: string }
- const selfAdded = (page.members || []).some(
- (m) => m.did === ownerDid && m.addedBy === ownerDid
- )
- if (selfAdded) return true
- memberCursor = page.cursor
- } while (memberCursor)
- return false
- } catch {
- return false
- }
- })
- )
- selfCreatedCount += results.filter(Boolean).length
+ if (allGroups.length >= MAX_SELF_CREATED_ORGS) {
+ for (let i = 0; i < allGroups.length; i += BATCH_SIZE) {
+ if (selfCreatedCount >= MAX_SELF_CREATED_ORGS) break
+ const batch = allGroups.slice(i, i + BATCH_SIZE)
+ const results = await Promise.all(
+ batch.map(async (g) => {
+ try {
+ const groupAgent = createGroupClient(auth.agent, g.groupDid)
+ // We only need to know whether the caller's OWN entry was
+ // self-added, so stop paginating the member list as soon as that
+ // entry is found — no later page can change the boolean answer.
+ let memberCursor: string | undefined
+ do {
+ const params: Record = {
+ repo: g.groupDid,
+ limit: 100,
+ }
+ if (memberCursor) params.cursor = memberCursor
+ const { data } = await groupAgent.call(
+ "app.certified.group.member.list",
+ params
+ )
+ const page = data as { members?: { did: string; addedBy: string }[]; cursor?: string }
+ const selfAdded = (page.members || []).some(
+ (m) => m.did === ownerDid && m.addedBy === ownerDid
+ )
+ if (selfAdded) return true
+ memberCursor = page.cursor
+ } while (memberCursor)
+ return false
+ } catch {
+ return false
+ }
+ })
+ )
+ selfCreatedCount += results.filter(Boolean).length
+ }
}
if (selfCreatedCount >= MAX_SELF_CREATED_ORGS) {
@@ -182,6 +195,10 @@ export async function POST(request: NextRequest) {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ handle, ownerDid, email }),
+ // Bound like every other CGS upstream (groupServiceFetch uses
+ // the same 15s) — a hung group service must not pin the
+ // invocation to the platform ceiling.
+ signal: AbortSignal.timeout(15_000),
}
)
diff --git a/src/app/api/indexer/__tests__/get-cacheable.test.ts b/src/app/api/indexer/__tests__/get-cacheable.test.ts
new file mode 100644
index 00000000..8f669810
--- /dev/null
+++ b/src/app/api/indexer/__tests__/get-cacheable.test.ts
@@ -0,0 +1,174 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+
+/**
+ * Tests for the GET /api/indexer edge-cacheable variant.
+ *
+ * Contract pinned here:
+ * - Only the CACHEABLE_OPS allowlist (five zero-variable count ops +
+ * AllEndorsements + OrganizationDids) is servable via GET; every
+ * other op — including ones POST accepts — 400s without an
+ * upstream call.
+ * - A clean 200 (no GraphQL `errors`) carries the shared-cache
+ * Cache-Control; counts get the long TTL, the paginated scans the
+ * short one.
+ * - A 200 body WITH `errors`, or a non-200 upstream, carries NO
+ * Cache-Control at all — a transient failure must never be pinned
+ * at the edge for the full TTL.
+ *
+ * Same harness as route.test.ts: mock `fetch` (no real indexer), and
+ * build a minimal NextRequest stand-in (the handler reads
+ * `nextUrl.searchParams`, `headers`, and `signal`).
+ */
+
+vi.mock("@/lib/auth/csrf", () => ({
+ checkCsrf: () => null,
+}))
+
+const mockFetch = vi.fn()
+const originalFetch = globalThis.fetch
+
+beforeEach(() => {
+ globalThis.fetch = mockFetch as unknown as typeof fetch
+ mockFetch.mockReset()
+ // Fresh Response per call — bodies are one-shot streams.
+ mockFetch.mockImplementation(() =>
+ Promise.resolve(
+ new Response(JSON.stringify({ data: {} }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ ),
+ )
+ // The limiter fail-opens without Redis but warns; keep output clean.
+ vi.spyOn(console, "warn").mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+ globalThis.fetch = originalFetch
+ vi.restoreAllMocks()
+})
+
+async function getIndexer(query: string): Promise {
+ const { GET } = await import("../route")
+ const url = new URL(`http://localhost/api/indexer${query}`)
+ const req = {
+ nextUrl: url,
+ headers: new Headers(),
+ signal: new AbortController().signal,
+ }
+ return GET(req as unknown as Parameters[0])
+}
+
+describe("GET /api/indexer (edge-cacheable allowlist)", () => {
+ describe("allowlist", () => {
+ it("400s on an op outside CACHEABLE_OPS, even one POST accepts", async () => {
+ // "constructor" / "toString" pin the Object.hasOwn own-key check —
+ // a plain object lookup would return truthy inherited members.
+ for (const op of ["FollowerEvents", "ReceivedEndorsements", "FundingReceipts", "nope", "constructor", "toString"]) {
+ mockFetch.mockClear()
+ const res = await getIndexer(`?op=${op}`)
+ expect(res.status, `op=${op}`).toBe(400)
+ const body = await res.json()
+ expect(body.error).toBe("Unknown operation")
+ expect(mockFetch).not.toHaveBeenCalled()
+ }
+ })
+
+ it("400s when op is missing", async () => {
+ const res = await getIndexer("")
+ expect(res.status).toBe(400)
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it("serves each allowlisted op with the same body as POST", async () => {
+ const ops = [
+ "ProfileCount",
+ "OrganizationCount",
+ "ActivityCount",
+ "ProjectCount",
+ "AwardCount",
+ "AllEndorsements",
+ "OrganizationDids",
+ ]
+ for (const op of ops) {
+ mockFetch.mockClear()
+ const res = await getIndexer(`?op=${op}`)
+ expect(res.status, `op=${op}`).toBe(200)
+ expect(await res.json()).toEqual({ data: {} })
+ const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
+ expect(body.operationName).toBe(op)
+ expect(body.query).toContain(`query ${op}`)
+ }
+ })
+ })
+
+ describe("Cache-Control", () => {
+ it("counts get the long shared TTL on a clean 200", async () => {
+ const res = await getIndexer("?op=ProfileCount")
+ expect(res.status).toBe(200)
+ expect(res.headers.get("cache-control")).toBe(
+ "public, s-maxage=300, stale-while-revalidate=86400",
+ )
+ })
+
+ it("AllEndorsements pages get the short shared TTL", async () => {
+ const res = await getIndexer("?op=AllEndorsements&badgeType=award&first=100")
+ expect(res.status).toBe(200)
+ expect(res.headers.get("cache-control")).toBe(
+ "public, s-maxage=60, stale-while-revalidate=600",
+ )
+ // Variables ride the query string so they are part of the edge
+ // cache key — verify the validated values were forwarded.
+ const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
+ expect(body.variables.badgeType).toBe("award")
+ expect(body.variables.first).toBe(100)
+ })
+
+ it("omits Cache-Control when the 200 body carries GraphQL errors", async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ errors: [{ message: "boom" }] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ )
+ const res = await getIndexer("?op=ActivityCount")
+ expect(res.status).toBe(200)
+ expect(res.headers.get("cache-control")).toBeNull()
+ })
+
+ it("omits Cache-Control when the upstream is non-200", async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response("bad gateway", { status: 502 }),
+ )
+ const res = await getIndexer("?op=ActivityCount")
+ expect(res.status).toBe(502)
+ expect(res.headers.get("cache-control")).toBeNull()
+ })
+
+ it("omits Cache-Control when the 200 body is not JSON", async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response("upstream proxy page", { status: 200 }),
+ )
+ const res = await getIndexer("?op=ActivityCount")
+ expect(res.status).toBe(200)
+ expect(res.headers.get("cache-control")).toBeNull()
+ })
+ })
+
+ describe("variable validation via query string", () => {
+ it("400s on an invalid badgeType (same allowlist as POST)", async () => {
+ const res = await getIndexer("?op=AllEndorsements&badgeType=injected")
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error).toBe("Invalid variables for operation")
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it("clamps first and forwards after like the POST form", async () => {
+ await getIndexer("?op=OrganizationDids&first=99999&after=cursor123")
+ const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
+ expect(body.variables.first).toBe(100)
+ expect(body.variables.after).toBe("cursor123")
+ })
+ })
+})
diff --git a/src/app/api/indexer/__tests__/route.test.ts b/src/app/api/indexer/__tests__/route.test.ts
index 278f54e2..fc83081c 100644
--- a/src/app/api/indexer/__tests__/route.test.ts
+++ b/src/app/api/indexer/__tests__/route.test.ts
@@ -72,6 +72,20 @@ describe("/api/indexer trust boundary", () => {
expect(mockFetch).not.toHaveBeenCalled()
})
+ it("rejects Object.prototype keys with 400 (own-key allowlist check)", async () => {
+ // OPERATIONS is a plain object literal — without the
+ // Object.hasOwn guard, inherited members (`constructor`,
+ // `toString`, …) are truthy and would slip past the first gate.
+ for (const op of ["constructor", "toString", "hasOwnProperty"]) {
+ mockFetch.mockClear()
+ const res = await postIndexer({ operationName: op, variables: {} })
+ expect(res.status, `op=${op}`).toBe(400)
+ const body = await res.json()
+ expect(body.error, `op=${op}`).toBe("Unknown operation")
+ expect(mockFetch).not.toHaveBeenCalled()
+ }
+ })
+
it("rejects missing operationName with 400", async () => {
const res = await postIndexer({ variables: { first: 10 } })
expect(res.status).toBe(400)
@@ -559,6 +573,53 @@ describe("/api/indexer trust boundary", () => {
})
})
+ describe("CollectionsByUris", () => {
+ it("forwards a valid uri batch against the collections connection", async () => {
+ const uris = [
+ "at://did:plc:a/org.hypercerts.collection/one",
+ "at://did:plc:b/org.hypercerts.collection/two",
+ ]
+ const res = await postIndexer({
+ operationName: "CollectionsByUris",
+ variables: { uris },
+ })
+ expect(res.status).toBe(200)
+ const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
+ expect(body.operationName).toBe("CollectionsByUris")
+ expect(body.query).toContain("query CollectionsByUris")
+ expect(body.query).toContain("orgHypercertsCollection")
+ expect(body.variables).toEqual({ uris })
+ })
+
+ it("400s on an empty uris array (callers skip the call instead)", async () => {
+ const res = await postIndexer({
+ operationName: "CollectionsByUris",
+ variables: { uris: [] },
+ })
+ expect(res.status).toBe(400)
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it("400s on non-at:// entries", async () => {
+ const res = await postIndexer({
+ operationName: "CollectionsByUris",
+ variables: { uris: ["https://example.com/x"] },
+ })
+ expect(res.status).toBe(400)
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it("400s when the batch exceeds MAX_URI_LIST_PER_KIND (50)", async () => {
+ const tooMany = Array.from({ length: 51 }, (_, i) => `at://did:plc:a/c/${i}`)
+ const res = await postIndexer({
+ operationName: "CollectionsByUris",
+ variables: { uris: tooMany },
+ })
+ expect(res.status).toBe(400)
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+ })
+
describe("HydrateFeedPage", () => {
const allEmpty = {
activityUris: [],
diff --git a/src/app/api/indexer/operations.ts b/src/app/api/indexer/operations.ts
new file mode 100644
index 00000000..4611b0c0
--- /dev/null
+++ b/src/app/api/indexer/operations.ts
@@ -0,0 +1,1215 @@
+import {
+ DEFAULT_HIDDEN_CERT_LABELS,
+ DEFAULT_HIDDEN_ORG_LABELS,
+} from "@/lib/atproto/labels"
+import { MAX_URI_LIST_PER_KIND } from "./variables"
+
+/**
+ * Server-held GraphQL query strings for the indexer proxy — the
+ * allowlist half of the trust boundary described in ./route.ts.
+ * Pure module-level constants: the query text embeds the shared
+ * label-exclusion policy and the MAX_URI_LIST_PER_KIND page size
+ * (single-sourced in ./variables.ts — changing it there changes both
+ * the validator cap and the on-wire page size).
+ */
+
+/** Activity node selection — shared by the three activity ops below. */
+const ACTIVITY_NODE_SELECTION = `
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ cid
+ did
+ title
+ shortDescription
+ createdAt
+ startDate
+ endDate
+ labels
+ image {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ workScope {
+ ... on OrgHypercertsClaimActivityWorkScopeString { scope }
+ ... on OrgHypercertsWorkscopeCel { expression }
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+`
+
+/**
+ * Allowlist of GraphQL operations we forward. Names are stable across
+ * client + server; query strings are server-only.
+ *
+ * NOTE on adding ops: a new entry MUST come with a `buildVariables`
+ * branch in ./variables.ts or the request 400s on unknown variables.
+ */
+export const OPERATIONS: Record = {
+ // Global activity feed + per-label / per-author filters.
+ Activities: `
+ query Activities(
+ $first: Int!
+ $after: String
+ $labels: [String!]
+ $excludeLabels: [String!]
+ $authors: [String!]
+ $authorLabels: [String!]
+ $excludeAuthorLabels: [String!]
+ $search: String
+ ) {
+ orgHypercertsClaimActivity(
+ first: $first
+ after: $after
+ labels: $labels
+ excludeLabels: $excludeLabels
+ authors: $authors
+ authorLabels: $authorLabels
+ excludeAuthorLabels: $excludeAuthorLabels
+ search: $search
+ ) {
+${ACTIVITY_NODE_SELECTION}
+ }
+ }
+ `,
+
+ // Fetch a specific set of activity URIs, with optional label
+ // include / exclude filters applied server-side. Used by surfaces
+ // that already know the URIs they want (e.g. the explore page's
+ // Ma Earth featured filter) and need labels on the records so
+ // the same Quality popover that filters server-side on the
+ // generic Activities op also works here.
+ ActivitiesByUris: `
+ query ActivitiesByUris(
+ $uris: [String!]!
+ $labels: [String!]
+ $excludeLabels: [String!]
+ $authorLabels: [String!]
+ $excludeAuthorLabels: [String!]
+ ) {
+ orgHypercertsClaimActivity(
+ first: 100
+ where: { uri: { in: $uris } }
+ labels: $labels
+ excludeLabels: $excludeLabels
+ authorLabels: $authorLabels
+ excludeAuthorLabels: $excludeAuthorLabels
+ ) {
+${ACTIVITY_NODE_SELECTION}
+ }
+ }
+ `,
+
+ // Per-user "authored" activities (Certs tab > Created bucket).
+ AuthoredActivities: `
+ query AuthoredActivities(
+ $did: String!
+ $first: Int!
+ $after: String
+ $labels: [String!]
+ $excludeLabels: [String!]
+ $search: String
+ ) {
+ orgHypercertsClaimActivity(
+ first: $first
+ after: $after
+ labels: $labels
+ excludeLabels: $excludeLabels
+ search: $search
+ where: { did: { eq: $did } }
+ ) {
+${ACTIVITY_NODE_SELECTION}
+ }
+ }
+ `,
+
+ // Per-user "contributed to" activities (Certs tab > Contributed bucket).
+ ContributedActivities: `
+ query ContributedActivities(
+ $did: String!
+ $first: Int!
+ $after: String
+ $labels: [String!]
+ $excludeLabels: [String!]
+ $search: String
+ ) {
+ orgHypercertsClaimActivity(
+ first: $first
+ after: $after
+ labels: $labels
+ excludeLabels: $excludeLabels
+ search: $search
+ where: { contributor: { eq: $did } }
+ ) {
+${ACTIVITY_NODE_SELECTION}
+ }
+ }
+ `,
+
+ // Deduped union count of activities a profile CREATED or CONTRIBUTED
+ // to. The `_or` returns each matching record once, so `totalCount` is
+ // the exact unique count (created ∪ contributed) — unlike summing the
+ // two per-bucket totals, which double-counts a record where the user
+ // is both author and contributor.
+ UserActivityCount: `
+ query UserActivityCount($did: String!) {
+ orgHypercertsClaimActivity(
+ first: 1
+ where: { _or: [{ did: { eq: $did } }, { contributor: { eq: $did } }] }
+ ) {
+ totalCount
+ edges { node { uri } }
+ pageInfo { hasNextPage }
+ }
+ }
+ `,
+
+ // Followers of a profile DID.
+ Followers: `
+ query Followers($did: String!, $first: Int!, $after: String) {
+ appCertifiedGraphFollow(
+ where: { subject: { eq: $did } }
+ first: $first
+ after: $after
+ ) {
+ totalCount
+ edges { node { uri cid did createdAt } }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Endorsement awards received by a profile DID — single-query
+ // shape per hb-agent/magic-indexer#96. Three indexer-side joins
+ // collapsed in here:
+ // - `where.badgeType` filters out non-endorsement awards
+ // server-side (drops the previous batch query against
+ // `appCertifiedBadgeDefinition` + the local URI-match filter).
+ // - `issuer { ... }` denormalises the issuer's actor profile
+ // onto each award node (drops the per-row `/api/resolve-did`
+ // fan-out on first paint, once the operator enables
+ // `app.bsky.actor.profile` ingestion on magic-indexer dev).
+ // - `response { state }` carries the recipient's latest
+ // accept/reject response (drops the parallel PDS
+ // `listResponses` call in `useProfileResponses` for this
+ // hot path). Ordered by sort_at DESC NULLS LAST per indexer
+ // #26, so reset-to-default-then-accept resolves correctly.
+ ReceivedEndorsements: `
+ query ReceivedEndorsements($did: String!, $first: Int!, $after: String) {
+ appCertifiedBadgeAward(
+ where: { subject: { eq: $did }, badgeType: { eq: "endorsement" } }
+ first: $first
+ after: $after
+ ) {
+ edges {
+ node {
+ uri cid did createdAt note badge { uri cid }
+ issuer { did handle displayName description avatarCid pds }
+ response { state weight createdAt }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Subject DIDs endorsed by any of a set of evaluator DIDs. Backs
+ // the home feed's "trusted evaluators" expansion — selecting an
+ // evaluator pulls in the activity of everyone they've endorsed.
+ // Paginated because a single prolific evaluator can issue
+ // hundreds of awards; client unions pages into a Set.
+ EvaluatorEndorsements: `
+ query EvaluatorEndorsements($evaluators: [String!]!, $first: Int!, $after: String) {
+ appCertifiedBadgeAward(
+ where: { did: { in: $evaluators }, badgeType: { eq: "endorsement" } }
+ first: $first
+ after: $after
+ ) {
+ edges {
+ cursor
+ node {
+ did
+ subject {
+ __typename
+ ... on AppCertifiedDefsDid { did }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Every badge award of one `badgeType` across the network — backs the
+ // /endorsement-graph page, which scans once per type ("endorsement" and
+ // "award"; the proxy allowlists the value). Returns the directed edge
+ // (issuer `did` → `subject`) plus the issuer's denormalised actor
+ // profile (same `issuer { ... }` join as ReceivedEndorsements, drops a
+ // per-issuer resolve fan-out). The subject union carries the DID for
+ // account-targeted awards and the strong-ref `uri` for record-targeted
+ // ones (award-typed badges usually point at records; the at:// authority
+ // identifies the owning account). Subjects that never issued an award
+ // have no inline profile here, so the client resolves those DIDs via
+ // NetworkActorsByDids. `excludeAuthorLabels` hides awards authored by
+ // likely-test accounts, mirroring AwardCount so the graph matches the
+ // public counters. Paginated; the client unions pages up to a cap.
+ AllEndorsements: `
+ query AllEndorsements($badgeType: String!, $first: Int!, $after: String) {
+ appCertifiedBadgeAward(
+ where: { badgeType: { eq: $badgeType } }
+ excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
+ first: $first
+ after: $after
+ ) {
+ edges {
+ cursor
+ node {
+ uri
+ createdAt
+ note
+ did
+ subject {
+ __typename
+ ... on AppCertifiedDefsDid { did }
+ ... on ComAtprotoRepoStrongRef { uri }
+ }
+ issuer { did handle displayName avatarCid pds }
+ response { state }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Endorsement-typed badge definitions for a batch of issuer DIDs.
+ EndorsementDefs: `
+ query EndorsementDefs($dids: [String!]!, $first: Int!) {
+ appCertifiedBadgeDefinition(
+ where: { did: { in: $dids }, badgeType: { eq: "endorsement" } }
+ first: $first
+ ) {
+ edges { node { uri title } }
+ }
+ }
+ `,
+
+ // Network-wide actor list for the /workspace pages. Returns the
+ // most-recently-indexed profiles so the actor switcher has
+ // something to show even before the user has interacted.
+ NetworkActors: `
+ query NetworkActors(
+ $first: Int!
+ $after: String
+ $search: String
+ $authorLabels: [String!]
+ $excludeAuthorLabels: [String!]
+ ) {
+ appCertifiedActorProfile(
+ first: $first
+ after: $after
+ search: $search
+ authorLabels: $authorLabels
+ excludeAuthorLabels: $excludeAuthorLabels
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ did
+ displayName
+ description
+ createdAt
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Same shape as NetworkActors but server-side-filtered to a
+ // single kind via the indexer's `isOrganization` flag (see
+ // certified-app#107 / magic-indexer#145). Used by the /explore
+ // Accounts People / Organizations sub-toggle so the result list
+ // paginates over members of that kind only — replaces the
+ // previous "fetch a mixed page + intersect client-side against
+ // the first-200 org DIDs" path that silently dropped any org
+ // beyond the first page and produced under-shown People pages.
+ //
+ // Kept as a separate operation (rather than threading
+ // `$isOrganization: Boolean = null`) because graphql-go rejects
+ // explicit `null` on the `eq` operator — the only safe way to
+ // express "no filter" is to omit the `where` arg entirely, which
+ // means a different query string. Two operations is the smallest
+ // diff. The unfiltered case stays on the original NetworkActors
+ // op above.
+ NetworkActorsByKind: `
+ query NetworkActorsByKind(
+ $first: Int!
+ $after: String
+ $isOrganization: Boolean!
+ $search: String
+ $authorLabels: [String!]
+ $excludeAuthorLabels: [String!]
+ ) {
+ appCertifiedActorProfile(
+ first: $first
+ after: $after
+ search: $search
+ authorLabels: $authorLabels
+ excludeAuthorLabels: $excludeAuthorLabels
+ where: { isOrganization: { eq: $isOrganization } }
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ did
+ displayName
+ description
+ createdAt
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Just the DIDs of every actor that has published an
+ // app.certified.actor.organization record. Used by the /explore
+ // Users sub-category to split individuals from groups —
+ // displayName + avatar live on the actor-profile record, not the
+ // organization record, so consumers join both client-side.
+ OrganizationDids: `
+ query OrganizationDids($first: Int!, $after: String) {
+ appCertifiedActorOrganization(first: $first, after: $after) {
+ totalCount
+ edges { cursor node { did } }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Org DIDs filtered by orglabeler tier. Backs the explore page's
+ // "Account quality" filter on the certs + accounts tabs: get the
+ // set of org DIDs matching the viewer's tier selection, then
+ // either use it to scope the certs `authors` filter or to
+ // narrow the actor list. Same shape as `OrganizationDids` but
+ // adds the label include / exclude args.
+ OrganizationDidsByLabel: `
+ query OrganizationDidsByLabel(
+ $first: Int!
+ $after: String
+ $labels: [String!]
+ $excludeLabels: [String!]
+ ) {
+ appCertifiedActorOrganization(
+ first: $first
+ after: $after
+ labels: $labels
+ excludeLabels: $excludeLabels
+ ) {
+ totalCount
+ edges { cursor node { did } }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Actor profiles for a specific set of DIDs. Bypasses the 100-
+ // most-recently-indexed pagination cap on `NetworkActors` when
+ // the caller already knows which DIDs they want — used by the
+ // explore page's "Account quality (include only)" path, where
+ // we resolve org DIDs via `OrganizationDidsByLabel` and then
+ // fetch the matching profiles in one shot.
+ NetworkActorsByDids: `
+ query NetworkActorsByDids($dids: [String!]!) {
+ appCertifiedActorProfile(
+ first: 100
+ where: { did: { in: $dids } }
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ did
+ displayName
+ description
+ createdAt
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // "Of this DID set, which ones are organizations?" — a focused
+ // companion to NetworkActorsByDids that returns just the org-DID
+ // subset (no profile fields). Used by /explore Accounts to apply
+ // the People/Organizations sub-toggle on paths where the actor
+ // list comes from a known DID set rather than `fetchNetworkActors`:
+ // - Featured (Ma Earth curated projects → author DIDs)
+ // - Endorsed (closure result's inline issuer block)
+ // The server-side `isOrganization` filter on appCertifiedActorProfile
+ // can't run via fetchNetworkActors there because the actor list is
+ // already determined by a different upstream — but the same filter
+ // works keyed on a `did: { in: [...] }` predicate, which this op
+ // exposes. Returns at most 100 DIDs per call (MAX_FIRST cap on the
+ // upstream); callers chunk if their set is larger.
+ OrganizationDidsForSet: `
+ query OrganizationDidsForSet($dids: [String!]!) {
+ appCertifiedActorProfile(
+ first: 100
+ where: { did: { in: $dids }, isOrganization: { eq: true } }
+ ) {
+ edges { node { did } }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Parameterised "of this DID set, which match the given kind?".
+ // Replaces the complement-based approach (fetch ORG dids, then
+ // !has() for People) that silently inverted on the client when
+ // the call returned 0 results — caller couldn't distinguish
+ // "everyone is people" from "the call failed and the set is
+ // empty by mistake". With this op the result is unambiguous:
+ // returned DIDs ARE the matching kind, period.
+ DidsByKindInSet: `
+ query DidsByKindInSet(
+ $dids: [String!]!
+ $isOrganization: Boolean!
+ ) {
+ appCertifiedActorProfile(
+ first: 100
+ where: { did: { in: $dids }, isOrganization: { eq: $isOrganization } }
+ ) {
+ edges { node { did } }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Per-actor counts of the major lexicons. One round-trip via
+ // aliased connections — each branch shares the where: { did: $did }
+ // filter so we get five small headers back in one fetch.
+ ActorWorkspaceCounts: `
+ query ActorWorkspaceCounts($did: String!) {
+ certs: orgHypercertsClaimActivity(first: 1, where: { did: { eq: $did } }) {
+ totalCount
+ }
+ projects: orgHypercertsCollection(
+ first: 1
+ where: { did: { eq: $did }, type: { eqi: "project" } }
+ ) {
+ totalCount
+ }
+ lists: orgHypercertsCollection(
+ first: 1
+ where: { did: { eq: $did }, type: { eqi: "list:endorsements" } }
+ ) {
+ totalCount
+ }
+ endorsementsReceived: appCertifiedBadgeAward(
+ first: 1
+ where: { subject: { eq: $did }, badgeType: { eq: "endorsement" } }
+ ) {
+ totalCount
+ }
+ followers: appCertifiedGraphFollow(
+ first: 1
+ where: { subject: { eq: $did } }
+ ) {
+ totalCount
+ }
+ }
+ `,
+
+ // Network-wide counts for the /welcome landing-page stats strip.
+ // Each query asks for a single page (first: 1) just to surface
+ // `totalCount`; client discards the edge. Selection mirrors the
+ // shape every other op uses (`totalCount + edges + pageInfo`)
+ // because some GraphQL schemas reject a bare-aggregate selection
+ // on a connection root.
+ // "Users" = personal + org actors. Excludes accounts labelled "likely-test"
+ // via `excludeAuthorLabels` (account-quality labels live on the bare account
+ // DID, so the profile connection's own-record-URI `excludeLabels` would NOT
+ // match — see magic-indexer#206/#207). Unlabeled accounts still count.
+ ProfileCount: `
+ query ProfileCount {
+ appCertifiedActorProfile(
+ first: 1
+ excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
+ ) {
+ totalCount
+ edges { node { uri } }
+ pageInfo { hasNextPage }
+ }
+ }
+ `,
+ // Excludes orgs labelled "likely-test" so the public counter matches the
+ // explore/feed default policy (DEFAULT_HIDDEN_ORG_LABELS). Unlabeled orgs
+ // still count — labelers only weigh in once they've caught up.
+ OrganizationCount: `
+ query OrganizationCount {
+ appCertifiedActorOrganization(
+ first: 1
+ excludeLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
+ ) {
+ totalCount
+ edges { node { uri } }
+ pageInfo { hasNextPage }
+ }
+ }
+ `,
+ // Excludes activities by both the record's own label (draft / likely-test,
+ // Activity-Labeler tier — DEFAULT_HIDDEN_CERT_LABELS) AND the author's
+ // account label (records authored by likely-test accounts, via
+ // excludeAuthorLabels — magic-indexer#207). Unlabeled records / authors
+ // still count.
+ ActivityCount: `
+ query ActivityCount {
+ orgHypercertsClaimActivity(
+ first: 1
+ excludeLabels: ${JSON.stringify([...DEFAULT_HIDDEN_CERT_LABELS])}
+ excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
+ ) {
+ totalCount
+ edges { node { uri } }
+ pageInfo { hasNextPage }
+ }
+ }
+ `,
+ // Excludes projects authored by likely-test accounts (projects carry no
+ // own quality label, so only the author-account filter applies).
+ ProjectCount: `
+ query ProjectCount {
+ orgHypercertsCollection(
+ first: 1
+ where: { type: { eqi: "project" } }
+ excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
+ ) {
+ totalCount
+ edges { node { uri } }
+ pageInfo { hasNextPage }
+ }
+ }
+ `,
+ // Excludes endorsements created by likely-test accounts (the award's author
+ // is its issuer).
+ AwardCount: `
+ query AwardCount {
+ appCertifiedBadgeAward(
+ first: 1
+ excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
+ ) {
+ totalCount
+ edges { node { uri } }
+ pageInfo { hasNextPage }
+ }
+ }
+ `,
+
+ // Generic project listing for the /explore page. Takes an optional
+ // `authors` filter (null = no scope, [] = match nothing, [...] =
+ // restrict to those DIDs) and an optional case-insensitive
+ // free-text search. Returns the same node shape as UserProjects.
+ Projects: `
+ query Projects(
+ $first: Int!
+ $after: String
+ $authors: [String!]
+ $authorLabels: [String!]
+ $excludeAuthorLabels: [String!]
+ $search: String
+ ) {
+ orgHypercertsCollection(
+ first: $first
+ after: $after
+ where: { type: { eqi: "project" } }
+ authors: $authors
+ authorLabels: $authorLabels
+ excludeAuthorLabels: $excludeAuthorLabels
+ search: $search
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ cid
+ did
+ createdAt
+ title
+ shortDescription
+ items {
+ itemIdentifier {
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ }
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ banner {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Projects authored by a single DID. Replaces the per-DID PDS
+ // listRecords scan in useUserProjects: the indexer handles the
+ // case-insensitive "project" type filter server-side via eqi
+ // (magic-indexer#81), which means records storing the discriminator
+ // as "Project" / "PROJECT" surface here too.
+ //
+ // Selected fields cover what profile-projects renders: title,
+ // shortDescription, createdAt, avatar, banner, items[] (avatar feeds
+ // the thumb slot's avatar-first precedence). The indexer does NOT
+ // surface the legacy value.name / value.image fallback fields some
+ // older records use — that's intentional. Records on the older shape
+ // will read "Untitled project" / no banner here so authors notice
+ // and re-publish on the canonical shape while the dataset is small.
+ UserProjects: `
+ query UserProjects($did: String!, $first: Int!, $after: String) {
+ orgHypercertsCollection(
+ first: $first
+ after: $after
+ where: { did: { eq: $did }, type: { eqi: "project" } }
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ cid
+ did
+ createdAt
+ title
+ shortDescription
+ items {
+ itemIdentifier {
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ }
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ banner {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Cross-DID "projects containing this cert" — backs the cert
+ // detail page's Projects section. Replaces the per-DID PDS
+ // listRecords stopgap in `use-cert-projects.ts` now that
+ // magic-indexer #110's `itemUri` promoted filter has shipped.
+ // Returns the same `orgHypercertsCollection` node shape as
+ // `UserProjects` so the consumer can reuse the existing
+ // CollectionRecord rendering.
+ ProjectsContainingCert: `
+ query ProjectsContainingCert($certUri: String!, $first: Int!) {
+ orgHypercertsCollection(
+ first: $first
+ where: {
+ type: { eqi: "project" }
+ itemUri: { eq: $certUri }
+ }
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ cid
+ did
+ createdAt
+ title
+ shortDescription
+ items {
+ itemIdentifier {
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ }
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ banner {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Batch fetch of specific collection URIs in one round-trip — the
+ // getRecords-by-uri form for surfaces that already hold strongRefs
+ // to org.hypercerts.collection records (project items) and would
+ // otherwise fan out one getRecord per URI. Same
+ // `where: { uri: { in: $uris } }` shape as the HydrateFeedPage
+ // collections branch, and the same node selection so consumers can
+ // reuse the existing CollectionRecord rendering. The indexer caps
+ // the `in` list at MAX_URI_LIST_PER_KIND (50) entries; callers
+ // chunk larger sets.
+ CollectionsByUris: `
+ query CollectionsByUris($uris: [String!]!) {
+ orgHypercertsCollection(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $uris } }
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ createdAt
+ title
+ shortDescription
+ type
+ items {
+ itemIdentifier {
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ }
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ banner {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
+ }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Funding receipts (org.hypercerts.funding.receipt) for the /explore
+ // Funding tab. Both `from` and `to` are unions that are either an AT
+ // Protocol account (AppCertifiedDefsDid) or a free-text label
+ // (OrgHypercertsFundingReceiptText); `from` is nullable, `to` is not.
+ // `for` is an optional strongRef pointing at an
+ // org.hypercerts.claim.activity. The indexer can't filter by union
+ // variant, so the explore loader applies the "from OR to is an
+ // account" gate client-side after fetching.
+ // `attestations` + `confirmedBy` require magic-indexer #214; until that
+ // deploys this operation errors against the upstream and the funding
+ // view fail-softs to empty. Held off `staging` on feat/funding-attestations
+ // until #214 ships. `confirmedBy` is a nullable DID — null means "no
+ // filter"; a value restricts to payments with a third-party attestor of
+ // that DID (the /explore "Confirmed by" picker).
+ FundingReceipts: `
+ query FundingReceipts(
+ $first: Int!
+ $after: String
+ $authorLabels: [String!]
+ $excludeAuthorLabels: [String!]
+ $confirmedBy: String
+ ) {
+ orgHypercertsFundingReceipt(
+ first: $first
+ after: $after
+ authorLabels: $authorLabels
+ excludeAuthorLabels: $excludeAuthorLabels
+ confirmedBy: $confirmedBy
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ cid
+ did
+ createdAt
+ occurredAt
+ amount
+ currency
+ from {
+ __typename
+ ... on AppCertifiedDefsDid { did }
+ ... on OrgHypercertsFundingReceiptText { value }
+ }
+ to {
+ __typename
+ ... on AppCertifiedDefsDid { did }
+ ... on OrgHypercertsFundingReceiptText { value }
+ }
+ for { uri cid }
+ paymentRail
+ paymentNetwork
+ transactionId
+ notes
+ matchingReceipt { uri cid }
+ attestations { role did }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Funding receipts for a single activity — backs the activity detail
+ // page's Funding tab + overview preview. Same node shape as
+ // FundingReceipts above, but filtered to receipts whose `for` strongRef
+ // points at the given activity URI (`where: { for: { eq: $forUri } }`).
+ // Both `from` and `to` are unions (AppCertifiedDefsDid account /
+ // OrgHypercertsFundingReceiptText free-text); on this surface text
+ // parties are surfaced (wallet addresses) rather than blanked.
+ FundingReceiptsForActivity: `
+ query FundingReceiptsForActivity(
+ $forUri: String!
+ $first: Int!
+ $after: String
+ ) {
+ orgHypercertsFundingReceipt(
+ first: $first
+ after: $after
+ where: { for: { eq: $forUri } }
+ ) {
+ totalCount
+ edges {
+ cursor
+ node {
+ uri
+ cid
+ did
+ createdAt
+ occurredAt
+ amount
+ currency
+ from {
+ __typename
+ ... on AppCertifiedDefsDid { did }
+ ... on OrgHypercertsFundingReceiptText { value }
+ }
+ to {
+ __typename
+ ... on AppCertifiedDefsDid { did }
+ ... on OrgHypercertsFundingReceiptText { value }
+ }
+ for { uri cid }
+ paymentRail
+ paymentNetwork
+ transactionId
+ notes
+ matchingReceipt { uri cid }
+ attestations { role did }
+ }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+ `,
+
+ // Viewer-centric endorsement-graph closure — magic-indexer issue
+ // #117. Returns DIDs reachable within `degree` hops of `viewer`
+ // through active (non-rejected, endorsement-typed) badge awards,
+ // plus per-DID provenance (which degree-(d-1) accounts brought
+ // them in). Powers /explore "Endorsed users" filter via
+ // src/hooks/use-explore.ts → fetchEndorsementClosure → this
+ // operation. Truncates with `truncated: true` if the closure
+ // exceeds the server cap (default 3000); UI shows a "showing a
+ // subset" notice in that case.
+ EndorsementClosure: `
+ query EndorsementClosure($viewer: String!, $degree: Int!) {
+ endorsementClosure(viewer: $viewer, degree: $degree) {
+ accounts {
+ did
+ degree
+ via
+ issuer {
+ did
+ handle
+ displayName
+ description
+ avatarCid
+ pds
+ }
+ }
+ truncated
+ }
+ }
+ `,
+
+ // Home-timeline feed — magic-indexer #122. Single GraphQL field that
+ // returns the union of the viewer's relevant lexicon-level "create"
+ // events authored by `authors` (the viewer's follow union). The
+ // `actor` is denormalised onto each FeedEvent so the client doesn't
+ // need a per-row profile lookup. Headline render still hydrates the
+ // record via `HydrateFeedPage` because the `subjectUri` only carries
+ // the URI.
+ //
+ // Coded errors (returned via `errors[].extensions.code`):
+ // - AUTHORS_FILTER_TOO_LARGE — set on the indexer (cap is
+ // `MaxAuthorsFilterSize = 500`). The proxy also caps at 500
+ // so this should be unreachable in normal use.
+ // - INVALID_CURSOR — opaque cursor failed to decode.
+ // - AUTHORS_REQUIRED — defensive; proxy rejects first via the
+ // `readAuthorList` required-array check.
+ FollowerEvents: `
+ query FollowerEvents(
+ $authors: [String!]!
+ $first: Int!
+ $after: String
+ $kinds: [String!]
+ $sortBy: FollowerEventsSort
+ ) {
+ followerEvents(
+ authors: $authors
+ first: $first
+ after: $after
+ kinds: $kinds
+ sortBy: $sortBy
+ ) {
+ edges {
+ cursor
+ node {
+ id
+ kind
+ subjectUri
+ sortAt
+ actor {
+ did
+ handle
+ displayName
+ avatarCid
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+ `,
+
+ // Headline-render hydration for one FollowerEvents page. Buckets each
+ // event by `kind` into a per-lexicon URI list and fetches all four
+ // connections in one round-trip via `where: { uri: { in: $uris } }`.
+ // Empty arrays are valid (and expected — most pages only have a
+ // subset of the four kinds present); the indexer returns an empty
+ // connection for each empty filter.
+ //
+ // If `where: { uri: { in: [...] } }` is not supported by the indexer
+ // schema for one of these collections, the client falls back to a
+ // per-collection op fan-out — see the track-1 log for details.
+ HydrateFeedPage: `
+ query HydrateFeedPage(
+ $activityUris: [String!]!
+ $collectionUris: [String!]!
+ $badgeAwardUris: [String!]!
+ $evaluationUris: [String!]!
+ $measurementUris: [String!]!
+ $hyperboardUris: [String!]!
+ $attachmentUris: [String!]!
+ $activityExcludeLabels: [String!]
+ $activityIncludeLabels: [String!]
+ ) {
+ activities: orgHypercertsClaimActivity(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $activityUris } }
+ labels: $activityIncludeLabels
+ excludeLabels: $activityExcludeLabels
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ title
+ shortDescription
+ createdAt
+ startDate
+ endDate
+ labels
+ image {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ workScope {
+ ... on OrgHypercertsClaimActivityWorkScopeString { scope }
+ ... on OrgHypercertsWorkscopeCel { expression }
+ }
+ }
+ }
+ }
+ collections: orgHypercertsCollection(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $collectionUris } }
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ createdAt
+ title
+ shortDescription
+ type
+ items {
+ itemIdentifier {
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ }
+ avatar {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
+ }
+ banner {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
+ }
+ }
+ }
+ }
+ badgeAwards: appCertifiedBadgeAward(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $badgeAwardUris } }
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ createdAt
+ note
+ subject {
+ __typename
+ ... on AppCertifiedDefsDid { did }
+ }
+ }
+ }
+ }
+ evaluations: orgHypercertsContextEvaluation(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $evaluationUris } }
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ createdAt
+ summary
+ subject {
+ __typename
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ }
+ }
+ }
+ measurements: orgHypercertsContextMeasurement(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $measurementUris } }
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ createdAt
+ metric
+ value
+ unit
+ subjects {
+ __typename
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ }
+ }
+ }
+ hyperboards: orgHyperboardsBoard(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $hyperboardUris } }
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ createdAt
+ }
+ }
+ }
+ attachments: orgHypercertsContextAttachment(
+ first: ${MAX_URI_LIST_PER_KIND}
+ where: { uri: { in: $attachmentUris } }
+ ) {
+ edges {
+ node {
+ uri
+ cid
+ did
+ createdAt
+ title
+ shortDescription
+ subjects {
+ __typename
+ ... on ComAtprotoRepoStrongRef { uri cid }
+ }
+ content {
+ __typename
+ ... on OrgHypercertsDefsUri { uri }
+ ... on OrgHypercertsDefsSmallBlob { blob { ref mimeType } }
+ }
+ }
+ }
+ }
+ }
+ `,
+}
diff --git a/src/app/api/indexer/route.ts b/src/app/api/indexer/route.ts
index d011ebfc..09494973 100644
--- a/src/app/api/indexer/route.ts
+++ b/src/app/api/indexer/route.ts
@@ -3,18 +3,16 @@ import { checkCsrf } from "@/lib/auth/csrf"
import { enforceRateLimit, makeLimiter } from "@/lib/auth/rate-limit"
import { clientIp } from "@/lib/utils/ip"
import { logSafe } from "@/lib/utils/log-safe"
-import {
- DEFAULT_HIDDEN_CERT_LABELS,
- DEFAULT_HIDDEN_ORG_LABELS,
-} from "@/lib/atproto/labels"
+import { OPERATIONS } from "./operations"
+import { buildVariables, type ClientVariables } from "./variables"
/**
* Same-origin proxy in front of the Magic Indexer's public GraphQL
* endpoint.
*
* Trust boundary: the client sends an `operationName` + `variables`.
- * The server holds the actual query strings (see `OPERATIONS` below)
- * and per-operation variable validators. The indexer endpoint itself
+ * The server holds the actual query strings (see `OPERATIONS` in
+ * ./operations.ts) and per-operation variable validators. The indexer endpoint itself
* is public (read-only, no service-auth required for these
* operations), but holding the queries server-side means:
*
@@ -71,1634 +69,6 @@ const MAX_BODY_SIZE = 32 * 1024
// the global brake that ensures one IP can't monopolise the
// upstream throughput.
const LIMITER = makeLimiter("indexer-proxy", 240, 60)
-const MAX_FIRST = 100
-const MAX_FIRST_DEFINITIONS = 1000
-const MAX_FEED_PAGE_SIZE = 50
-const MAX_SEARCH_LEN = 200
-const MAX_AFTER_LEN = 1024
-const MAX_DID_LEN = 256
-const MAX_DID_LIST = 1000
-// Hard cap on `authors` for FollowerEvents, matching the indexer's
-// `MaxAuthorsFilterSize`. The client also pre-truncates to this value;
-// enforcing here is defence-in-depth so a manipulated request can't
-// push a 10k-entry array downstream.
-const MAX_AUTHORS_FILTER_SIZE = 500
-const MAX_LABEL_LIST = 50
-const MAX_LABEL_LEN = 64
-const MAX_KIND_LIST = 16
-const MAX_KIND_LEN = 64
-const MAX_URI_LEN = 512
-/** Per-kind URI cap for the `HydrateFeedPage` op (4 kinds × 50 = up to
- * 200 URIs total per feed page). Matches the indexer's hard cap on
- * the `where: { uri: { in: [...] } }` filter (50 entries; values
- * above that error out with "in list must contain 1 to 50 values").
- * The GraphQL query also embeds this as `first: ${MAX_URI_LIST_PER_KIND}`
- * so changing it here changes the page size on the wire too. */
-const MAX_URI_LIST_PER_KIND = 50
-
-/** Activity node selection — shared by the three activity ops below. */
-const ACTIVITY_NODE_SELECTION = `
- totalCount
- edges {
- cursor
- node {
- uri
- cid
- did
- title
- shortDescription
- createdAt
- startDate
- endDate
- labels
- image {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
- }
- workScope {
- ... on OrgHypercertsClaimActivityWorkScopeString { scope }
- ... on OrgHypercertsWorkscopeCel { expression }
- }
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
-`
-
-/**
- * Allowlist of GraphQL operations we forward. Names are stable across
- * client + server; query strings are server-only.
- *
- * NOTE on adding ops: a new entry MUST come with a `buildVariables`
- * branch below or the request 400s on unknown variables.
- */
-const OPERATIONS: Record = {
- // Global activity feed + per-label / per-author filters.
- Activities: `
- query Activities(
- $first: Int!
- $after: String
- $labels: [String!]
- $excludeLabels: [String!]
- $authors: [String!]
- $authorLabels: [String!]
- $excludeAuthorLabels: [String!]
- $search: String
- ) {
- orgHypercertsClaimActivity(
- first: $first
- after: $after
- labels: $labels
- excludeLabels: $excludeLabels
- authors: $authors
- authorLabels: $authorLabels
- excludeAuthorLabels: $excludeAuthorLabels
- search: $search
- ) {
-${ACTIVITY_NODE_SELECTION}
- }
- }
- `,
-
- // Fetch a specific set of activity URIs, with optional label
- // include / exclude filters applied server-side. Used by surfaces
- // that already know the URIs they want (e.g. the explore page's
- // Ma Earth featured filter) and need labels on the records so
- // the same Quality popover that filters server-side on the
- // generic Activities op also works here.
- ActivitiesByUris: `
- query ActivitiesByUris(
- $uris: [String!]!
- $labels: [String!]
- $excludeLabels: [String!]
- $authorLabels: [String!]
- $excludeAuthorLabels: [String!]
- ) {
- orgHypercertsClaimActivity(
- first: 100
- where: { uri: { in: $uris } }
- labels: $labels
- excludeLabels: $excludeLabels
- authorLabels: $authorLabels
- excludeAuthorLabels: $excludeAuthorLabels
- ) {
-${ACTIVITY_NODE_SELECTION}
- }
- }
- `,
-
- // Per-user "authored" activities (Certs tab > Created bucket).
- AuthoredActivities: `
- query AuthoredActivities(
- $did: String!
- $first: Int!
- $after: String
- $labels: [String!]
- $excludeLabels: [String!]
- $search: String
- ) {
- orgHypercertsClaimActivity(
- first: $first
- after: $after
- labels: $labels
- excludeLabels: $excludeLabels
- search: $search
- where: { did: { eq: $did } }
- ) {
-${ACTIVITY_NODE_SELECTION}
- }
- }
- `,
-
- // Per-user "contributed to" activities (Certs tab > Contributed bucket).
- ContributedActivities: `
- query ContributedActivities(
- $did: String!
- $first: Int!
- $after: String
- $labels: [String!]
- $excludeLabels: [String!]
- $search: String
- ) {
- orgHypercertsClaimActivity(
- first: $first
- after: $after
- labels: $labels
- excludeLabels: $excludeLabels
- search: $search
- where: { contributor: { eq: $did } }
- ) {
-${ACTIVITY_NODE_SELECTION}
- }
- }
- `,
-
- // Deduped union count of activities a profile CREATED or CONTRIBUTED
- // to. The `_or` returns each matching record once, so `totalCount` is
- // the exact unique count (created ∪ contributed) — unlike summing the
- // two per-bucket totals, which double-counts a record where the user
- // is both author and contributor.
- UserActivityCount: `
- query UserActivityCount($did: String!) {
- orgHypercertsClaimActivity(
- first: 1
- where: { _or: [{ did: { eq: $did } }, { contributor: { eq: $did } }] }
- ) {
- totalCount
- edges { node { uri } }
- pageInfo { hasNextPage }
- }
- }
- `,
-
- // Followers of a profile DID.
- Followers: `
- query Followers($did: String!, $first: Int!, $after: String) {
- appCertifiedGraphFollow(
- where: { subject: { eq: $did } }
- first: $first
- after: $after
- ) {
- totalCount
- edges { node { uri cid did createdAt } }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Endorsement awards received by a profile DID — single-query
- // shape per hb-agent/magic-indexer#96. Three indexer-side joins
- // collapsed in here:
- // - `where.badgeType` filters out non-endorsement awards
- // server-side (drops the previous batch query against
- // `appCertifiedBadgeDefinition` + the local URI-match filter).
- // - `issuer { ... }` denormalises the issuer's actor profile
- // onto each award node (drops the per-row `/api/resolve-did`
- // fan-out on first paint, once the operator enables
- // `app.bsky.actor.profile` ingestion on magic-indexer dev).
- // - `response { state }` carries the recipient's latest
- // accept/reject response (drops the parallel PDS
- // `listResponses` call in `useProfileResponses` for this
- // hot path). Ordered by sort_at DESC NULLS LAST per indexer
- // #26, so reset-to-default-then-accept resolves correctly.
- ReceivedEndorsements: `
- query ReceivedEndorsements($did: String!, $first: Int!, $after: String) {
- appCertifiedBadgeAward(
- where: { subject: { eq: $did }, badgeType: { eq: "endorsement" } }
- first: $first
- after: $after
- ) {
- edges {
- node {
- uri cid did createdAt note badge { uri cid }
- issuer { did handle displayName description avatarCid pds }
- response { state weight createdAt }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Subject DIDs endorsed by any of a set of evaluator DIDs. Backs
- // the home feed's "trusted evaluators" expansion — selecting an
- // evaluator pulls in the activity of everyone they've endorsed.
- // Paginated because a single prolific evaluator can issue
- // hundreds of awards; client unions pages into a Set.
- EvaluatorEndorsements: `
- query EvaluatorEndorsements($evaluators: [String!]!, $first: Int!, $after: String) {
- appCertifiedBadgeAward(
- where: { did: { in: $evaluators }, badgeType: { eq: "endorsement" } }
- first: $first
- after: $after
- ) {
- edges {
- cursor
- node {
- did
- subject {
- __typename
- ... on AppCertifiedDefsDid { did }
- }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Every badge award of one `badgeType` across the network — backs the
- // /endorsement-graph page, which scans once per type ("endorsement" and
- // "award"; the proxy allowlists the value). Returns the directed edge
- // (issuer `did` → `subject`) plus the issuer's denormalised actor
- // profile (same `issuer { ... }` join as ReceivedEndorsements, drops a
- // per-issuer resolve fan-out). The subject union carries the DID for
- // account-targeted awards and the strong-ref `uri` for record-targeted
- // ones (award-typed badges usually point at records; the at:// authority
- // identifies the owning account). Subjects that never issued an award
- // have no inline profile here, so the client resolves those DIDs via
- // NetworkActorsByDids. `excludeAuthorLabels` hides awards authored by
- // likely-test accounts, mirroring AwardCount so the graph matches the
- // public counters. Paginated; the client unions pages up to a cap.
- AllEndorsements: `
- query AllEndorsements($badgeType: String!, $first: Int!, $after: String) {
- appCertifiedBadgeAward(
- where: { badgeType: { eq: $badgeType } }
- excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
- first: $first
- after: $after
- ) {
- edges {
- cursor
- node {
- uri
- createdAt
- note
- did
- subject {
- __typename
- ... on AppCertifiedDefsDid { did }
- ... on ComAtprotoRepoStrongRef { uri }
- }
- issuer { did handle displayName avatarCid pds }
- response { state }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Endorsement-typed badge definitions for a batch of issuer DIDs.
- EndorsementDefs: `
- query EndorsementDefs($dids: [String!]!, $first: Int!) {
- appCertifiedBadgeDefinition(
- where: { did: { in: $dids }, badgeType: { eq: "endorsement" } }
- first: $first
- ) {
- edges { node { uri title } }
- }
- }
- `,
-
- // Network-wide actor list for the /workspace pages. Returns the
- // most-recently-indexed profiles so the actor switcher has
- // something to show even before the user has interacted.
- NetworkActors: `
- query NetworkActors(
- $first: Int!
- $after: String
- $search: String
- $authorLabels: [String!]
- $excludeAuthorLabels: [String!]
- ) {
- appCertifiedActorProfile(
- first: $first
- after: $after
- search: $search
- authorLabels: $authorLabels
- excludeAuthorLabels: $excludeAuthorLabels
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- did
- displayName
- description
- createdAt
- avatar {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
- }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Same shape as NetworkActors but server-side-filtered to a
- // single kind via the indexer's `isOrganization` flag (see
- // certified-app#107 / magic-indexer#145). Used by the /explore
- // Accounts People / Organizations sub-toggle so the result list
- // paginates over members of that kind only — replaces the
- // previous "fetch a mixed page + intersect client-side against
- // the first-200 org DIDs" path that silently dropped any org
- // beyond the first page and produced under-shown People pages.
- //
- // Kept as a separate operation (rather than threading
- // `$isOrganization: Boolean = null`) because graphql-go rejects
- // explicit `null` on the `eq` operator — the only safe way to
- // express "no filter" is to omit the `where` arg entirely, which
- // means a different query string. Two operations is the smallest
- // diff. The unfiltered case stays on the original NetworkActors
- // op above.
- NetworkActorsByKind: `
- query NetworkActorsByKind(
- $first: Int!
- $after: String
- $isOrganization: Boolean!
- $search: String
- $authorLabels: [String!]
- $excludeAuthorLabels: [String!]
- ) {
- appCertifiedActorProfile(
- first: $first
- after: $after
- search: $search
- authorLabels: $authorLabels
- excludeAuthorLabels: $excludeAuthorLabels
- where: { isOrganization: { eq: $isOrganization } }
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- did
- displayName
- description
- createdAt
- avatar {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
- }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Just the DIDs of every actor that has published an
- // app.certified.actor.organization record. Used by the /explore
- // Users sub-category to split individuals from groups —
- // displayName + avatar live on the actor-profile record, not the
- // organization record, so consumers join both client-side.
- OrganizationDids: `
- query OrganizationDids($first: Int!, $after: String) {
- appCertifiedActorOrganization(first: $first, after: $after) {
- totalCount
- edges { cursor node { did } }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Org DIDs filtered by orglabeler tier. Backs the explore page's
- // "Account quality" filter on the certs + accounts tabs: get the
- // set of org DIDs matching the viewer's tier selection, then
- // either use it to scope the certs `authors` filter or to
- // narrow the actor list. Same shape as `OrganizationDids` but
- // adds the label include / exclude args.
- OrganizationDidsByLabel: `
- query OrganizationDidsByLabel(
- $first: Int!
- $after: String
- $labels: [String!]
- $excludeLabels: [String!]
- ) {
- appCertifiedActorOrganization(
- first: $first
- after: $after
- labels: $labels
- excludeLabels: $excludeLabels
- ) {
- totalCount
- edges { cursor node { did } }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Actor profiles for a specific set of DIDs. Bypasses the 100-
- // most-recently-indexed pagination cap on `NetworkActors` when
- // the caller already knows which DIDs they want — used by the
- // explore page's "Account quality (include only)" path, where
- // we resolve org DIDs via `OrganizationDidsByLabel` and then
- // fetch the matching profiles in one shot.
- NetworkActorsByDids: `
- query NetworkActorsByDids($dids: [String!]!) {
- appCertifiedActorProfile(
- first: 100
- where: { did: { in: $dids } }
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- did
- displayName
- description
- createdAt
- avatar {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
- }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // "Of this DID set, which ones are organizations?" — a focused
- // companion to NetworkActorsByDids that returns just the org-DID
- // subset (no profile fields). Used by /explore Accounts to apply
- // the People/Organizations sub-toggle on paths where the actor
- // list comes from a known DID set rather than `fetchNetworkActors`:
- // - Featured (Ma Earth curated projects → author DIDs)
- // - Endorsed (closure result's inline issuer block)
- // The server-side `isOrganization` filter on appCertifiedActorProfile
- // can't run via fetchNetworkActors there because the actor list is
- // already determined by a different upstream — but the same filter
- // works keyed on a `did: { in: [...] }` predicate, which this op
- // exposes. Returns at most 100 DIDs per call (MAX_FIRST cap on the
- // upstream); callers chunk if their set is larger.
- OrganizationDidsForSet: `
- query OrganizationDidsForSet($dids: [String!]!) {
- appCertifiedActorProfile(
- first: 100
- where: { did: { in: $dids }, isOrganization: { eq: true } }
- ) {
- edges { node { did } }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Parameterised "of this DID set, which match the given kind?".
- // Replaces the complement-based approach (fetch ORG dids, then
- // !has() for People) that silently inverted on the client when
- // the call returned 0 results — caller couldn't distinguish
- // "everyone is people" from "the call failed and the set is
- // empty by mistake". With this op the result is unambiguous:
- // returned DIDs ARE the matching kind, period.
- DidsByKindInSet: `
- query DidsByKindInSet(
- $dids: [String!]!
- $isOrganization: Boolean!
- ) {
- appCertifiedActorProfile(
- first: 100
- where: { did: { in: $dids }, isOrganization: { eq: $isOrganization } }
- ) {
- edges { node { did } }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Per-actor counts of the major lexicons. One round-trip via
- // aliased connections — each branch shares the where: { did: $did }
- // filter so we get five small headers back in one fetch.
- ActorWorkspaceCounts: `
- query ActorWorkspaceCounts($did: String!) {
- certs: orgHypercertsClaimActivity(first: 1, where: { did: { eq: $did } }) {
- totalCount
- }
- projects: orgHypercertsCollection(
- first: 1
- where: { did: { eq: $did }, type: { eqi: "project" } }
- ) {
- totalCount
- }
- lists: orgHypercertsCollection(
- first: 1
- where: { did: { eq: $did }, type: { eqi: "list:endorsements" } }
- ) {
- totalCount
- }
- endorsementsReceived: appCertifiedBadgeAward(
- first: 1
- where: { subject: { eq: $did }, badgeType: { eq: "endorsement" } }
- ) {
- totalCount
- }
- followers: appCertifiedGraphFollow(
- first: 1
- where: { subject: { eq: $did } }
- ) {
- totalCount
- }
- }
- `,
-
- // Network-wide counts for the /welcome landing-page stats strip.
- // Each query asks for a single page (first: 1) just to surface
- // `totalCount`; client discards the edge. Selection mirrors the
- // shape every other op uses (`totalCount + edges + pageInfo`)
- // because some GraphQL schemas reject a bare-aggregate selection
- // on a connection root.
- // "Users" = personal + org actors. Excludes accounts labelled "likely-test"
- // via `excludeAuthorLabels` (account-quality labels live on the bare account
- // DID, so the profile connection's own-record-URI `excludeLabels` would NOT
- // match — see magic-indexer#206/#207). Unlabeled accounts still count.
- ProfileCount: `
- query ProfileCount {
- appCertifiedActorProfile(
- first: 1
- excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
- ) {
- totalCount
- edges { node { uri } }
- pageInfo { hasNextPage }
- }
- }
- `,
- // Excludes orgs labelled "likely-test" so the public counter matches the
- // explore/feed default policy (DEFAULT_HIDDEN_ORG_LABELS). Unlabeled orgs
- // still count — labelers only weigh in once they've caught up.
- OrganizationCount: `
- query OrganizationCount {
- appCertifiedActorOrganization(
- first: 1
- excludeLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
- ) {
- totalCount
- edges { node { uri } }
- pageInfo { hasNextPage }
- }
- }
- `,
- // Excludes activities by both the record's own label (draft / likely-test,
- // Activity-Labeler tier — DEFAULT_HIDDEN_CERT_LABELS) AND the author's
- // account label (records authored by likely-test accounts, via
- // excludeAuthorLabels — magic-indexer#207). Unlabeled records / authors
- // still count.
- ActivityCount: `
- query ActivityCount {
- orgHypercertsClaimActivity(
- first: 1
- excludeLabels: ${JSON.stringify([...DEFAULT_HIDDEN_CERT_LABELS])}
- excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
- ) {
- totalCount
- edges { node { uri } }
- pageInfo { hasNextPage }
- }
- }
- `,
- // Excludes projects authored by likely-test accounts (projects carry no
- // own quality label, so only the author-account filter applies).
- ProjectCount: `
- query ProjectCount {
- orgHypercertsCollection(
- first: 1
- where: { type: { eqi: "project" } }
- excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
- ) {
- totalCount
- edges { node { uri } }
- pageInfo { hasNextPage }
- }
- }
- `,
- // Excludes endorsements created by likely-test accounts (the award's author
- // is its issuer).
- AwardCount: `
- query AwardCount {
- appCertifiedBadgeAward(
- first: 1
- excludeAuthorLabels: ${JSON.stringify([...DEFAULT_HIDDEN_ORG_LABELS])}
- ) {
- totalCount
- edges { node { uri } }
- pageInfo { hasNextPage }
- }
- }
- `,
-
- // Generic project listing for the /explore page. Takes an optional
- // `authors` filter (null = no scope, [] = match nothing, [...] =
- // restrict to those DIDs) and an optional case-insensitive
- // free-text search. Returns the same node shape as UserProjects.
- Projects: `
- query Projects(
- $first: Int!
- $after: String
- $authors: [String!]
- $authorLabels: [String!]
- $excludeAuthorLabels: [String!]
- $search: String
- ) {
- orgHypercertsCollection(
- first: $first
- after: $after
- where: { type: { eqi: "project" } }
- authors: $authors
- authorLabels: $authorLabels
- excludeAuthorLabels: $excludeAuthorLabels
- search: $search
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- cid
- did
- createdAt
- title
- shortDescription
- items {
- itemIdentifier {
- ... on ComAtprotoRepoStrongRef { uri cid }
- }
- }
- banner {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
- }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Projects authored by a single DID. Replaces the per-DID PDS
- // listRecords scan in useUserProjects: the indexer handles the
- // case-insensitive "project" type filter server-side via eqi
- // (magic-indexer#81), which means records storing the discriminator
- // as "Project" / "PROJECT" surface here too.
- //
- // Selected fields cover what profile-projects renders: title,
- // shortDescription, createdAt, banner, items[]. The indexer does NOT
- // surface the legacy value.name / value.image fallback fields some
- // older records use — that's intentional. Records on the older shape
- // will read "Untitled project" / no banner here so authors notice
- // and re-publish on the canonical shape while the dataset is small.
- UserProjects: `
- query UserProjects($did: String!, $first: Int!, $after: String) {
- orgHypercertsCollection(
- first: $first
- after: $after
- where: { did: { eq: $did }, type: { eqi: "project" } }
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- cid
- did
- createdAt
- title
- shortDescription
- items {
- itemIdentifier {
- ... on ComAtprotoRepoStrongRef { uri cid }
- }
- }
- banner {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
- }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Cross-DID "projects containing this cert" — backs the cert
- // detail page's Projects section. Replaces the per-DID PDS
- // listRecords stopgap in `use-cert-projects.ts` now that
- // magic-indexer #110's `itemUri` promoted filter has shipped.
- // Returns the same `orgHypercertsCollection` node shape as
- // `UserProjects` so the consumer can reuse the existing
- // CollectionRecord rendering.
- ProjectsContainingCert: `
- query ProjectsContainingCert($certUri: String!, $first: Int!) {
- orgHypercertsCollection(
- first: $first
- where: {
- type: { eqi: "project" }
- itemUri: { eq: $certUri }
- }
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- cid
- did
- createdAt
- title
- shortDescription
- items {
- itemIdentifier {
- ... on ComAtprotoRepoStrongRef { uri cid }
- }
- }
- banner {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
- }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Funding receipts (org.hypercerts.funding.receipt) for the /explore
- // Funding tab. Both `from` and `to` are unions that are either an AT
- // Protocol account (AppCertifiedDefsDid) or a free-text label
- // (OrgHypercertsFundingReceiptText); `from` is nullable, `to` is not.
- // `for` is an optional strongRef pointing at an
- // org.hypercerts.claim.activity. The indexer can't filter by union
- // variant, so the explore loader applies the "from OR to is an
- // account" gate client-side after fetching.
- // `attestations` + `confirmedBy` require magic-indexer #214; until that
- // deploys this operation errors against the upstream and the funding
- // view fail-softs to empty. Held off `staging` on feat/funding-attestations
- // until #214 ships. `confirmedBy` is a nullable DID — null means "no
- // filter"; a value restricts to payments with a third-party attestor of
- // that DID (the /explore "Confirmed by" picker).
- FundingReceipts: `
- query FundingReceipts(
- $first: Int!
- $after: String
- $authorLabels: [String!]
- $excludeAuthorLabels: [String!]
- $confirmedBy: String
- ) {
- orgHypercertsFundingReceipt(
- first: $first
- after: $after
- authorLabels: $authorLabels
- excludeAuthorLabels: $excludeAuthorLabels
- confirmedBy: $confirmedBy
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- cid
- did
- createdAt
- occurredAt
- amount
- currency
- from {
- __typename
- ... on AppCertifiedDefsDid { did }
- ... on OrgHypercertsFundingReceiptText { value }
- }
- to {
- __typename
- ... on AppCertifiedDefsDid { did }
- ... on OrgHypercertsFundingReceiptText { value }
- }
- for { uri cid }
- paymentRail
- paymentNetwork
- transactionId
- notes
- matchingReceipt { uri cid }
- attestations { role did }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Funding receipts for a single activity — backs the activity detail
- // page's Funding tab + overview preview. Same node shape as
- // FundingReceipts above, but filtered to receipts whose `for` strongRef
- // points at the given activity URI (`where: { for: { eq: $forUri } }`).
- // Both `from` and `to` are unions (AppCertifiedDefsDid account /
- // OrgHypercertsFundingReceiptText free-text); on this surface text
- // parties are surfaced (wallet addresses) rather than blanked.
- FundingReceiptsForActivity: `
- query FundingReceiptsForActivity(
- $forUri: String!
- $first: Int!
- $after: String
- ) {
- orgHypercertsFundingReceipt(
- first: $first
- after: $after
- where: { for: { eq: $forUri } }
- ) {
- totalCount
- edges {
- cursor
- node {
- uri
- cid
- did
- createdAt
- occurredAt
- amount
- currency
- from {
- __typename
- ... on AppCertifiedDefsDid { did }
- ... on OrgHypercertsFundingReceiptText { value }
- }
- to {
- __typename
- ... on AppCertifiedDefsDid { did }
- ... on OrgHypercertsFundingReceiptText { value }
- }
- for { uri cid }
- paymentRail
- paymentNetwork
- transactionId
- notes
- matchingReceipt { uri cid }
- attestations { role did }
- }
- }
- pageInfo { hasNextPage endCursor }
- }
- }
- `,
-
- // Viewer-centric endorsement-graph closure — magic-indexer issue
- // #117. Returns DIDs reachable within `degree` hops of `viewer`
- // through active (non-rejected, endorsement-typed) badge awards,
- // plus per-DID provenance (which degree-(d-1) accounts brought
- // them in). Powers /explore "Endorsed users" filter via
- // src/hooks/use-explore.ts → fetchEndorsementClosure → this
- // operation. Truncates with `truncated: true` if the closure
- // exceeds the server cap (default 3000); UI shows a "showing a
- // subset" notice in that case.
- EndorsementClosure: `
- query EndorsementClosure($viewer: String!, $degree: Int!) {
- endorsementClosure(viewer: $viewer, degree: $degree) {
- accounts {
- did
- degree
- via
- issuer {
- did
- handle
- displayName
- description
- avatarCid
- pds
- }
- }
- truncated
- }
- }
- `,
-
- // Home-timeline feed — magic-indexer #122. Single GraphQL field that
- // returns the union of the viewer's relevant lexicon-level "create"
- // events authored by `authors` (the viewer's follow union). The
- // `actor` is denormalised onto each FeedEvent so the client doesn't
- // need a per-row profile lookup. Headline render still hydrates the
- // record via `HydrateFeedPage` because the `subjectUri` only carries
- // the URI.
- //
- // Coded errors (returned via `errors[].extensions.code`):
- // - AUTHORS_FILTER_TOO_LARGE — set on the indexer (cap is
- // `MaxAuthorsFilterSize = 500`). The proxy also caps at 500
- // so this should be unreachable in normal use.
- // - INVALID_CURSOR — opaque cursor failed to decode.
- // - AUTHORS_REQUIRED — defensive; proxy rejects first via the
- // `readAuthorList` required-array check.
- FollowerEvents: `
- query FollowerEvents(
- $authors: [String!]!
- $first: Int!
- $after: String
- $kinds: [String!]
- $sortBy: FollowerEventsSort
- ) {
- followerEvents(
- authors: $authors
- first: $first
- after: $after
- kinds: $kinds
- sortBy: $sortBy
- ) {
- edges {
- cursor
- node {
- id
- kind
- subjectUri
- sortAt
- actor {
- did
- handle
- displayName
- avatarCid
- }
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- `,
-
- // Headline-render hydration for one FollowerEvents page. Buckets each
- // event by `kind` into a per-lexicon URI list and fetches all four
- // connections in one round-trip via `where: { uri: { in: $uris } }`.
- // Empty arrays are valid (and expected — most pages only have a
- // subset of the four kinds present); the indexer returns an empty
- // connection for each empty filter.
- //
- // If `where: { uri: { in: [...] } }` is not supported by the indexer
- // schema for one of these collections, the client falls back to a
- // per-collection op fan-out — see the track-1 log for details.
- HydrateFeedPage: `
- query HydrateFeedPage(
- $activityUris: [String!]!
- $collectionUris: [String!]!
- $badgeAwardUris: [String!]!
- $evaluationUris: [String!]!
- $measurementUris: [String!]!
- $hyperboardUris: [String!]!
- $attachmentUris: [String!]!
- $activityExcludeLabels: [String!]
- $activityIncludeLabels: [String!]
- ) {
- activities: orgHypercertsClaimActivity(
- first: ${MAX_URI_LIST_PER_KIND}
- where: { uri: { in: $activityUris } }
- labels: $activityIncludeLabels
- excludeLabels: $activityExcludeLabels
- ) {
- edges {
- node {
- uri
- cid
- did
- title
- shortDescription
- createdAt
- startDate
- endDate
- labels
- image {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
- }
- workScope {
- ... on OrgHypercertsClaimActivityWorkScopeString { scope }
- ... on OrgHypercertsWorkscopeCel { expression }
- }
- }
- }
- }
- collections: orgHypercertsCollection(
- first: ${MAX_URI_LIST_PER_KIND}
- where: { uri: { in: $collectionUris } }
- ) {
- edges {
- node {
- uri
- cid
- did
- createdAt
- title
- shortDescription
- type
- items {
- itemIdentifier {
- ... on ComAtprotoRepoStrongRef { uri cid }
- }
- }
- avatar {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsSmallImage { image { ref mimeType } }
- }
- banner {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsLargeImage { image { ref mimeType } }
- }
- }
- }
- }
- badgeAwards: appCertifiedBadgeAward(
- first: ${MAX_URI_LIST_PER_KIND}
- where: { uri: { in: $badgeAwardUris } }
- ) {
- edges {
- node {
- uri
- cid
- did
- createdAt
- note
- subject {
- __typename
- ... on AppCertifiedDefsDid { did }
- }
- }
- }
- }
- evaluations: orgHypercertsContextEvaluation(
- first: ${MAX_URI_LIST_PER_KIND}
- where: { uri: { in: $evaluationUris } }
- ) {
- edges {
- node {
- uri
- cid
- did
- createdAt
- summary
- subject {
- __typename
- ... on ComAtprotoRepoStrongRef { uri cid }
- }
- }
- }
- }
- measurements: orgHypercertsContextMeasurement(
- first: ${MAX_URI_LIST_PER_KIND}
- where: { uri: { in: $measurementUris } }
- ) {
- edges {
- node {
- uri
- cid
- did
- createdAt
- metric
- value
- unit
- subjects {
- __typename
- ... on ComAtprotoRepoStrongRef { uri cid }
- }
- }
- }
- }
- hyperboards: orgHyperboardsBoard(
- first: ${MAX_URI_LIST_PER_KIND}
- where: { uri: { in: $hyperboardUris } }
- ) {
- edges {
- node {
- uri
- cid
- did
- createdAt
- }
- }
- }
- attachments: orgHypercertsContextAttachment(
- first: ${MAX_URI_LIST_PER_KIND}
- where: { uri: { in: $attachmentUris } }
- ) {
- edges {
- node {
- uri
- cid
- did
- createdAt
- title
- shortDescription
- subjects {
- __typename
- ... on ComAtprotoRepoStrongRef { uri cid }
- }
- content {
- __typename
- ... on OrgHypercertsDefsUri { uri }
- ... on OrgHypercertsDefsSmallBlob { blob { ref mimeType } }
- }
- }
- }
- }
- }
- `,
-}
-
-type ClientVariables = Record
-
-function clampFirst(value: unknown, max: number, fallback: number): number {
- if (typeof value !== "number" || !Number.isFinite(value)) return fallback
- return Math.min(Math.max(1, Math.floor(value)), max)
-}
-
-function readString(value: unknown, maxLen: number): string | null {
- if (typeof value !== "string") return null
- if (value.length === 0 || value.length > maxLen) return null
- return value
-}
-
-function readDid(value: unknown): string | null {
- const s = readString(value, MAX_DID_LEN)
- if (!s) return null
- return s.startsWith("did:") ? s : null
-}
-
-function readDidList(value: unknown, maxItems: number): string[] | null {
- if (!Array.isArray(value)) return null
- if (value.length === 0 || value.length > maxItems) return null
- // Fail-soft: filter out non-DID entries silently rather than
- // rejecting the whole batch. A single malformed DID in the
- // indexed data (e.g. a contributor field that wasn't normalised
- // upstream) shouldn't take out an entire Received-endorsements
- // panel for every viewer. Issue #73 / round-2 receivers' fix.
- // Returns null only when nothing valid remains — at that point
- // the caller's GraphQL `where: { did: { in: [] } }` would return
- // empty anyway, so saving a round-trip.
- const out: string[] = []
- for (const item of value) {
- const did = readDid(item)
- if (did) out.push(did)
- }
- if (out.length === 0) return null
- return out
-}
-
-function readOptionalDidList(value: unknown): string[] | null | undefined {
- // tri-state: undefined (no filter), [] (match nothing), [...] (filter)
- if (value === undefined || value === null) return undefined
- if (!Array.isArray(value)) return undefined
- if (value.length === 0) return []
- if (value.length > MAX_DID_LIST) return undefined
- const out: string[] = []
- for (const item of value) {
- const did = readDid(item)
- if (!did) return undefined
- out.push(did)
- }
- return out
-}
-
-function readLabelList(value: unknown): string[] | null {
- if (value === undefined || value === null) return null
- if (!Array.isArray(value)) return null
- if (value.length === 0 || value.length > MAX_LABEL_LIST) return null
- const out: string[] = []
- for (const item of value) {
- if (typeof item !== "string") return null
- if (item.length === 0 || item.length > MAX_LABEL_LEN) return null
- out.push(item)
- }
- return out
-}
-
-/**
- * Reads the `authors` argument for `FollowerEvents`.
- *
- * - Required (cannot be omitted; the indexer's `AUTHORS_REQUIRED` is
- * defensive, our proxy rejects first).
- * - Length 0..MAX_AUTHORS_FILTER_SIZE inclusive. The empty array is
- * load-bearing: the upstream returns an empty connection rather
- * than an error, which the client uses for the
- * no-follows-yet case.
- * - Per-entry: non-DID strings are filtered out silently
- * (fail-soft, matching `readDidList`). Returns null only on
- * structural failure or oversize, not on bad-entry content —
- * a single malformed DID in a viewer's follow list shouldn't
- * take out their entire feed.
- */
-function readAuthorList(value: unknown): string[] | null {
- if (!Array.isArray(value)) return null
- if (value.length > MAX_AUTHORS_FILTER_SIZE) return null
- const out: string[] = []
- for (const item of value) {
- const did = readDid(item)
- if (did) out.push(did)
- }
- return out
-}
-
-/**
- * Reads the optional `kinds` inclusion filter on `FollowerEvents`.
- * The cap numbers are defensive defaults (the spec doesn't mandate
- * them), kept tight so a manipulated request can't push pathological
- * inputs downstream. Returns null for structurally-invalid input
- * (non-array / non-string entry / oversized), which 400s the request.
- */
-function readKindList(value: unknown): string[] | null | undefined {
- if (value === undefined || value === null) return undefined
- if (!Array.isArray(value)) return null
- if (value.length === 0) return undefined
- if (value.length > MAX_KIND_LIST) return null
- const out: string[] = []
- for (const item of value) {
- if (typeof item !== "string") return null
- if (item.length === 0 || item.length > MAX_KIND_LEN) return null
- out.push(item)
- }
- return out
-}
-
-/**
- * Reads the optional `sortBy` enum for `FollowerEvents`. The indexer
- * accepts `SORT_AT` (default) or `CREATED_AT` (matches the rendered
- * "X ago" order — see magic-indexer#136). Anything else is dropped to
- * null so a manipulated request can't push an unknown enum literal
- * downstream; the indexer then falls back to its server default.
- */
-function readFollowerEventsSort(value: unknown): "SORT_AT" | "CREATED_AT" | null {
- if (value === "SORT_AT" || value === "CREATED_AT") return value
- return null
-}
-
-/**
- * Reads one of the `*Uris` array variables. Length 0..`maxItems`
- * inclusive — empty arrays pass through because a typical
- * `HydrateFeedPage` call only has events of a few kinds and the
- * unused kinds should be `[]`. The `maxItems` arg lets the
- * `ActivitiesByUris` path accept a larger set than the per-kind
- * hydration arrays (one indexer page = 100 URIs, vs the feed
- * hydration's 50-per-kind page-size cap).
- */
-function readUriList(value: unknown, maxItems: number): string[] | null {
- if (!Array.isArray(value)) return null
- if (value.length > maxItems) return null
- const out: string[] = []
- for (const item of value) {
- if (typeof item !== "string") return null
- if (item.length === 0 || item.length > MAX_URI_LEN) return null
- // Defensive prefix check — every consumer of this list passes
- // the values as a GraphQL `$uris` variable (not body-interpolated),
- // so the actual injection risk is zero. Rejecting non-at:// values
- // here makes a manipulated request fail at the proxy with a 400
- // instead of producing an empty result downstream.
- if (!item.startsWith("at://")) return null
- out.push(item)
- }
- return out
-}
-
-/**
- * Normalize client-supplied variables per-operation. Returns null when
- * required vars are missing or malformed — the route then 400s.
- *
- * Required vars are pulled with strict readers (`readDid` etc.) that
- * return null on miss. Optional vars are pulled with permissive
- * readers that fall back to `null` so the GraphQL query receives the
- * "no filter" sentinel.
- */
-function buildVariables(
- operationName: string,
- vars: ClientVariables,
-): Record | null {
- switch (operationName) {
- case "Activities": {
- const authors = readOptionalDidList(vars.authors)
- return {
- first: clampFirst(vars.first, MAX_FIRST, 20),
- after: readString(vars.after, MAX_AFTER_LEN),
- labels: readLabelList(vars.labels),
- excludeLabels: readLabelList(vars.excludeLabels),
- authors: authors === undefined ? null : authors,
- authorLabels: readLabelList(vars.authorLabels),
- excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
- search: readString(vars.search, MAX_SEARCH_LEN),
- }
- }
- case "ActivitiesByUris": {
- const uris = readUriList(vars.uris, MAX_URI_LIST_PER_KIND)
- if (uris === null) return null
- return {
- uris,
- labels: readLabelList(vars.labels),
- excludeLabels: readLabelList(vars.excludeLabels),
- authorLabels: readLabelList(vars.authorLabels),
- excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
- }
- }
- case "AuthoredActivities":
- case "ContributedActivities": {
- const did = readDid(vars.did)
- if (!did) return null
- return {
- did,
- first: clampFirst(vars.first, MAX_FIRST, 20),
- after: readString(vars.after, MAX_AFTER_LEN),
- labels: readLabelList(vars.labels),
- excludeLabels: readLabelList(vars.excludeLabels),
- search: readString(vars.search, MAX_SEARCH_LEN),
- }
- }
- case "Followers":
- case "ReceivedEndorsements": {
- const did = readDid(vars.did)
- if (!did) return null
- return {
- did,
- first: clampFirst(vars.first, MAX_FIRST, 100),
- after: readString(vars.after, MAX_AFTER_LEN),
- }
- }
- case "UserActivityCount": {
- const did = readDid(vars.did)
- if (!did) return null
- return { did }
- }
- case "EndorsementDefs": {
- const dids = readDidList(vars.dids, MAX_DID_LIST)
- if (!dids) return null
- return {
- dids,
- first: clampFirst(vars.first, MAX_FIRST_DEFINITIONS, MAX_FIRST_DEFINITIONS),
- }
- }
- case "ProfileCount":
- case "OrganizationCount":
- case "ActivityCount":
- case "ProjectCount":
- case "AwardCount": {
- // Zero-argument operations — nothing to validate. Return an
- // empty object so the route's null-check passes and the
- // query is forwarded.
- return {}
- }
- case "UserProjects": {
- const did = readDid(vars.did)
- if (!did) return null
- return {
- did,
- first: clampFirst(vars.first, MAX_FIRST, 50),
- after: readString(vars.after, MAX_AFTER_LEN),
- }
- }
- case "ProjectsContainingCert": {
- const certUri = readString(vars.certUri, MAX_URI_LEN)
- if (!certUri || !certUri.startsWith("at://")) return null
- return {
- certUri,
- first: clampFirst(vars.first, MAX_FIRST, 50),
- }
- }
- case "Projects": {
- const authors = readOptionalDidList(vars.authors)
- return {
- first: clampFirst(vars.first, MAX_FIRST, 24),
- after: readString(vars.after, MAX_AFTER_LEN),
- authors: authors === undefined ? null : authors,
- authorLabels: readLabelList(vars.authorLabels),
- excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
- search: readString(vars.search, MAX_SEARCH_LEN),
- }
- }
- case "NetworkActors": {
- return {
- first: clampFirst(vars.first, MAX_FIRST, 20),
- after: readString(vars.after, MAX_AFTER_LEN),
- search: readString(vars.search, MAX_SEARCH_LEN),
- authorLabels: readLabelList(vars.authorLabels),
- excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
- }
- }
- case "OrganizationDids": {
- return {
- first: clampFirst(vars.first, MAX_FIRST, 20),
- after: readString(vars.after, MAX_AFTER_LEN),
- }
- }
- case "NetworkActorsByKind": {
- // `isOrganization` is non-nullable on the upstream op (the
- // indexer rejects `eq: null`); reject missing / non-boolean
- // inputs so the route's contract matches the upstream's.
- if (typeof vars.isOrganization !== "boolean") return null
- return {
- first: clampFirst(vars.first, MAX_FIRST, 20),
- after: readString(vars.after, MAX_AFTER_LEN),
- isOrganization: vars.isOrganization,
- search: readString(vars.search, MAX_SEARCH_LEN),
- authorLabels: readLabelList(vars.authorLabels),
- excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
- }
- }
- case "OrganizationDidsByLabel": {
- return {
- first: clampFirst(vars.first, MAX_FIRST, 100),
- after: readString(vars.after, MAX_AFTER_LEN),
- labels: readLabelList(vars.labels),
- excludeLabels: readLabelList(vars.excludeLabels),
- }
- }
- case "NetworkActorsByDids": {
- // Reuse the author-list reader: same shape (DID list, ≤500),
- // same defensive truncation. Empty list is rejected — the
- // op is meaningless without a target set.
- const dids = readAuthorList(vars.dids)
- if (dids === null || dids.length === 0) return null
- return { dids }
- }
- case "OrganizationDidsForSet": {
- // Same shape as `NetworkActorsByDids`: a DID-set narrowing
- // op. Returns just the org-DID subset; consumers chunk to
- // stay under the upstream `first: 100` cap.
- const dids = readAuthorList(vars.dids)
- if (dids === null || dids.length === 0) return null
- return { dids }
- }
- case "DidsByKindInSet": {
- // DID-set narrowing + kind filter. Same validation as the
- // OrganizationDidsForSet op above, plus a required boolean
- // for the kind (graphql-go rejects `eq: null`, so we don't
- // accept undefined here — callers pick "people" or
- // "organizations" explicitly).
- const dids = readAuthorList(vars.dids)
- if (dids === null || dids.length === 0) return null
- if (typeof vars.isOrganization !== "boolean") return null
- return { dids, isOrganization: vars.isOrganization }
- }
- case "ActorWorkspaceCounts": {
- const did = readDid(vars.did)
- if (!did) return null
- return { did }
- }
- case "FundingReceipts": {
- // Paginated read. Clamp `first` like the other paginated ops;
- // `after` is the opaque cursor. The optional author-label filters
- // gate receipts by the creator's account (orglabeler) tier so the
- // Funding tab can hide receipts authored by likely-test accounts
- // (magic-indexer#207); `confirmedBy` is an optional third-party-
- // attestor DID filter (magic-indexer #214), forwarded only when it's
- // a valid DID, otherwise null ("no filter").
- return {
- first: clampFirst(vars.first, MAX_FIRST, 50),
- after: readString(vars.after, MAX_AFTER_LEN),
- authorLabels: readLabelList(vars.authorLabels),
- excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
- confirmedBy: readDid(vars.confirmedBy),
- }
- }
- case "FundingReceiptsForActivity": {
- // Required `forUri` — a single at:// activity URI to filter by.
- // Mirrors the URI validation the ProjectsContainingCert op uses.
- const forUri = readString(vars.forUri, MAX_URI_LEN)
- if (!forUri || !forUri.startsWith("at://")) return null
- return {
- forUri,
- first: clampFirst(vars.first, MAX_FIRST, 50),
- after: readString(vars.after, MAX_AFTER_LEN),
- }
- }
- case "EndorsementClosure": {
- // Viewer-centric BFS closure (magic-indexer #117). `viewer`
- // must be a DID; `degree` must be ∈ {1, 2, 3}. Validation here
- // mirrors the indexer-side gate so a malformed request 400s
- // at the proxy rather than producing a noisy GraphQL error
- // downstream.
- const viewer = readDid(vars.viewer)
- if (!viewer) return null
- const rawDegree = vars.degree
- if (typeof rawDegree !== "number" || !Number.isInteger(rawDegree)) return null
- if (rawDegree < 1 || rawDegree > 3) return null
- return { viewer, degree: rawDegree }
- }
- case "AllEndorsements": {
- // Paginated network-wide scan, one badge type per pass. Strict
- // allowlist on `badgeType` (defaults to "endorsement" for older
- // clients) — anything else 400s here rather than fanning an
- // arbitrary string out to the indexer. Same clamp shape as the
- // other 100-per-page reads (ReceivedEndorsements).
- const badgeType = vars.badgeType === undefined ? "endorsement" : vars.badgeType
- if (badgeType !== "endorsement" && badgeType !== "award") return null
- return {
- badgeType,
- first: clampFirst(vars.first, MAX_FIRST, 100),
- after: readString(vars.after, MAX_AFTER_LEN),
- }
- }
- case "EvaluatorEndorsements": {
- // Same cap as `authors` on FollowerEvents — defensive, in practice
- // the client passes single-digit lengths from a fixed evaluator list.
- const evaluators = readAuthorList(vars.evaluators)
- if (evaluators === null) return null
- if (evaluators.length === 0) return null
- return {
- evaluators,
- first: clampFirst(vars.first, 100, 50),
- after: readString(vars.after, MAX_AFTER_LEN),
- }
- }
- case "FollowerEvents": {
- const authors = readAuthorList(vars.authors)
- if (authors === null) return null
- const kinds = readKindList(vars.kinds)
- if (kinds === null) return null
- // Allowlisted enum — anything else gets stripped to null so the
- // indexer falls back to its default (SORT_AT). Strict on shape:
- // a malformed value is suspicious enough to drop, not coerce.
- const sortBy = readFollowerEventsSort(vars.sortBy)
- return {
- authors,
- first: clampFirst(vars.first, MAX_FEED_PAGE_SIZE, 20),
- after: readString(vars.after, MAX_AFTER_LEN),
- kinds: kinds ?? null,
- sortBy,
- }
- }
- case "HydrateFeedPage": {
- const activityUris = readUriList(vars.activityUris, MAX_URI_LIST_PER_KIND)
- const collectionUris = readUriList(vars.collectionUris, MAX_URI_LIST_PER_KIND)
- const badgeAwardUris = readUriList(vars.badgeAwardUris, MAX_URI_LIST_PER_KIND)
- const evaluationUris = readUriList(vars.evaluationUris, MAX_URI_LIST_PER_KIND)
- const measurementUris = readUriList(vars.measurementUris, MAX_URI_LIST_PER_KIND)
- const hyperboardUris = readUriList(vars.hyperboardUris, MAX_URI_LIST_PER_KIND)
- const attachmentUris = readUriList(vars.attachmentUris, MAX_URI_LIST_PER_KIND)
- if (
- activityUris === null ||
- collectionUris === null ||
- badgeAwardUris === null ||
- evaluationUris === null ||
- measurementUris === null ||
- hyperboardUris === null ||
- attachmentUris === null
- ) {
- return null
- }
- // Optional inclusion / exclusion filter for the hyperlabel-style
- // cert labels (`high-quality` / `standard` / `draft` /
- // `likely-test`). Permissive reader — null when omitted or
- // invalid; the GraphQL query treats null as "no filter" on each
- // side. The client picks ONE of the two modes:
- // - excludeLabels: include unlabeled records, drop the listed
- // tiers (the home-feed default).
- // - includeLabels: only records carrying one of the listed
- // tiers pass; unlabeled records do not. Used when the
- // "Not labeled yet" checkbox is unchecked.
- const activityExcludeLabels = readLabelList(vars.activityExcludeLabels)
- const activityIncludeLabels = readLabelList(vars.activityIncludeLabels)
- return {
- activityUris,
- collectionUris,
- badgeAwardUris,
- evaluationUris,
- measurementUris,
- hyperboardUris,
- attachmentUris,
- activityExcludeLabels,
- activityIncludeLabels,
- }
- }
- default:
- return null
- }
-}
/**
* POST /api/indexer
@@ -1746,10 +116,13 @@ export async function POST(request: NextRequest) {
}
const operationName = parsed.operationName
- const query = OPERATIONS[operationName]
- if (!query) {
+ // Own-key check: a plain `OPERATIONS[operationName]` lookup would let
+ // Object.prototype keys (`constructor`, `toString`, …) return truthy
+ // inherited members and slip past this gate.
+ if (!Object.hasOwn(OPERATIONS, operationName)) {
return NextResponse.json({ error: "Unknown operation" }, { status: 400 })
}
+ const query = OPERATIONS[operationName]
const clientVars =
parsed.variables && typeof parsed.variables === "object"
@@ -1763,12 +136,42 @@ export async function POST(request: NextRequest) {
)
}
+ const result = await forwardToIndexer(
+ query,
+ operationName,
+ variables,
+ request.signal,
+ )
+ if (result instanceof NextResponse) return result
+
+ return new NextResponse(result.responseBody, {
+ status: result.upstream.status,
+ headers: {
+ "Content-Type":
+ result.upstream.headers.get("content-type") || "application/json",
+ },
+ })
+}
+
+/**
+ * Forward one validated operation to the upstream GraphQL endpoint.
+ * Shared by POST (verbatim passthrough) and GET (edge-cacheable
+ * variant): same timeout, same rate-limit-bypass header, same
+ * error-to-status mapping. Returns the upstream Response + body text,
+ * or a ready-made error NextResponse on timeout / network failure.
+ */
+async function forwardToIndexer(
+ query: string,
+ operationName: string,
+ variables: Record,
+ requestSignal: AbortSignal,
+): Promise<{ upstream: Response; responseBody: string } | NextResponse> {
const timeoutController = new AbortController()
const timeoutId = setTimeout(
() => timeoutController.abort(),
UPSTREAM_TIMEOUT_MS,
)
- const signal = AbortSignal.any([request.signal, timeoutController.signal])
+ const signal = AbortSignal.any([requestSignal, timeoutController.signal])
// Bypass the indexer's per-IP `/graphql` rate limiter (magic-indexer
// R-7): the app's own proxied traffic should never be throttled.
@@ -1790,13 +193,7 @@ export async function POST(request: NextRequest) {
})
const responseBody = await upstream.text()
- return new NextResponse(responseBody, {
- status: upstream.status,
- headers: {
- "Content-Type":
- upstream.headers.get("content-type") || "application/json",
- },
- })
+ return { upstream, responseBody }
} catch (err: unknown) {
const error = err as { name?: string; message?: string }
if (error?.name === "AbortError") {
@@ -1815,3 +212,111 @@ export async function POST(request: NextRequest) {
clearTimeout(timeoutId)
}
}
+
+/**
+ * Shared-cache directives for the GET variant below. The five network
+ * counts change on the order of hours — a 5 min shared TTL plus a day
+ * of stale-while-revalidate keeps the /welcome stats strip warm
+ * without a function invocation per visitor. The paginated scans
+ * (AllEndorsements / OrganizationDids) get a shorter window so a
+ * fresh write shows up within a minute.
+ */
+const COUNT_CACHE_CONTROL = "public, s-maxage=300, stale-while-revalidate=86400"
+const SCAN_CACHE_CONTROL = "public, s-maxage=60, stale-while-revalidate=600"
+
+/**
+ * Operations servable via GET, mapped to their Cache-Control. Only
+ * public, viewer-independent, staleness-tolerant reads belong here —
+ * the edge cache key is the full query string, so every variable must
+ * ride in it. NEVER add: FundingReceipts / FundingReceiptsForActivity
+ * (money-adjacent attestation state must not be stale-shared),
+ * ReceivedEndorsements / EvaluatorEndorsements (accept/reject state
+ * drives UX immediately after a user action), FollowerEvents /
+ * HydrateFeedPage / EndorsementClosure (viewer-derived variables,
+ * cache-key explosion), or any op invoked with `search` / label
+ * variance.
+ */
+const CACHEABLE_OPS: Record = {
+ ProfileCount: COUNT_CACHE_CONTROL,
+ OrganizationCount: COUNT_CACHE_CONTROL,
+ ActivityCount: COUNT_CACHE_CONTROL,
+ ProjectCount: COUNT_CACHE_CONTROL,
+ AwardCount: COUNT_CACHE_CONTROL,
+ AllEndorsements: SCAN_CACHE_CONTROL,
+ OrganizationDids: SCAN_CACHE_CONTROL,
+}
+
+/**
+ * GET /api/indexer?op=[&first][&after][&badgeType]
+ *
+ * Edge-cacheable variant of POST for the CACHEABLE_OPS allowlist —
+ * POST responses are never edge-cached by Vercel, so the hot
+ * zero-variable counts (5 parallel RPCs per cold /welcome visit) and
+ * the /endorsement-graph scans invoked the function for every
+ * visitor. Response body is identical to the POST form; anything
+ * outside the allowlist 400s. Cache-Control is only set on a clean
+ * 200 (no GraphQL `errors`, parseable body) so a transient upstream
+ * failure is never pinned at the edge for the full TTL.
+ *
+ * No CSRF check: read-only, no credentials, allowlisted ops only.
+ * The IP limiter stays — edge hits never reach the function, so it
+ * only meters cache misses.
+ */
+export async function GET(request: NextRequest) {
+ const rateDenied = await enforceRateLimit(LIMITER, clientIp(request))
+ if (rateDenied) return rateDenied
+
+ const searchParams = request.nextUrl.searchParams
+ const operationName = searchParams.get("op") ?? ""
+ // Own-key check — same reason as POST: prototype keys must not pass
+ // the allowlist.
+ if (!Object.hasOwn(CACHEABLE_OPS, operationName)) {
+ return NextResponse.json({ error: "Unknown operation" }, { status: 400 })
+ }
+ const cacheControl = CACHEABLE_OPS[operationName]
+
+ // Re-materialise the POST variable shape from the query string; the
+ // per-op validators clamp exactly as they do for POST.
+ const clientVars: ClientVariables = {}
+ const first = searchParams.get("first")
+ if (first !== null) clientVars.first = Number(first)
+ const after = searchParams.get("after")
+ if (after !== null) clientVars.after = after
+ const badgeType = searchParams.get("badgeType")
+ if (badgeType !== null) clientVars.badgeType = badgeType
+
+ const variables = buildVariables(operationName, clientVars)
+ if (!variables) {
+ return NextResponse.json(
+ { error: "Invalid variables for operation" },
+ { status: 400 },
+ )
+ }
+
+ const result = await forwardToIndexer(
+ OPERATIONS[operationName],
+ operationName,
+ variables,
+ request.signal,
+ )
+ if (result instanceof NextResponse) return result
+ const { upstream, responseBody } = result
+
+ const headers: Record = {
+ "Content-Type": upstream.headers.get("content-type") || "application/json",
+ }
+ if (upstream.status === 200 && !bodyHasErrors(responseBody)) {
+ headers["Cache-Control"] = cacheControl
+ }
+ return new NextResponse(responseBody, { status: upstream.status, headers })
+}
+
+/** True when a 200 body carries GraphQL `errors` or isn't JSON at all. */
+function bodyHasErrors(responseBody: string): boolean {
+ try {
+ const parsed = JSON.parse(responseBody) as { errors?: unknown }
+ return parsed.errors !== undefined
+ } catch {
+ return true
+ }
+}
diff --git a/src/app/api/indexer/variables.ts b/src/app/api/indexer/variables.ts
new file mode 100644
index 00000000..aa1f07dd
--- /dev/null
+++ b/src/app/api/indexer/variables.ts
@@ -0,0 +1,508 @@
+/**
+ * Per-operation variable validation for the indexer proxy — the
+ * pure half of the trust boundary described in ./route.ts. Every
+ * reader here clamps / type-checks client-supplied input against the
+ * MAX_* caps below so a manipulated request can't push pathological
+ * inputs (10k-element arrays, multi-MB strings) downstream. No
+ * request state, no I/O — `buildVariables` is a pure function of
+ * (operationName, client variables).
+ */
+
+const MAX_FIRST = 100
+const MAX_FIRST_DEFINITIONS = 1000
+const MAX_FEED_PAGE_SIZE = 50
+const MAX_SEARCH_LEN = 200
+const MAX_AFTER_LEN = 1024
+const MAX_DID_LEN = 256
+const MAX_DID_LIST = 1000
+// Hard cap on `authors` for FollowerEvents, matching the indexer's
+// `MaxAuthorsFilterSize`. The client also pre-truncates to this value;
+// enforcing here is defence-in-depth so a manipulated request can't
+// push a 10k-entry array downstream.
+const MAX_AUTHORS_FILTER_SIZE = 500
+const MAX_LABEL_LIST = 50
+const MAX_LABEL_LEN = 64
+const MAX_KIND_LIST = 16
+const MAX_KIND_LEN = 64
+const MAX_URI_LEN = 512
+/** Per-kind URI cap for the `HydrateFeedPage` op (4 kinds × 50 = up to
+ * 200 URIs total per feed page). Matches the indexer's hard cap on
+ * the `where: { uri: { in: [...] } }` filter (50 entries; values
+ * above that error out with "in list must contain 1 to 50 values").
+ * The GraphQL query also embeds this as `first: ${MAX_URI_LIST_PER_KIND}`
+ * so changing it here changes the page size on the wire too. */
+export const MAX_URI_LIST_PER_KIND = 50
+
+export type ClientVariables = Record
+
+function clampFirst(value: unknown, max: number, fallback: number): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback
+ return Math.min(Math.max(1, Math.floor(value)), max)
+}
+
+function readString(value: unknown, maxLen: number): string | null {
+ if (typeof value !== "string") return null
+ if (value.length === 0 || value.length > maxLen) return null
+ return value
+}
+
+function readDid(value: unknown): string | null {
+ const s = readString(value, MAX_DID_LEN)
+ if (!s) return null
+ return s.startsWith("did:") ? s : null
+}
+
+function readDidList(value: unknown, maxItems: number): string[] | null {
+ if (!Array.isArray(value)) return null
+ if (value.length === 0 || value.length > maxItems) return null
+ // Fail-soft: filter out non-DID entries silently rather than
+ // rejecting the whole batch. A single malformed DID in the
+ // indexed data (e.g. a contributor field that wasn't normalised
+ // upstream) shouldn't take out an entire Received-endorsements
+ // panel for every viewer. Issue #73 / round-2 receivers' fix.
+ // Returns null only when nothing valid remains — at that point
+ // the caller's GraphQL `where: { did: { in: [] } }` would return
+ // empty anyway, so saving a round-trip.
+ const out: string[] = []
+ for (const item of value) {
+ const did = readDid(item)
+ if (did) out.push(did)
+ }
+ if (out.length === 0) return null
+ return out
+}
+
+function readOptionalDidList(value: unknown): string[] | null | undefined {
+ // tri-state: undefined (no filter), [] (match nothing), [...] (filter)
+ if (value === undefined || value === null) return undefined
+ if (!Array.isArray(value)) return undefined
+ if (value.length === 0) return []
+ if (value.length > MAX_DID_LIST) return undefined
+ const out: string[] = []
+ for (const item of value) {
+ const did = readDid(item)
+ if (!did) return undefined
+ out.push(did)
+ }
+ return out
+}
+
+function readLabelList(value: unknown): string[] | null {
+ if (value === undefined || value === null) return null
+ if (!Array.isArray(value)) return null
+ if (value.length === 0 || value.length > MAX_LABEL_LIST) return null
+ const out: string[] = []
+ for (const item of value) {
+ if (typeof item !== "string") return null
+ if (item.length === 0 || item.length > MAX_LABEL_LEN) return null
+ out.push(item)
+ }
+ return out
+}
+
+/**
+ * Reads the `authors` argument for `FollowerEvents`.
+ *
+ * - Required (cannot be omitted; the indexer's `AUTHORS_REQUIRED` is
+ * defensive, our proxy rejects first).
+ * - Length 0..MAX_AUTHORS_FILTER_SIZE inclusive. The empty array is
+ * load-bearing: the upstream returns an empty connection rather
+ * than an error, which the client uses for the
+ * no-follows-yet case.
+ * - Per-entry: non-DID strings are filtered out silently
+ * (fail-soft, matching `readDidList`). Returns null only on
+ * structural failure or oversize, not on bad-entry content —
+ * a single malformed DID in a viewer's follow list shouldn't
+ * take out their entire feed.
+ */
+function readAuthorList(value: unknown): string[] | null {
+ if (!Array.isArray(value)) return null
+ if (value.length > MAX_AUTHORS_FILTER_SIZE) return null
+ const out: string[] = []
+ for (const item of value) {
+ const did = readDid(item)
+ if (did) out.push(did)
+ }
+ return out
+}
+
+/**
+ * Reads the optional `kinds` inclusion filter on `FollowerEvents`.
+ * The cap numbers are defensive defaults (the spec doesn't mandate
+ * them), kept tight so a manipulated request can't push pathological
+ * inputs downstream. Returns null for structurally-invalid input
+ * (non-array / non-string entry / oversized), which 400s the request.
+ */
+function readKindList(value: unknown): string[] | null | undefined {
+ if (value === undefined || value === null) return undefined
+ if (!Array.isArray(value)) return null
+ if (value.length === 0) return undefined
+ if (value.length > MAX_KIND_LIST) return null
+ const out: string[] = []
+ for (const item of value) {
+ if (typeof item !== "string") return null
+ if (item.length === 0 || item.length > MAX_KIND_LEN) return null
+ out.push(item)
+ }
+ return out
+}
+
+/**
+ * Reads the optional `sortBy` enum for `FollowerEvents`. The indexer
+ * accepts `SORT_AT` (default) or `CREATED_AT` (matches the rendered
+ * "X ago" order — see magic-indexer#136). Anything else is dropped to
+ * null so a manipulated request can't push an unknown enum literal
+ * downstream; the indexer then falls back to its server default.
+ */
+function readFollowerEventsSort(value: unknown): "SORT_AT" | "CREATED_AT" | null {
+ if (value === "SORT_AT" || value === "CREATED_AT") return value
+ return null
+}
+
+/**
+ * Reads one of the `*Uris` array variables. Length 0..`maxItems`
+ * inclusive — empty arrays pass through because a typical
+ * `HydrateFeedPage` call only has events of a few kinds and the
+ * unused kinds should be `[]`. The `maxItems` arg lets the
+ * `ActivitiesByUris` path accept a larger set than the per-kind
+ * hydration arrays (one indexer page = 100 URIs, vs the feed
+ * hydration's 50-per-kind page-size cap).
+ */
+function readUriList(value: unknown, maxItems: number): string[] | null {
+ if (!Array.isArray(value)) return null
+ if (value.length > maxItems) return null
+ const out: string[] = []
+ for (const item of value) {
+ if (typeof item !== "string") return null
+ if (item.length === 0 || item.length > MAX_URI_LEN) return null
+ // Defensive prefix check — every consumer of this list passes
+ // the values as a GraphQL `$uris` variable (not body-interpolated),
+ // so the actual injection risk is zero. Rejecting non-at:// values
+ // here makes a manipulated request fail at the proxy with a 400
+ // instead of producing an empty result downstream.
+ if (!item.startsWith("at://")) return null
+ out.push(item)
+ }
+ return out
+}
+
+/**
+ * Normalize client-supplied variables per-operation. Returns null when
+ * required vars are missing or malformed — the route then 400s.
+ *
+ * Required vars are pulled with strict readers (`readDid` etc.) that
+ * return null on miss. Optional vars are pulled with permissive
+ * readers that fall back to `null` so the GraphQL query receives the
+ * "no filter" sentinel.
+ */
+export function buildVariables(
+ operationName: string,
+ vars: ClientVariables,
+): Record | null {
+ switch (operationName) {
+ case "Activities": {
+ const authors = readOptionalDidList(vars.authors)
+ return {
+ first: clampFirst(vars.first, MAX_FIRST, 20),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ labels: readLabelList(vars.labels),
+ excludeLabels: readLabelList(vars.excludeLabels),
+ authors: authors === undefined ? null : authors,
+ authorLabels: readLabelList(vars.authorLabels),
+ excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
+ search: readString(vars.search, MAX_SEARCH_LEN),
+ }
+ }
+ case "ActivitiesByUris": {
+ const uris = readUriList(vars.uris, MAX_URI_LIST_PER_KIND)
+ if (uris === null) return null
+ return {
+ uris,
+ labels: readLabelList(vars.labels),
+ excludeLabels: readLabelList(vars.excludeLabels),
+ authorLabels: readLabelList(vars.authorLabels),
+ excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
+ }
+ }
+ case "CollectionsByUris": {
+ // Batch getRecords-by-uri over collections. Same reader + cap as
+ // the per-kind hydration arrays; the empty list is rejected — a
+ // batch fetch of nothing is a wasted round-trip, callers skip the
+ // call instead (matches NetworkActorsByDids).
+ const uris = readUriList(vars.uris, MAX_URI_LIST_PER_KIND)
+ if (uris === null || uris.length === 0) return null
+ return { uris }
+ }
+ case "AuthoredActivities":
+ case "ContributedActivities": {
+ const did = readDid(vars.did)
+ if (!did) return null
+ return {
+ did,
+ first: clampFirst(vars.first, MAX_FIRST, 20),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ labels: readLabelList(vars.labels),
+ excludeLabels: readLabelList(vars.excludeLabels),
+ search: readString(vars.search, MAX_SEARCH_LEN),
+ }
+ }
+ case "Followers":
+ case "ReceivedEndorsements": {
+ const did = readDid(vars.did)
+ if (!did) return null
+ return {
+ did,
+ first: clampFirst(vars.first, MAX_FIRST, 100),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ }
+ }
+ case "UserActivityCount": {
+ const did = readDid(vars.did)
+ if (!did) return null
+ return { did }
+ }
+ case "EndorsementDefs": {
+ const dids = readDidList(vars.dids, MAX_DID_LIST)
+ if (!dids) return null
+ return {
+ dids,
+ first: clampFirst(vars.first, MAX_FIRST_DEFINITIONS, MAX_FIRST_DEFINITIONS),
+ }
+ }
+ case "ProfileCount":
+ case "OrganizationCount":
+ case "ActivityCount":
+ case "ProjectCount":
+ case "AwardCount": {
+ // Zero-argument operations — nothing to validate. Return an
+ // empty object so the route's null-check passes and the
+ // query is forwarded.
+ return {}
+ }
+ case "UserProjects": {
+ const did = readDid(vars.did)
+ if (!did) return null
+ return {
+ did,
+ first: clampFirst(vars.first, MAX_FIRST, 50),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ }
+ }
+ case "ProjectsContainingCert": {
+ const certUri = readString(vars.certUri, MAX_URI_LEN)
+ if (!certUri || !certUri.startsWith("at://")) return null
+ return {
+ certUri,
+ first: clampFirst(vars.first, MAX_FIRST, 50),
+ }
+ }
+ case "Projects": {
+ const authors = readOptionalDidList(vars.authors)
+ return {
+ first: clampFirst(vars.first, MAX_FIRST, 24),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ authors: authors === undefined ? null : authors,
+ authorLabels: readLabelList(vars.authorLabels),
+ excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
+ search: readString(vars.search, MAX_SEARCH_LEN),
+ }
+ }
+ case "NetworkActors": {
+ return {
+ first: clampFirst(vars.first, MAX_FIRST, 20),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ search: readString(vars.search, MAX_SEARCH_LEN),
+ authorLabels: readLabelList(vars.authorLabels),
+ excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
+ }
+ }
+ case "OrganizationDids": {
+ return {
+ first: clampFirst(vars.first, MAX_FIRST, 20),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ }
+ }
+ case "NetworkActorsByKind": {
+ // `isOrganization` is non-nullable on the upstream op (the
+ // indexer rejects `eq: null`); reject missing / non-boolean
+ // inputs so the route's contract matches the upstream's.
+ if (typeof vars.isOrganization !== "boolean") return null
+ return {
+ first: clampFirst(vars.first, MAX_FIRST, 20),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ isOrganization: vars.isOrganization,
+ search: readString(vars.search, MAX_SEARCH_LEN),
+ authorLabels: readLabelList(vars.authorLabels),
+ excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
+ }
+ }
+ case "OrganizationDidsByLabel": {
+ return {
+ first: clampFirst(vars.first, MAX_FIRST, 100),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ labels: readLabelList(vars.labels),
+ excludeLabels: readLabelList(vars.excludeLabels),
+ }
+ }
+ case "NetworkActorsByDids": {
+ // Reuse the author-list reader: same shape (DID list, ≤500),
+ // same defensive truncation. Empty list is rejected — the
+ // op is meaningless without a target set.
+ const dids = readAuthorList(vars.dids)
+ if (dids === null || dids.length === 0) return null
+ return { dids }
+ }
+ case "OrganizationDidsForSet": {
+ // Same shape as `NetworkActorsByDids`: a DID-set narrowing
+ // op. Returns just the org-DID subset; consumers chunk to
+ // stay under the upstream `first: 100` cap.
+ const dids = readAuthorList(vars.dids)
+ if (dids === null || dids.length === 0) return null
+ return { dids }
+ }
+ case "DidsByKindInSet": {
+ // DID-set narrowing + kind filter. Same validation as the
+ // OrganizationDidsForSet op above, plus a required boolean
+ // for the kind (graphql-go rejects `eq: null`, so we don't
+ // accept undefined here — callers pick "people" or
+ // "organizations" explicitly).
+ const dids = readAuthorList(vars.dids)
+ if (dids === null || dids.length === 0) return null
+ if (typeof vars.isOrganization !== "boolean") return null
+ return { dids, isOrganization: vars.isOrganization }
+ }
+ case "ActorWorkspaceCounts": {
+ const did = readDid(vars.did)
+ if (!did) return null
+ return { did }
+ }
+ case "FundingReceipts": {
+ // Paginated read. Clamp `first` like the other paginated ops;
+ // `after` is the opaque cursor. The optional author-label filters
+ // gate receipts by the creator's account (orglabeler) tier so the
+ // Funding tab can hide receipts authored by likely-test accounts
+ // (magic-indexer#207); `confirmedBy` is an optional third-party-
+ // attestor DID filter (magic-indexer #214), forwarded only when it's
+ // a valid DID, otherwise null ("no filter").
+ return {
+ first: clampFirst(vars.first, MAX_FIRST, 50),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ authorLabels: readLabelList(vars.authorLabels),
+ excludeAuthorLabels: readLabelList(vars.excludeAuthorLabels),
+ confirmedBy: readDid(vars.confirmedBy),
+ }
+ }
+ case "FundingReceiptsForActivity": {
+ // Required `forUri` — a single at:// activity URI to filter by.
+ // Mirrors the URI validation the ProjectsContainingCert op uses.
+ const forUri = readString(vars.forUri, MAX_URI_LEN)
+ if (!forUri || !forUri.startsWith("at://")) return null
+ return {
+ forUri,
+ first: clampFirst(vars.first, MAX_FIRST, 50),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ }
+ }
+ case "EndorsementClosure": {
+ // Viewer-centric BFS closure (magic-indexer #117). `viewer`
+ // must be a DID; `degree` must be ∈ {1, 2, 3}. Validation here
+ // mirrors the indexer-side gate so a malformed request 400s
+ // at the proxy rather than producing a noisy GraphQL error
+ // downstream.
+ const viewer = readDid(vars.viewer)
+ if (!viewer) return null
+ const rawDegree = vars.degree
+ if (typeof rawDegree !== "number" || !Number.isInteger(rawDegree)) return null
+ if (rawDegree < 1 || rawDegree > 3) return null
+ return { viewer, degree: rawDegree }
+ }
+ case "AllEndorsements": {
+ // Paginated network-wide scan, one badge type per pass. Strict
+ // allowlist on `badgeType` (defaults to "endorsement" for older
+ // clients) — anything else 400s here rather than fanning an
+ // arbitrary string out to the indexer. Same clamp shape as the
+ // other 100-per-page reads (ReceivedEndorsements).
+ const badgeType = vars.badgeType === undefined ? "endorsement" : vars.badgeType
+ if (badgeType !== "endorsement" && badgeType !== "award") return null
+ return {
+ badgeType,
+ first: clampFirst(vars.first, MAX_FIRST, 100),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ }
+ }
+ case "EvaluatorEndorsements": {
+ // Same cap as `authors` on FollowerEvents — defensive, in practice
+ // the client passes single-digit lengths from a fixed evaluator list.
+ const evaluators = readAuthorList(vars.evaluators)
+ if (evaluators === null) return null
+ if (evaluators.length === 0) return null
+ return {
+ evaluators,
+ first: clampFirst(vars.first, 100, 50),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ }
+ }
+ case "FollowerEvents": {
+ const authors = readAuthorList(vars.authors)
+ if (authors === null) return null
+ const kinds = readKindList(vars.kinds)
+ if (kinds === null) return null
+ // Allowlisted enum — anything else gets stripped to null so the
+ // indexer falls back to its default (SORT_AT). Strict on shape:
+ // a malformed value is suspicious enough to drop, not coerce.
+ const sortBy = readFollowerEventsSort(vars.sortBy)
+ return {
+ authors,
+ first: clampFirst(vars.first, MAX_FEED_PAGE_SIZE, 20),
+ after: readString(vars.after, MAX_AFTER_LEN),
+ kinds: kinds ?? null,
+ sortBy,
+ }
+ }
+ case "HydrateFeedPage": {
+ const activityUris = readUriList(vars.activityUris, MAX_URI_LIST_PER_KIND)
+ const collectionUris = readUriList(vars.collectionUris, MAX_URI_LIST_PER_KIND)
+ const badgeAwardUris = readUriList(vars.badgeAwardUris, MAX_URI_LIST_PER_KIND)
+ const evaluationUris = readUriList(vars.evaluationUris, MAX_URI_LIST_PER_KIND)
+ const measurementUris = readUriList(vars.measurementUris, MAX_URI_LIST_PER_KIND)
+ const hyperboardUris = readUriList(vars.hyperboardUris, MAX_URI_LIST_PER_KIND)
+ const attachmentUris = readUriList(vars.attachmentUris, MAX_URI_LIST_PER_KIND)
+ if (
+ activityUris === null ||
+ collectionUris === null ||
+ badgeAwardUris === null ||
+ evaluationUris === null ||
+ measurementUris === null ||
+ hyperboardUris === null ||
+ attachmentUris === null
+ ) {
+ return null
+ }
+ // Optional inclusion / exclusion filter for the hyperlabel-style
+ // cert labels (`high-quality` / `standard` / `draft` /
+ // `likely-test`). Permissive reader — null when omitted or
+ // invalid; the GraphQL query treats null as "no filter" on each
+ // side. The client picks ONE of the two modes:
+ // - excludeLabels: include unlabeled records, drop the listed
+ // tiers (the home-feed default).
+ // - includeLabels: only records carrying one of the listed
+ // tiers pass; unlabeled records do not. Used when the
+ // "Not labeled yet" checkbox is unchecked.
+ const activityExcludeLabels = readLabelList(vars.activityExcludeLabels)
+ const activityIncludeLabels = readLabelList(vars.activityIncludeLabels)
+ return {
+ activityUris,
+ collectionUris,
+ badgeAwardUris,
+ evaluationUris,
+ measurementUris,
+ hyperboardUris,
+ attachmentUris,
+ activityExcludeLabels,
+ activityIncludeLabels,
+ }
+ }
+ default:
+ return null
+ }
+}
diff --git a/src/app/api/xrpc/[...method]/__tests__/get-blob-foreign.test.ts b/src/app/api/xrpc/[...method]/__tests__/get-blob-foreign.test.ts
index 5207c928..ab3f03ac 100644
--- a/src/app/api/xrpc/[...method]/__tests__/get-blob-foreign.test.ts
+++ b/src/app/api/xrpc/[...method]/__tests__/get-blob-foreign.test.ts
@@ -115,7 +115,7 @@ describe("foreign-DID getBlob — Content-Length cap + fixed Cache-Control", ()
expect(res.status).toBe(200)
expect(res.headers.get("cache-control")).toBe(
- "public, max-age=3600, immutable",
+ "public, max-age=3600, s-maxage=86400, immutable",
)
expect(res.headers.get("cache-control")).not.toContain("no-store")
fetchSpy.mockRestore()
diff --git a/src/app/api/xrpc/[...method]/__tests__/lazy-restore.test.ts b/src/app/api/xrpc/[...method]/__tests__/lazy-restore.test.ts
new file mode 100644
index 00000000..0c408184
--- /dev/null
+++ b/src/app/api/xrpc/[...method]/__tests__/lazy-restore.test.ts
@@ -0,0 +1,184 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+
+/**
+ * Tests for the lazy OAuth-session restore in the XRPC proxy GET
+ * handler.
+ *
+ * The old handler restored the full OAuth session (Upstash read +
+ * DPoP deserialization, possibly a token refresh) for EVERY signed-in
+ * GET — including foreign-repo/blob reads that never touch the bound
+ * agent. The restore is now deferred into a `getAgent` helper invoked
+ * only by the same-session branches and the auth-required methods.
+ *
+ * Pinned here:
+ * - Foreign-repo listRecords / foreign-DID getBlob with a session
+ * cookie never call `getOAuthClient` at all.
+ * - getSession with a cookie whose restore fails still 401s AND
+ * still drops the stale session cookie (deleteSession).
+ * - A same-repo read whose restore fails falls back to the public
+ * PDS read instead of 500/401 — degraded sessions must not break
+ * reading a user's own public records.
+ */
+
+const getSessionDid = vi.fn()
+const getOAuthClient = vi.fn()
+const deleteSession = vi.fn()
+const resolvePdsUrl = vi.fn()
+
+vi.mock("@/lib/auth/oauth-client", () => ({ getOAuthClient }))
+vi.mock("@/lib/auth/session", () => ({
+ getSessionDid,
+ deleteSession,
+}))
+vi.mock("@/lib/auth/csrf", () => ({ checkCsrf: vi.fn() }))
+vi.mock("@/lib/atproto/did", () => ({
+ resolvePdsUrl,
+ invalidateDidDoc: vi.fn(),
+}))
+vi.mock("@/lib/auth/rate-limit", () => ({
+ checkAndIncrementWriteRate: vi.fn(),
+ RATE_LIMITED_WRITE_COLLECTIONS: {},
+ makeLimiter: (name: string, max: number, windowSec: number) => ({
+ name,
+ max,
+ windowSec,
+ }),
+ enforceRateLimit: vi.fn(async () => null),
+}))
+vi.mock("@/lib/utils/ip", () => ({ clientIp: () => "test-ip" }))
+vi.mock("@atproto/api", () => ({ Agent: class {} }))
+
+const OWN_DID = "did:plc:s4puetfspot742ai7y4otuel"
+const FOREIGN_DID = "did:plc:foreigner000000000000000"
+
+function makeRequest(query: string) {
+ const url = new URL(`https://app.example/api/xrpc/${query}`)
+ return { nextUrl: url } as unknown as Parameters<
+ Awaited["GET"]
+ >[0]
+}
+
+function makeParams(methodName: string) {
+ return { params: Promise.resolve({ method: methodName.split(".") }) }
+}
+
+beforeEach(() => {
+ getSessionDid.mockReset().mockResolvedValue(OWN_DID)
+ getOAuthClient.mockReset()
+ deleteSession.mockReset()
+ resolvePdsUrl.mockReset().mockResolvedValue("https://pds.example")
+ vi.spyOn(console, "error").mockImplementation(() => undefined)
+ vi.spyOn(console, "warn").mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe("lazy restore — foreign reads skip the OAuth session entirely", () => {
+ it("foreign-repo listRecords with a session cookie never calls getOAuthClient", async () => {
+ const { GET } = await import("../route")
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ records: [] }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ )
+
+ const res = await GET(
+ makeRequest(
+ `com.atproto.repo.listRecords?repo=${FOREIGN_DID}&collection=org.hypercerts.collection`,
+ ),
+ makeParams("com.atproto.repo.listRecords"),
+ )
+
+ expect(res.status).toBe(200)
+ expect(getOAuthClient).not.toHaveBeenCalled()
+ // The read federated straight to the foreign repo's home PDS.
+ expect(resolvePdsUrl).toHaveBeenCalledWith(FOREIGN_DID)
+ fetchSpy.mockRestore()
+ })
+
+ it("foreign-DID getBlob with a session cookie never calls getOAuthClient", async () => {
+ const { GET } = await import("../route")
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("blob-bytes", {
+ status: 200,
+ headers: { "content-type": "image/png", "content-length": "10" },
+ }),
+ )
+
+ const res = await GET(
+ makeRequest(`com.atproto.sync.getBlob?did=${FOREIGN_DID}&cid=bafkreigh2akiscaildc`),
+ makeParams("com.atproto.sync.getBlob"),
+ )
+
+ expect(res.status).toBe(200)
+ expect(getOAuthClient).not.toHaveBeenCalled()
+ fetchSpy.mockRestore()
+ })
+})
+
+describe("lazy restore — auth-required methods still fail closed", () => {
+ it("getSession with a failed restore returns 401 and deletes the session", async () => {
+ const { GET } = await import("../route")
+ getOAuthClient.mockResolvedValue({
+ restore: vi.fn().mockRejectedValue(new Error("token refresh failed")),
+ })
+
+ const res = await GET(
+ makeRequest("com.atproto.server.getSession"),
+ makeParams("com.atproto.server.getSession"),
+ )
+
+ expect(res.status).toBe(401)
+ expect(deleteSession).toHaveBeenCalledTimes(1)
+ })
+
+ it("getSession without a session cookie returns 401 without touching OAuth", async () => {
+ const { GET } = await import("../route")
+ getSessionDid.mockResolvedValue(null)
+
+ const res = await GET(
+ makeRequest("com.atproto.server.getSession"),
+ makeParams("com.atproto.server.getSession"),
+ )
+
+ expect(res.status).toBe(401)
+ expect(getOAuthClient).not.toHaveBeenCalled()
+ expect(deleteSession).not.toHaveBeenCalled()
+ })
+})
+
+describe("lazy restore — same-repo reads degrade to the public proxy", () => {
+ it("same-repo listRecords falls back to the public read when restore fails", async () => {
+ const { GET } = await import("../route")
+ getOAuthClient.mockResolvedValue({
+ restore: vi.fn().mockRejectedValue(new Error("dpop hiccup")),
+ })
+
+ const records = [{ uri: "at://x/y/z", cid: "bafy", value: {} }]
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ records }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ )
+
+ const res = await GET(
+ makeRequest(
+ `com.atproto.repo.listRecords?repo=${OWN_DID}&collection=org.hypercerts.collection`,
+ ),
+ makeParams("com.atproto.repo.listRecords"),
+ )
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ records })
+ // Restore was attempted (same-repo branch wants the bound agent),
+ // failed, dropped the stale cookie, and degraded to the public read.
+ expect(getOAuthClient).toHaveBeenCalledTimes(1)
+ expect(deleteSession).toHaveBeenCalledTimes(1)
+ expect(resolvePdsUrl).toHaveBeenCalledWith(OWN_DID)
+ fetchSpy.mockRestore()
+ })
+})
diff --git a/src/app/api/xrpc/[...method]/route.ts b/src/app/api/xrpc/[...method]/route.ts
index 0ec09f96..a0ff772f 100644
--- a/src/app/api/xrpc/[...method]/route.ts
+++ b/src/app/api/xrpc/[...method]/route.ts
@@ -103,9 +103,24 @@ const GET_LIMITER = makeLimiter("xrpc-get-ip", 300, 60)
* addressed and immutable, so we own the directive rather than
* forwarding the upstream PDS's (which an attacker-chosen, hostile
* PDS controls). Mirrors the FOREIGN_READ_CACHE_HEADERS pattern.
+ * `s-maxage` lets the Vercel edge serve repeat visitors without a
+ * function invocation (max-age alone is browser-only there); 24h
+ * bounds takedown latency for deleted blobs, so no longer-lived
+ * CDN directive.
*/
const FOREIGN_BLOB_CACHE_HEADERS = {
- "Cache-Control": "public, max-age=3600, immutable",
+ "Cache-Control": "public, max-age=3600, s-maxage=86400, immutable",
+} as const
+
+/**
+ * Same-session blob reads went through the bound OAuth agent, so keep
+ * them out of shared caches — but the cid in the URL still makes the
+ * bytes immutable, so let the browser keep the user's own avatar /
+ * banner instead of re-streaming it through a full agent restore on
+ * every mount.
+ */
+const SAME_SESSION_BLOB_CACHE_HEADERS = {
+ "Cache-Control": "private, max-age=3600, immutable",
} as const
function asString(v: unknown): string | undefined {
@@ -388,37 +403,42 @@ export async function GET(
// resolves a client-supplied repo/did to a PDS it then fetches, so
// block floods (SSRF / amplification fan-out) before any upstream
// work. IP-scoped; fail-open on a limiter backend error (handled
- // inside enforceRateLimit).
- const rateDenied = await enforceRateLimit(GET_LIMITER, clientIp(request))
+ // inside enforceRateLimit). Run the session lookup concurrently —
+ // both are independent Upstash round-trips, and the limiter INCRs
+ // regardless, so discarding the session result on the (rare)
+ // denied path changes no semantics.
+ const [rateDenied, did] = await Promise.all([
+ enforceRateLimit(GET_LIMITER, clientIp(request)),
+ getSessionDid(),
+ ])
if (rateDenied) return rateDenied
const { method } = await params
const methodName = method.join(".")
- const did = await getSessionDid()
+ if (!did && !PUBLIC_READ_METHODS.has(methodName)) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ }
- // Try to build a bound agent if we have a session — it's nice to
- // have for methods that target the user's own repo (avoids a
- // roundtrip through resolvePdsUrl). Not having one is fine for the
- // public read methods below, which will always federate via plain
- // fetch against the target PDS.
- let agent: Agent | null = null
- if (did) {
+ // Lazily build the bound agent — restoring the OAuth session costs
+ // at least one Upstash round-trip plus DPoP deserialization and can
+ // trigger a token refresh, and the foreign-repo/blob branches below
+ // never touch the agent (feeds fan out dozens of those per page).
+ // Only the same-session branches and the auth-required methods pay
+ // for it. Restore failure keeps its semantics: log, drop the stale
+ // session cookie, and return null — same-session reads then fall
+ // back to the public proxy, auth-required methods 401.
+ const getAgent = async (): Promise => {
+ if (!did) return null
try {
const client = await getOAuthClient()
const oauthSession = await client.restore(did)
- agent = new Agent(oauthSession)
+ return new Agent(oauthSession)
} catch (err) {
logSafe("[xrpc] oauth restore failed", err, { method: methodName })
await deleteSession()
- // If it's a public read method, we can still proceed unauth;
- // otherwise fall through to the 401 below.
- if (!PUBLIC_READ_METHODS.has(methodName)) {
- return NextResponse.json({ error: "Session expired" }, { status: 401 })
- }
+ return null
}
- } else if (!PUBLIC_READ_METHODS.has(methodName)) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
}
// Query params come as Record from URLSearchParams.
@@ -444,6 +464,7 @@ export async function GET(
// no-store). If it throws (expired/unrefreshable OAuth session or
// a transient PDS-auth error) fall back to the public read so a
// degraded session doesn't 500 a read of the user's own record.
+ const agent = await getAgent()
if (agent) {
try {
const result = await agent.com.atproto.repo.getRecord({ repo, collection, rkey, cid })
@@ -477,6 +498,7 @@ export async function GET(
// OAuth session or a transient PDS-auth error) fall back to the
// public read so a degraded session doesn't 500 the user's own
// Lists/Activities tabs — listRecords is a public XRPC.
+ const agent = await getAgent()
if (agent) {
try {
const result = await agent.com.atproto.repo.listRecords({
@@ -496,15 +518,19 @@ export async function GET(
return proxyPublicListRecords(methodName, repo, collection, queryParams)
}
case "com.atproto.server.getSession": {
+ // Auth-required: a cookie whose session no longer restores must
+ // still 401 (getAgent already dropped the stale cookie).
+ const agent = await getAgent()
if (!agent) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "Session expired" }, { status: 401 })
}
const result = await agent.com.atproto.server.getSession()
return NextResponse.json(result.data, { headers: SAME_SESSION_NO_STORE_HEADERS })
}
case "com.atproto.server.listAppPasswords": {
+ const agent = await getAgent()
if (!agent) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "Session expired" }, { status: 401 })
}
const result = await agent.com.atproto.server.listAppPasswords()
return NextResponse.json(result.data, { headers: SAME_SESSION_NO_STORE_HEADERS })
@@ -524,6 +550,7 @@ export async function GET(
// Same-session blob → prefer the bound agent, fall back to the
// public read if a degraded session makes it throw.
+ const agent = await getAgent()
if (agent) {
try {
const result = await agent.com.atproto.sync.getBlob({ did: blobDid, cid })
@@ -532,6 +559,7 @@ export async function GET(
headers: {
"Content-Type":
result.headers["content-type"] || "application/octet-stream",
+ ...SAME_SESSION_BLOB_CACHE_HEADERS,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
},
diff --git a/src/app/apps/page.tsx b/src/app/apps/page.tsx
index eb8196cd..e9526e09 100644
--- a/src/app/apps/page.tsx
+++ b/src/app/apps/page.tsx
@@ -1,17 +1,16 @@
-"use client"
-
-import React from "react"
import Image from "next/image"
-import { usePageTitle } from "@/lib/navbar-context"
-import { useSession } from "@/hooks/use-session"
+import PageTitle from "@/components/layout/page-title"
import { CONNECTED_APPS } from "@/lib/constants/apps"
+import SsoAppLink from "./sso-app-link"
+// Server component: /apps is a public, sitemap-listed directory of a
+// fixed partner list, so the grid ships as RSC payload. The only
+// client pieces are the navbar-title island and the per-tile
+// href upgrade (session-dependent).
export default function AppsPage() {
- usePageTitle("Apps")
- const { handle } = useSession()
-
return (
+
{/* The navbar already carries the "Apps" page title — no eyebrow /
h1 repeat here, just the one-line intro. */}
@@ -21,26 +20,13 @@ export default function AppsPage() {
- {CONNECTED_APPS.map((app) => {
- // Silent SSO: if the partner exposes an ePDS handle-login
- // endpoint AND the viewer is signed in, deep-link them via
- // the shared Certified PDS session so they land already
- // signed in. Otherwise fall through to the marketing URL.
- const ssoTemplate =
- "ssoHandleUrl" in app ? app.ssoHandleUrl : undefined
- const href =
- handle && ssoTemplate
- ? ssoTemplate + encodeURIComponent(handle)
- : app.url
- return (
-
-
+ {CONNECTED_APPS.map((app) => (
+
+
{app.longDesc}
-
-
- )
- })}
+
+
+ ))}
)
diff --git a/src/app/apps/sso-app-link.tsx b/src/app/apps/sso-app-link.tsx
new file mode 100644
index 00000000..ef9b7c0b
--- /dev/null
+++ b/src/app/apps/sso-app-link.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import { useSession } from "@/hooks/use-session"
+
+/**
+ * Client island wrapping one app tile's outbound link. Silent SSO:
+ * if the partner exposes an ePDS handle-login endpoint AND the
+ * viewer is signed in, deep-link them via the shared Certified PDS
+ * session so they land already signed in. Otherwise (and on the
+ * server-rendered first paint, before the session resolves) fall
+ * through to the marketing URL. Keeping only this wrapper client-side
+ * lets the /apps grid itself render as a server component.
+ */
+export default function SsoAppLink({
+ url,
+ ssoHandleUrl,
+ ariaLabel,
+ children,
+}: {
+ url: string
+ ssoHandleUrl?: string
+ ariaLabel: string
+ children: React.ReactNode
+}) {
+ const { handle } = useSession()
+ const href =
+ handle && ssoHandleUrl ? ssoHandleUrl + encodeURIComponent(handle) : url
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx
index fbe17d5e..c3627fc1 100644
--- a/src/app/create/page.tsx
+++ b/src/app/create/page.tsx
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
-import { recordUrl } from "@/lib/urls"
+import { parseAtUri, recordUrl, rkeyFromUri } from "@/lib/urls"
import { useRouter } from "next/navigation"
import {
Calendar,
@@ -80,8 +80,6 @@ import { countGraphemes } from "@/lib/utils/graphemes"
* form itself stays plain-text.
*/
-const AT_URI_RE = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/
-
/**
* Single curated publisher of org.hypercerts.claim.rights records.
* The rights dropdown lists every record under this DID's collection
@@ -244,7 +242,7 @@ export default function CreatePage() {
typeof rec.value?.rightsName === "string"
? rec.value.rightsName.trim()
: ""
- const fallback = rec.uri.split("/").pop() ?? "(unnamed rights)"
+ const fallback = rkeyFromUri(rec.uri) || "(unnamed rights)"
return {
ref: { uri: rec.uri, cid: rec.cid },
name: rawName || fallback,
@@ -550,11 +548,10 @@ export default function CreatePage() {
}
const uri: unknown = data?.uri
- const match = typeof uri === "string" ? AT_URI_RE.exec(uri) : null
- if (match) {
- const [, ownerDid, , rkey] = match
+ const parsed = typeof uri === "string" ? parseAtUri(uri) : null
+ if (parsed) {
router.push(
- recordUrl(ownerDid, "activity", rkey),
+ recordUrl(parsed.did, "activity", parsed.rkey),
)
} else {
router.push("/")
diff --git a/src/app/endorsement-graph/page.tsx b/src/app/endorsement-graph/page.tsx
index 741f2a14..6942dfd2 100644
--- a/src/app/endorsement-graph/page.tsx
+++ b/src/app/endorsement-graph/page.tsx
@@ -2,7 +2,7 @@ import type { Metadata } from "next"
import Visualization from "@/components/visualization/visualization"
export const metadata: Metadata = {
- title: "Endorsement network — Certified",
+ title: "Endorsement network",
description:
"An interactive graph of the connections created through endorsements across the Certified network.",
}
diff --git a/src/app/endorsements/page.tsx b/src/app/endorsements/page.tsx
index 4b454af5..29401818 100644
--- a/src/app/endorsements/page.tsx
+++ b/src/app/endorsements/page.tsx
@@ -1,7 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
-import { profileUrl } from "@/lib/urls"
import Link from "next/link"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Plus, Award } from "lucide-react"
@@ -24,7 +23,7 @@ import ErrorMessage from "@/components/ui/error-message"
import Skeleton from "@/components/ui/skeleton"
import SignedOutPrompt from "@/components/layout/signed-out-prompt"
import { formatShortDate } from "@/lib/utils/format-date"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
type TabKey = "received" | "given"
@@ -111,10 +110,12 @@ function ReceivedRow({
onAfterWrite: () => void | Promise
}) {
const { info, isLoading } = useAuthorInfo(endorsement.issuerDid)
- const displayName = info?.displayName || info?.handle || endorsement.issuerDid
- const handle = info?.handle && info.handle !== info.did ? info.handle : null
- const initials = getInitials(info?.displayName, endorsement.issuerDid)
- const href = profileUrl(info?.handle || endorsement.issuerDid)
+ const {
+ displayName,
+ handle,
+ initials,
+ profileHref: href,
+ } = deriveIdentity(info, endorsement.issuerDid)
return (
diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx
index 42e11470..647b3a60 100644
--- a/src/app/explore/page.tsx
+++ b/src/app/explore/page.tsx
@@ -3,7 +3,7 @@ import { Suspense } from "react"
import Explore from "@/components/explore-page/explore"
export const metadata: Metadata = {
- title: "Explore — Certified",
+ title: "Explore",
description:
"Browse users, projects, and activities across the Certified network.",
openGraph: {
diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx
index 0fcf9117..f805c3b9 100644
--- a/src/app/home/page.tsx
+++ b/src/app/home/page.tsx
@@ -1,5 +1,10 @@
+import type { Metadata } from "next"
import Home from "@/components/home/home"
+export const metadata: Metadata = {
+ title: "Home",
+}
+
export default function HomePage() {
return
}
diff --git a/src/app/project/new/layout.tsx b/src/app/project/new/layout.tsx
new file mode 100644
index 00000000..2e1c5ae1
--- /dev/null
+++ b/src/app/project/new/layout.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next"
+
+export const metadata: Metadata = {
+ title: "New project",
+ description: "Create a project on Certified.",
+}
+
+export default function NewProjectLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/src/app/project/new/page.tsx b/src/app/project/new/page.tsx
index 1b2aae52..8913fa0c 100644
--- a/src/app/project/new/page.tsx
+++ b/src/app/project/new/page.tsx
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useState } from "react"
-import { recordUrl } from "@/lib/urls"
+import { parseAtUri, recordUrl } from "@/lib/urls"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { MapPin, Plus, X, FolderGit2 } from "lucide-react"
@@ -55,8 +55,6 @@ import LocationPickerDialog, {
* - avatar (deferred; banner covers the hero slot)
*/
-const AT_URI_RE = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/
-
interface SelectedCert {
uri: string
cid: string
@@ -280,11 +278,10 @@ export default function CreateProjectPage() {
}
const uri: unknown = data?.uri
- const match = typeof uri === "string" ? AT_URI_RE.exec(uri) : null
- if (match) {
- const [, ownerDid, , rkey] = match
+ const parsed = typeof uri === "string" ? parseAtUri(uri) : null
+ if (parsed) {
router.push(
- recordUrl(ownerDid, "project", rkey),
+ recordUrl(parsed.did, "project", parsed.rkey),
)
} else {
router.push("/")
diff --git a/src/app/settings/edit-profile/page.tsx b/src/app/settings/edit-profile/page.tsx
index 60c5ba27..9cce559c 100644
--- a/src/app/settings/edit-profile/page.tsx
+++ b/src/app/settings/edit-profile/page.tsx
@@ -82,7 +82,20 @@ export default function EditProfilePage() {
const [orgMarker, setOrgMarker] = useState(null);
const [isOrg, setIsOrg] = useState(false);
- const [orgLoaded, setOrgLoaded] = useState(false);
+ // No DID means no marker fetch — the org state is already settled.
+ const [orgLoaded, setOrgLoaded] = useState(!did);
+
+ // Adjust state during render when the signed-in DID goes away, so the
+ // effect holds only the fetchOwnOrgMarker lifecycle.
+ const [prevDid, setPrevDid] = useState(did);
+ if (prevDid !== did) {
+ setPrevDid(did);
+ if (!did) {
+ setOrgMarker(null);
+ setIsOrg(false);
+ setOrgLoaded(true);
+ }
+ }
// Drive the navbar breadcrumb. When we know the handle, render
// `@handle / Edit profile`; otherwise fall through to a plain title.
@@ -102,12 +115,7 @@ export default function EditProfilePage() {
);
useEffect(() => {
- if (!did) {
- setOrgMarker(null);
- setIsOrg(false);
- setOrgLoaded(true);
- return;
- }
+ if (!did) return;
const controller = new AbortController();
fetchOwnOrgMarker(did, controller.signal)
.then((record) => {
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 6c5c527b..e566e701 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -14,12 +14,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "weekly",
priority: 0.8,
},
- {
- url: "https://certified.app/about",
- lastModified: new Date("2026-04-07"),
- changeFrequency: "monthly",
- priority: 0.8,
- },
{
url: "https://certified.app/help",
lastModified: new Date("2026-06-08"),
diff --git a/src/app/styles/cert-detail.css b/src/app/styles/cert-detail.css
index e64fffc8..0ca5bc6e 100644
--- a/src/app/styles/cert-detail.css
+++ b/src/app/styles/cert-detail.css
@@ -776,10 +776,6 @@
}
}
-/* "more" link below the short description — styling now lives in
- `.more-link-row` / `.more-link` in `components.css` so the
- project detail and cert detail share the same affordance shape. */
-
/* ---------- Headline byline ----------
Sits directly under the title:
@@ -2016,7 +2012,7 @@
.create-cert__contrib-id-input--invalid:focus {
border-color: var(--color-error);
- box-shadow: 0 0 0 2px rgba(185, 28, 28, 0.15);
+ box-shadow: 0 0 0 2px var(--color-error-ring);
}
/* Per-row identity error — slots into the contributor grid by
@@ -2051,7 +2047,7 @@
top: 100%;
left: 0;
right: 0;
- z-index: 60;
+ z-index: var(--z-popover);
max-height: 220px;
overflow-y: auto;
background: var(--bg-elevated);
@@ -2501,7 +2497,10 @@
position: absolute;
top: 8px;
right: 8px;
- z-index: 500;
+ /* The sibling .certs-map is an isolated stacking context, so any
+ positive z paints this button above the whole map — no need to
+ outbid Leaflet's internal 200-700 pane values. */
+ z-index: var(--z-local-raise);
width: 30px;
height: 30px;
display: inline-flex;
diff --git a/src/app/styles/components.css b/src/app/styles/components.css
index 30d9726f..ec343aad 100644
--- a/src/app/styles/components.css
+++ b/src/app/styles/components.css
@@ -16,39 +16,6 @@
align-items: center;
}
-/* ========== "More" link ==========
-
- Shared affordance used wherever a truncated / summarised piece
- of content links to its full view (project / cert short
- description → long description tab, etc.). Right-aligned to the
- content edge so it sits at the predictable "continue reading"
- spot. Quiet underlined text — never a button. */
-.more-link-row {
- /* Sits flush against the preceding paragraph (no top margin) so
- "more" is the immediate next line under the truncated content.
- Bottom margin creates the breathing room before whatever
- section follows. */
- margin: 0 0 16px;
- font-size: 0.875rem;
- text-align: right;
-}
-
-.more-link {
- color: var(--fg-primary);
- text-decoration: underline;
- text-underline-offset: 2px;
-}
-
-.more-link:hover {
- text-decoration-thickness: 2px;
-}
-
-.more-link:focus-visible {
- outline: 2px solid var(--focus-ring);
- outline-offset: 2px;
- border-radius: var(--radius);
-}
-
/* ========== Micro-interactions ========== */
/* Press feedback — applied to interactive cards and buttons via utility class */
@@ -76,7 +43,7 @@
}
.delete-record-dialog__warning strong {
- color: var(--color-error, #b91c1c);
+ color: var(--color-error);
}
.delete-record-dialog__field {
@@ -123,14 +90,14 @@
.delete-record-dialog__input:focus {
outline: none;
- border-color: var(--color-error, #b91c1c);
- box-shadow: 0 0 0 2px rgba(185, 28, 28, 0.15);
+ border-color: var(--color-error);
+ box-shadow: 0 0 0 2px var(--color-error-ring);
}
.delete-record-dialog__error {
margin: 0 0 12px 0;
font-size: 0.8125rem;
- color: var(--color-error, #b91c1c);
+ color: var(--color-error);
}
.delete-record-dialog__actions {
@@ -176,7 +143,7 @@
overflow-y: auto;
background: var(--bg-elevated);
border-radius: var(--radius);
- box-shadow: 0 24px 64px var(--navy-overlay-30);
+ box-shadow: var(--shadow-modal);
border: 1px solid var(--border-default);
animation: modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -396,48 +363,11 @@
border-color: var(--fg-muted);
}
-/* Mobile bottom sheet for feedback (reuses .bottom-sheet base classes) */
-.feedback-bottom-sheet__backdrop {
- display: none;
-}
-
-.feedback-bottom-sheet {
- display: none;
-}
-
@media (max-width: 799px) {
.feedback-modal__backdrop--desktop {
display: none;
}
- .feedback-bottom-sheet__backdrop {
- display: block;
- z-index: var(--z-feedback);
- }
-
- .feedback-bottom-sheet {
- display: flex;
- z-index: var(--z-feedback-above);
- }
-
- .feedback-bottom-sheet .feedback-modal__textarea {
- resize: none;
- min-height: 80px;
- font-size: 16px;
- }
-
- .feedback-bottom-sheet.bottom-sheet--expanded .feedback-modal__textarea {
- min-height: 120px;
- }
-
- .feedback-bottom-sheet .feedback-modal__input {
- font-size: 16px;
- }
-
- .feedback-bottom-sheet .feedback-modal__body {
- padding: 16px 20px;
- }
-
.feedback-modal__expand {
display: none;
}
@@ -505,30 +435,6 @@
gap: 8px;
}
-.handle-edit__row {
- display: flex;
- align-items: flex-start;
- gap: 0;
-}
-
-.handle-edit__row > div:first-child {
- flex: 1;
- min-width: 0;
-}
-
-.handle-edit__suffix {
- font-size: 0.875rem;
- font-family: monospace;
- color: var(--fg-muted);
- padding: 0 0 0 2px;
- white-space: nowrap;
- /* Align with the input field: label height (~20px + 6px margin) + input padding */
- margin-top: 26px;
- height: 44px;
- display: flex;
- align-items: center;
-}
-
.username-card__switch-btns {
display: flex;
flex-wrap: wrap;
@@ -703,11 +609,6 @@
margin-bottom: 8px;
}
-.email-section--form {
- display: flex;
- flex-direction: column;
-}
-
.email-section__status--success {
color: var(--color-success-text);
margin-top: 4px;
@@ -721,11 +622,6 @@
margin-bottom: 0;
}
-.email-section--form .email-section__hint {
- margin-top: 0;
- margin-bottom: 12px;
-}
-
.email-section__fields {
display: flex;
flex-direction: column;
@@ -745,18 +641,6 @@
}
/* ========== Custom Domain Modal ========== */
-.domain-modal__backdrop {
- position: fixed;
- inset: 0;
- z-index: 100;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--navy-overlay-70);
- backdrop-filter: blur(8px);
- animation: modalFadeIn 200ms ease-out;
-}
-
.domain-modal {
width: 90vw;
max-width: 480px;
@@ -765,7 +649,7 @@
overflow: hidden;
display: flex;
flex-direction: column;
- box-shadow: 0 24px 64px var(--navy-overlay-30);
+ box-shadow: var(--shadow-modal);
border: 1px solid var(--border-default);
animation: modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -1147,7 +1031,6 @@
}
@media (prefers-reduced-motion: reduce) {
- .domain-modal__backdrop { animation: none; }
.domain-modal { animation: none; }
.domain-modal__spinner { animation: none; }
}
@@ -1320,103 +1203,6 @@
color: var(--color-error);
}
-/* ========== Connected Apps (/apps directory) ========== */
-.connected-apps__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 4px;
-}
-
-.connected-apps__count {
- font-size: 0.75rem;
- font-weight: 500;
- color: var(--fg-muted);
- background: var(--bg-sunken);
- padding: 4px 10px;
- border-radius: var(--radius);
-}
-
-.connected-apps__list {
- list-style: none;
- margin: 12px 0 0;
- padding: 0;
- display: flex;
- flex-direction: column;
-}
-
-.connected-apps__item-wrap {
- display: contents;
-}
-
-.connected-apps__item {
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 16px;
- text-decoration: none;
- color: inherit;
- border-radius: var(--radius);
- transition: background var(--transition-fast);
-}
-
-.connected-apps__item + .connected-apps__item {
- border-top: 1px solid var(--border-subtle);
-}
-
-.connected-apps__item--link:hover {
- background: var(--overlay-weak);
-}
-
-.connected-apps__item:focus-visible {
- outline: 2px solid var(--focus-ring);
- outline-offset: -2px;
-}
-
-.connected-apps__icon {
- width: 40px;
- height: 40px;
- border-radius: var(--radius);
- background: var(--bg-sunken);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- overflow: hidden;
-}
-
-.connected-apps__logo {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.connected-apps__info {
- flex: 1;
- min-width: 0;
-}
-
-.connected-apps__name {
- font-size: 0.9375rem;
- font-weight: 600;
- color: var(--fg-primary);
- margin: 0;
-}
-
-.connected-apps__desc {
- font-size: 0.8125rem;
- line-height: 1.5;
- color: var(--fg-muted);
- margin: 2px 0 0;
-}
-
-/* Tighter rows on small screens. Drop the description to keep each
- row compact — the user can tap through for more. */
-/* (Removed a non-canonical `@media (max-width: 520px)` block that tightened
- `.connected-apps__item` and hid `.connected-apps__desc`; the ≤799px block
- below already does both across the whole mobile range, so it was redundant.) */
-
/* ========== Responsive: Dashboard & Pages (≤799px / mobile) ========== */
@media (max-width: 799px) {
.app-shell__content {
@@ -1464,65 +1250,6 @@
font-size: 1.375rem;
}
- /* Personal info: single column */
- .personal-info__grid {
- grid-template-columns: 1fr;
- }
-
- .personal-info__field {
- padding: 10px 0;
- font-size: 0.8125rem;
- }
-
- .personal-info__field--mono {
- font-size: 0.75rem;
- }
-
- /* Connected apps items: hide status text on very small screens */
- .connected-apps__item {
- gap: 10px;
- padding: 12px 0;
- }
-
- .connected-apps__desc {
- display: none;
- }
-
- /* App detail cards (connected apps page): stack */
- .app-detail {
- flex-direction: column;
- gap: 12px;
- }
-
- .app-detail__icon {
- width: 48px;
- height: 48px;
- }
-
- .app-detail__header {
- flex-direction: column;
- align-items: flex-start;
- gap: 6px;
- }
-
- /* My data records: stack meta below main */
- .my-data__record {
- flex-direction: column;
- gap: 8px;
- }
-
- .my-data__record-meta {
- flex-direction: row;
- align-items: center;
- gap: 12px;
- }
-
- .my-data__did-info {
- flex-direction: column;
- align-items: flex-start;
- gap: 6px;
- }
-
/* Edit profile: actions stack full-width on mobile (covers both
Save and Cancel). Consolidated here from a separate, mostly-overridden
520px block. */
@@ -1557,66 +1284,6 @@
/* Preview button: smaller on mobile */
}
-/* ========== Membership Sync ========== */
-.org-sync__list {
- display: flex;
- flex-direction: column;
-}
-
-.org-sync__item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 12px 0;
- border-bottom: 1px solid var(--border-subtle);
-}
-
-.org-sync__item:last-child {
- border-bottom: none;
-}
-
-.org-sync__item-info {
- flex: 1;
- min-width: 0;
-}
-
-.org-sync__item-handle {
- font-size: 0.8125rem;
- font-weight: 600;
- color: var(--fg-primary);
-}
-
-.org-sync__item-did {
- font-size: 0.6875rem;
- font-family: monospace;
- color: var(--fg-muted);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- margin-top: 2px;
-}
-
-.org-sync__badge {
- font-size: 0.6875rem;
- font-weight: 500;
- letter-spacing: 0.04em;
- padding: 4px 10px;
- border-radius: var(--radius);
- flex-shrink: 0;
- white-space: nowrap;
-}
-
-.org-sync__badge--removed {
- color: var(--color-error);
- background: var(--color-error-bg);
-}
-
-.org-sync__badge--changed {
- color: var(--color-accent);
- background: var(--bg-canvas);
-}
-
/* ========== Pagination ========== */
.pagination {
display: flex;
@@ -1660,7 +1327,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
- z-index: 1;
+ z-index: var(--z-local-raise);
}
.image-edit-overlay__btn {
diff --git a/src/app/styles/explore.css b/src/app/styles/explore.css
index 4a5d920c..fe1cf8bc 100644
--- a/src/app/styles/explore.css
+++ b/src/app/styles/explore.css
@@ -711,6 +711,21 @@
border-top: 1px solid var(--border-subtle);
}
+/* Skip layout/paint for offscreen rows — load-more appends 50 per page
+ with no cap. Estimate matches the ~60px row (40px thumb + padding);
+ rendered rows are re-measured via the `auto` keyword, keeping
+ back-button scroll restoration stable.
+ Scoped to the three variants it helps: --funding is excluded because
+ its desktop subgrid rows need `display: contents` on the li, and
+ content-visibility / containment cannot apply to a boxless element
+ — the declaration would be inert there. */
+.explore__list--certs > li,
+.explore__list--accounts > li,
+.explore__list--projects > li {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 64px;
+}
+
.cert-list-row {
/* Four columns: link block (thumb + title/meta), author column,
and time. The link block shrinks; the author column has a
@@ -950,6 +965,12 @@
gap: 12px;
}
+/* Same containment for grid cards (user / project / cert results). */
+.explore__grid > li {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 320px;
+}
+
.explore__grid--users {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
diff --git a/src/app/styles/feed.css b/src/app/styles/feed.css
index 3c3a04bb..0dc95ed7 100644
--- a/src/app/styles/feed.css
+++ b/src/app/styles/feed.css
@@ -1,28 +1,3 @@
-/* ==========================================================================
- Search Page (mobile-first)
- ========================================================================== */
-
-.search-page {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding-top: 8px;
-}
-
-/* The legacy `.search-page__bar` / `__icon` / `__input` rules were
- removed after the Explore page migrated to the people-search
- typeahead (`/api/search-actors`). The remaining `.search-page` +
- `.search-page__hint` selectors are still in use by /search and
- embedded versions of the typeahead. */
-
-.search-page__hint {
- font-family: var(--font-inter), system-ui, sans-serif;
- font-size: 0.8125rem;
- color: var(--fg-muted);
- margin-top: 12px;
-}
-
-
/* ==========================================================================
Create Form (mobile-first)
========================================================================== */
@@ -362,22 +337,6 @@
color: var(--fg-primary);
}
-/* ---- Unfiltered Warning Banner ---- */
-
-.feed-unfiltered-banner {
- position: sticky;
- top: 0;
- z-index: 20;
- background: var(--btn-primary-bg);
- color: var(--btn-primary-fg);
- font-family: var(--font-inter), system-ui, sans-serif;
- font-size: 0.8125rem;
- font-weight: 500;
- text-align: center;
- padding: 8px 16px;
- border-bottom: 1px solid var(--border-default);
-}
-
/* ==========================================================================
Activity Feed (mobile-first)
@@ -707,124 +666,6 @@
/* Contributor-row skeleton bars now render via the primitive.
The hand-rolled *-skel rules were removed in the consolidation. */
-/* ========== Location cards (activity detail) ========== */
-
-.location-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.location-card {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding: 10px 12px;
- background: var(--bg-raised);
- border: 1px solid var(--border-subtle);
- border-radius: var(--radius);
-}
-
-.location-card--fallback {
- background: transparent;
- border-style: dashed;
-}
-
-.location-card__icon {
- flex-shrink: 0;
- width: 28px;
- height: 28px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border-radius: 999px;
- background: var(--overlay-weak);
- color: var(--fg-muted);
- margin-top: 2px;
-}
-
-.location-card__body {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.location-card__name {
- margin: 0;
- font-size: 0.9375rem;
- font-weight: 600;
- color: var(--fg-primary);
- line-height: 1.3;
- word-break: break-word;
-}
-
-.location-card__desc {
- margin: 2px 0 0;
- font-size: 0.8125rem;
- color: var(--fg-secondary);
- line-height: 1.45;
- word-break: break-word;
-}
-
-.location-card__detail {
- margin: 4px 0 0;
- font-size: 0.8125rem;
- color: var(--fg-muted);
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
-}
-
-.location-card__coords {
- font-family: var(--font-mono, ui-monospace, monospace);
- color: var(--fg-secondary);
- font-size: 0.75rem;
-}
-
-.location-card__type {
- font-size: 0.6875rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--fg-muted);
- padding: 2px 6px;
- background: var(--overlay-weak);
- border-radius: var(--radius);
-}
-
-.location-card__fallback {
- word-break: break-word;
-}
-
-.location-card__map-link {
- color: var(--fg-primary);
- text-decoration: underline;
- text-underline-offset: 2px;
- font-weight: 500;
-}
-
-.location-card__map-link:hover {
- color: var(--fg-secondary);
-}
-
-.location-card__uri {
- margin: 2px 0 0;
- font-family: var(--font-mono, ui-monospace, monospace);
- font-size: 0.75rem;
- color: var(--fg-muted);
- word-break: break-all;
-}
-
-/* The .location-card skeleton (icon/name/detail bars + location-card-pulse
- keyframe) was dead CSS — no TSX consumer — and was removed in the
- consolidation. */
-
/* ========== Map (Leaflet wrapper) ========== */
.certs-map {
@@ -833,9 +674,10 @@
overflow: hidden;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
- /* Keep map above our own stacking contexts but below modals. */
+ /* Atomic stacking context so Leaflet's internal panes (z 200-700)
+ stay contained below app chrome and sibling controls. */
position: relative;
- z-index: 0;
+ isolation: isolate;
}
.certs-map .leaflet-container {
@@ -919,11 +761,6 @@
}
}
-/* Spacing for the inline map inside a LocationCard */
-.location-card__map {
- margin-top: 8px;
-}
-
/* Endorsements
* -----------------------------------------------------------------
* - Main /endorsements page uses the settings dashboard shell
@@ -938,7 +775,6 @@
* the bottom of the bar so the active-tab underline coincides with
* the bar border; the "New" button is vertically centered on the
* same row. */
-.page-tabs-bar,
.endorsements-tabs-bar {
display: flex;
justify-content: space-between;
@@ -948,9 +784,8 @@
}
/* Generic "+ New X" toolbar button used in the tab-bar pattern
- (endorsements, groups). Subtle text-button styling so it doesn't
+ (endorsements). Subtle text-button styling so it doesn't
compete with primary content actions further down the page. */
-.page-tabs-bar__new,
.endorsements-new-btn {
align-self: center;
display: inline-flex;
@@ -968,29 +803,15 @@
flex-shrink: 0;
}
-.page-tabs-bar__new:hover:not(:disabled),
.endorsements-new-btn:hover:not(:disabled) {
background: var(--overlay-weak);
}
-.page-tabs-bar__new:disabled,
.endorsements-new-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
-/* Section heading shown above a tab bar (e.g. "Membership" above
- Public/Private tabs on /groups). */
-.page-section-heading {
- margin: 0 0 12px;
- font-family: var(--font-headline), "Noto Serif", serif;
- font-size: 1.5rem;
- font-weight: 700;
- color: var(--fg-primary);
- letter-spacing: -0.01em;
- line-height: 1.2;
-}
-
/* Inline "new endorsement" panel that sits at the top of the Given
tab — replaces the prior "+ New" modal flow. */
.endorsement-panel {
@@ -1445,7 +1266,7 @@
color: var(--fg-primary);
text-align: left;
cursor: pointer;
- border-radius: 2px;
+ border-radius: var(--radius);
transition: background var(--transition-fast);
}
@@ -1507,7 +1328,7 @@
padding: 2px 6px;
background: transparent;
border: 1px solid var(--bg-elevated);
- border-radius: 2px;
+ border-radius: var(--radius);
color: var(--bg-elevated);
font: inherit;
font-weight: 600;
@@ -1557,42 +1378,12 @@
min-height: 44px;
padding: 10px;
}
- /* Tab-bar "New" buttons (endorsements, groups) → 44px tall. */
- .page-tabs-bar__new,
+ /* Tab-bar "New" buttons (endorsements) → 44px tall. */
.endorsements-new-btn {
min-height: 44px;
}
}
-/* Preview block inside the New endorsement modal */
-.endorsement-preview {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 12px;
- background: var(--overlay-weak);
- border: 1px solid var(--border-default);
- border-radius: var(--radius);
-}
-
-.endorsement-preview__meta {
- display: flex;
- flex-direction: column;
- min-width: 0;
- line-height: 1.2;
-}
-
-.endorsement-preview__name {
- font-weight: 600;
- font-size: 0.9375rem;
- color: var(--fg-primary);
-}
-
-.endorsement-preview__handle {
- font-size: 0.8125rem;
- color: var(--fg-muted);
-}
-
/* Multi-recipient endorsement modal — list of selected recipients
with per-row write status. */
.endorsement-multi-list {
@@ -1848,58 +1639,6 @@
padding: 24px 0;
}
-/* Received endorsement card (endorsements page) */
-.received-endorsement-card {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 12px 0;
- border-bottom: 1px solid var(--border-default);
-}
-
-.received-endorsement-card:last-child {
- border-bottom: none;
-}
-
-.received-endorsement-card__endorsers {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- padding-left: 60px; /* align with text after avatar */
-}
-
-/* Endorser chip — compact inline link */
-.endorser-chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 4px 10px 4px 4px;
- background: var(--overlay-weak);
- border: 1px solid var(--border-default);
- border-radius: 9999px;
- text-decoration: none;
- color: inherit;
- font-size: 0.8125rem;
- line-height: 1.2;
- transition: background 0.15s ease;
-}
-
-.endorser-chip:hover {
- background: var(--overlay-medium);
-}
-
-.endorser-chip__name {
- font-weight: 600;
- color: var(--fg-primary);
- white-space: nowrap;
-}
-
-.endorser-chip__date {
- color: var(--fg-muted);
- font-size: 0.75rem;
- white-space: nowrap;
-}
-
/* iOS auto-zoom rule: any input touched on mobile must be >=16px or
Safari zooms the viewport. The people-search input lives on /search
and in the right rail; ensure that and the create-form inputs hit
diff --git a/src/app/styles/home.css b/src/app/styles/home.css
index 3b0b35a4..4e67dc2e 100644
--- a/src/app/styles/home.css
+++ b/src/app/styles/home.css
@@ -389,6 +389,12 @@
.home-feed__item {
border-bottom: 1px solid var(--border-subtle);
+ /* Skip layout/paint for offscreen rows — the feed's auto-loop can
+ accumulate hundreds of items. The intrinsic estimate is a mixed
+ average (compact endorsement rows dominate; image cards are
+ re-measured once rendered via the `auto` keyword). */
+ content-visibility: auto;
+ contain-intrinsic-size: auto 140px;
}
.home-feed__item:last-child {
diff --git a/src/app/styles/landing.css b/src/app/styles/landing.css
index 883e2055..8fee2d17 100644
--- a/src/app/styles/landing.css
+++ b/src/app/styles/landing.css
@@ -1447,7 +1447,7 @@
* - modalFadeIn / modalSlideUp back AppDialog's animate-[modalSlideUp]
* and the feedback modal animations.
* - .feedback-* is the app-wide feedback modal (feedback-modal.tsx).
- * - .app-page / .app-card back /about, /terms, /privacy, /dsa,
+ * - .app-page backs /about, /terms, /privacy, /dsa,
* /imprint, /help.
* - .welcome-footer wraps SiteFooter on /welcome (welcome/layout.tsx).
* ========================================================================== */
@@ -1529,7 +1529,7 @@
overflow-y: auto;
background: var(--color-off-white);
border-radius: var(--radius);
- box-shadow: 0 24px 64px var(--navy-overlay-30);
+ box-shadow: var(--shadow-modal);
border: 1px solid var(--border-default);
animation: modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -1695,15 +1695,6 @@
border-color: var(--color-mid-gray);
}
-/* Mobile bottom sheet for feedback (reuses .bottom-sheet base classes) */
-.feedback-bottom-sheet__backdrop {
- display: none;
-}
-
-.feedback-bottom-sheet {
- display: none;
-}
-
@media (max-width: 799px) {
.feedback-trigger {
bottom: 16px;
@@ -1717,34 +1708,6 @@
display: none;
}
- .feedback-bottom-sheet__backdrop {
- display: block;
- z-index: var(--z-feedback);
- }
-
- .feedback-bottom-sheet {
- display: flex;
- z-index: var(--z-feedback-above);
- }
-
- .feedback-bottom-sheet .feedback-modal__textarea {
- resize: none;
- min-height: 80px;
- font-size: 16px;
- }
-
- .feedback-bottom-sheet.bottom-sheet--expanded .feedback-modal__textarea {
- min-height: 120px;
- }
-
- .feedback-bottom-sheet .feedback-modal__input {
- font-size: 16px;
- }
-
- .feedback-bottom-sheet .feedback-modal__body {
- padding: 16px 20px;
- }
-
.feedback-modal__expand {
display: none;
}
@@ -1773,27 +1736,6 @@
}
}
-/* ========== Cards (app pages) ========== */
-.app-card {
- background: var(--color-off-white);
- border: 1px solid var(--border-default);
- border-radius: var(--radius);
- padding: 24px;
- transition: border-color var(--transition-fast);
-}
-
-.app-card:hover {
- border-color: var(--border-hover-soft);
-}
-
-.app-card__label {
- font-size: 0.6875rem;
- font-weight: 600;
- letter-spacing: 0.08em;
- color: var(--color-mid-gray);
- margin-bottom: 8px;
-}
-
/* /welcome footer wrapper — the app shell skips SiteFooter on this
route, so the landing layout renders it directly. Full-bleed (no
horizontal padding or max-width cap) so the off-white band and the
diff --git a/src/app/styles/layout.css b/src/app/styles/layout.css
index 34cdfb3d..c4a91669 100644
--- a/src/app/styles/layout.css
+++ b/src/app/styles/layout.css
@@ -39,18 +39,6 @@
display: inline-flex;
}
-/* ========== Theme-aware image swap (dual-image pattern) ==========
- Uses html[data-theme] / html:not([data-theme="dark"]) selectors for
- higher specificity (0,2,1) so component-level display rules still take
- effect on the visible image. Both images must carry the --light and
- --dark class respectively. */
-html:not([data-theme="dark"]) .signin-mark__img--dark {
- display: none;
-}
-html[data-theme="dark"] .signin-mark__img--light {
- display: none;
-}
-
/* ========== Loading Screen ========== */
.loading-screen {
/* Fix the loading screen to the viewport so the logo always sits at
@@ -647,27 +635,6 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
padding: 16px 0;
}
-/* ========== Cards (app pages) ========== */
-.app-card {
- background: var(--bg-elevated);
- border: 1px solid var(--border-default);
- border-radius: var(--radius);
- padding: 24px;
- transition: border-color var(--transition-fast);
-}
-
-.app-card:hover {
- border-color: var(--border-hover-soft);
-}
-
-.app-card__label {
- font-size: 0.6875rem;
- font-weight: 600;
- letter-spacing: 0.08em;
- color: var(--fg-muted);
- margin-bottom: 8px;
-}
-
/* ========== App Shell (editorial layout) ==========
Mobile (<800px): single column, content max-width 720px.
Desktop (≥800px): foundation for the 3-column rail layout in PR2.
@@ -832,8 +799,7 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
}
/* Profile pages on desktop expand the content area to a GitHub-style
- 1280px container. Non-Overview tabs inside the profile page constrain
- themselves to a reading column via .profile-panel--reading. */
+ 1280px container. */
@media (min-width: 800px) {
.app-shell--fullbleed .app-shell__content {
max-width: 1280px;
@@ -972,79 +938,6 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
display: block;
}
-.personal-info__hint {
- font-size: 0.6875rem;
- color: var(--fg-muted);
- margin-top: 4px;
- line-height: 1.4;
-}
-
-.personal-info__field--link {
- color: var(--color-accent);
- text-decoration: none;
-}
-
-.personal-info__field--link:hover {
- text-decoration: underline;
-}
-
-.personal-info__full-width {
- grid-column: 1 / -1;
-}
-
-.profile-fallback-note {
- background: var(--color-warning-bg);
- border: 1px solid var(--color-warning-border);
- border-radius: var(--radius);
- padding: 12px 16px;
- margin-top: 16px;
-}
-
-.profile-fallback-note p {
- font-size: 0.8125rem;
- color: var(--color-warning-text);
- line-height: 1.5;
- margin: 0;
-}
-
-/* Personal Information form fields */
-.personal-info__grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 16px;
- margin-top: 16px;
-}
-
-.personal-info__label {
- display: block;
- font-size: 0.625rem;
- font-weight: 500;
- letter-spacing: 0.15em;
- text-transform: uppercase;
- color: var(--fg-muted);
- margin-bottom: 6px;
-}
-
-.personal-info__field {
- padding: 12px 0;
- border: none;
- border-radius: 0;
- font-size: 0.875rem;
- color: var(--fg-primary);
- background: transparent;
-}
-
-.personal-info__field--mono {
- font-family: monospace;
- font-size: 0.75rem;
- letter-spacing: 0.02em;
- color: var(--fg-muted);
- word-break: break-all;
- background: transparent;
- border: none;
- padding: 12px 0;
-}
-
/* Utility */
.mt-4 {
margin-top: 16px;
@@ -1181,8 +1074,8 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
.right-rail__heading {
font-family: var(--font-inter), system-ui, sans-serif;
- /* Caption scale per DESIGN.md §3 — matches .app-card__label and other
- uppercase labels (0.6875rem / 0.08em). */
+ /* Caption scale per DESIGN.md §3 — matches the app's other uppercase
+ labels (0.6875rem / 0.08em). */
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
@@ -1320,7 +1213,7 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
.news__rich-link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
- border-radius: 2px;
+ border-radius: var(--radius);
}
/* The relative-time element is the canonical "view this post on
@@ -1341,7 +1234,7 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
.news__time-link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
- border-radius: 2px;
+ border-radius: var(--radius);
}
.news__more {
@@ -1372,7 +1265,7 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
.news__more:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
- border-radius: 2px;
+ border-radius: var(--radius);
}
/* ========== People search typeahead ==========
@@ -3355,6 +3248,6 @@ a.mobile-sidebar__profile:hover .mobile-sidebar__name {
.site-footer__link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
- border-radius: 2px;
+ border-radius: var(--radius);
}
diff --git a/src/app/styles/pages.css b/src/app/styles/pages.css
index 386c81b9..b2285059 100644
--- a/src/app/styles/pages.css
+++ b/src/app/styles/pages.css
@@ -47,7 +47,7 @@
border: 1px solid var(--border-default);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
- z-index: 60;
+ z-index: var(--z-popover);
max-height: 280px;
overflow-y: auto;
margin-top: 4px;
@@ -246,29 +246,6 @@
white-space: normal;
}
-/* Create organization page */
-.org-create__fields {
- display: flex;
- flex-direction: column;
- gap: 20px;
- margin-top: 8px;
-}
-
-.org-create__handle-hint {
- font-size: 0.75rem;
- color: var(--fg-muted);
- margin-top: 4px;
-}
-
-.org-create__actions {
- display: flex;
- justify-content: flex-end;
- gap: 12px;
- padding-top: 24px;
- margin-top: 8px;
- border-top: 1px solid var(--border-light);
-}
-
/* Org profile page */
.org-profile__loading {
display: flex;
@@ -702,7 +679,9 @@
position: fixed;
inset: 0;
background: var(--navy-overlay-30);
- z-index: 70;
+ /* Backdrop and sheet tie at --z-portal-sheet; the backdrop renders
+ first in the portal fragment, so DOM order keeps it underneath. */
+ z-index: var(--z-portal-sheet);
animation: bottomSheetFadeIn 0.2s ease-out;
}
@@ -716,7 +695,7 @@
max-height: 70vh;
background: var(--bg-elevated);
border-radius: var(--radius) var(--radius) 0 0;
- z-index: 71;
+ z-index: var(--z-portal-sheet);
animation: bottomSheetSlideUp 0.3s ease-out;
overflow: hidden;
transition: max-height 0.3s ease-out;
diff --git a/src/app/styles/profile-edit.css b/src/app/styles/profile-edit.css
index 2d9d0923..ac946b1c 100644
--- a/src/app/styles/profile-edit.css
+++ b/src/app/styles/profile-edit.css
@@ -81,15 +81,6 @@
margin-bottom: 56px;
}
-/* Tighten BannerUpload's profile-card__banner reuse so the hero is
- compact (the edit page isn't trying to show off the banner — it's
- showing you can edit it). */
-.pe .profile-card__banner {
- height: 140px;
- margin: 0;
- border-radius: var(--radius);
-}
-
.pe__avatar-slot {
position: absolute;
left: 16px;
@@ -295,10 +286,10 @@
.pe__footer {
position: sticky;
/* Pin the bar ABOVE the fixed mobile bottom nav rather than flush to the
- viewport bottom — otherwise it sticks under the bar (z-index 50 > 5) and
- the Save/Cancel row is half-hidden. `--bottom-nav-height` is 0px at
- ≥800px, so on desktop this resolves to the original `bottom: 0`
- (+ safe-area, which is also 0px there). */
+ viewport bottom — otherwise it sticks under the bar (--z-bottom-nav (50)
+ > --z-rail-sticky (10)) and the Save/Cancel row is half-hidden.
+ `--bottom-nav-height` is 0px at ≥800px, so on desktop this resolves to
+ the original `bottom: 0` (+ safe-area, which is also 0px there). */
bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
/* Negative side margins so the footer reaches the column's full width
even though the form content sits inside the 32px column padding.
@@ -312,7 +303,7 @@
hard-cut. Falls back to the solid bg when backdrop-filter is unsupported. */
backdrop-filter: saturate(180%) blur(8px);
-webkit-backdrop-filter: saturate(180%) blur(8px);
- z-index: 5;
+ z-index: var(--z-rail-sticky);
}
.pe__footer-inner {
diff --git a/src/app/styles/profile-endorsements.css b/src/app/styles/profile-endorsements.css
index 319a9059..7618c587 100644
--- a/src/app/styles/profile-endorsements.css
+++ b/src/app/styles/profile-endorsements.css
@@ -171,7 +171,7 @@
border: 1px solid var(--border-default);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
- z-index: 30;
+ z-index: var(--z-popover);
}
.profile-endorsements-v2__sort-item {
@@ -359,7 +359,7 @@
position: absolute;
top: 10px;
right: 10px;
- z-index: 1;
+ z-index: var(--z-local-raise);
}
.profile-endorsements-v2__card:has(.profile-endorsements-v2__card-menu)
diff --git a/src/app/styles/profile-groups.css b/src/app/styles/profile-groups.css
index c354749f..f43ea014 100644
--- a/src/app/styles/profile-groups.css
+++ b/src/app/styles/profile-groups.css
@@ -167,7 +167,7 @@
border: 1px solid var(--border-default);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
- z-index: 30;
+ z-index: var(--z-popover);
}
.profile-groups__sort-item {
@@ -267,7 +267,7 @@
.profile-groups__subtab:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 4px;
- border-radius: 2px;
+ border-radius: var(--radius);
}
.profile-groups__subtab-count {
diff --git a/src/app/styles/profile-inline-edit.css b/src/app/styles/profile-inline-edit.css
index 45b5a62e..8003e967 100644
--- a/src/app/styles/profile-inline-edit.css
+++ b/src/app/styles/profile-inline-edit.css
@@ -35,7 +35,7 @@
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: background var(--transition-fast);
- z-index: 1;
+ z-index: var(--z-local-raise);
}
.profile-sidebar__avatar-edit-btn:hover {
@@ -190,7 +190,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
- z-index: 1;
+ z-index: var(--z-local-raise);
}
.profile-banner-upload__btn {
diff --git a/src/app/styles/profile-lists.css b/src/app/styles/profile-lists.css
index c58450a2..0586b8f3 100644
--- a/src/app/styles/profile-lists.css
+++ b/src/app/styles/profile-lists.css
@@ -514,7 +514,7 @@
aside meta rows (Time period, Locations) where a
right-flowing menu would overflow past the cert column. */
right: 0;
- z-index: 50;
+ z-index: var(--z-popover);
min-width: 168px;
padding: 4px;
background: var(--bg-elevated);
diff --git a/src/app/styles/profile.css b/src/app/styles/profile.css
index 50eb9307..e576e62e 100644
--- a/src/app/styles/profile.css
+++ b/src/app/styles/profile.css
@@ -296,22 +296,7 @@
.profile-tabs__tab:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
- border-radius: 2px;
-}
-
-.profile-panel {
- padding: 16px;
-}
-
-/* Reading-column constraint for the non-Overview tab panels (Activities,
- Endorsements, Groups) on desktop. The profile page widens to 1280px
- for the GitHub-style Overview, but list views still want a narrow
- reading column. */
-@media (min-width: 800px) {
- .profile-panel--reading {
- max-width: 720px;
- margin: 0 auto;
- }
+ border-radius: var(--radius);
}
/* ========== Profile › Groups tab ========== */
diff --git a/src/app/styles/project-detail.css b/src/app/styles/project-detail.css
index 2b577d66..d6e0be88 100644
--- a/src/app/styles/project-detail.css
+++ b/src/app/styles/project-detail.css
@@ -598,10 +598,6 @@
}
}
-/* Note: the project's "more" link is now `.more-link-row` /
- `.more-link` (defined in components.css) — same utility the cert
- detail uses. Right-aligned by default. */
-
/* ---------- Long-form description (prose) ---------- */
/* Read-mode wrapper for the structured description AND edit-mode
@@ -1143,7 +1139,7 @@
position: absolute;
top: 8px;
right: 8px;
- z-index: 1;
+ z-index: var(--z-local-raise);
}
.project-detail__cert-menu-btn {
diff --git a/src/app/styles/settings-page.css b/src/app/styles/settings-page.css
index 78b30ba3..d247083f 100644
--- a/src/app/styles/settings-page.css
+++ b/src/app/styles/settings-page.css
@@ -554,7 +554,7 @@
position: absolute;
width: 7px;
height: 7px;
- border-radius: 1px;
+ border-radius: var(--radius);
opacity: 0;
animation: groupConfetti 0.9s ease-out 0.08s both;
}
diff --git a/src/app/styles/tokens.css b/src/app/styles/tokens.css
index 6b203cc4..d03535f5 100644
--- a/src/app/styles/tokens.css
+++ b/src/app/styles/tokens.css
@@ -10,7 +10,7 @@
width: 1px;
height: 1px;
overflow: hidden;
- z-index: 9999;
+ z-index: var(--z-skip-nav);
padding: 8px 16px;
background: var(--color-primary);
color: var(--color-white);
@@ -112,6 +112,10 @@
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12);
+ /* Modal chrome elevation (AppDialog, feedback + domain modals). Single
+ definition: --navy-overlay-30 carries its own dark override on :root,
+ so this resolves theme-correctly without a dark-theme duplicate. */
+ --shadow-modal: 0 24px 64px var(--navy-overlay-30);
/* Focus + selection */
--focus-ring: var(--color-accent);
@@ -132,6 +136,8 @@
Soft red consistent with #ba1a1a at low alpha over the light canvas. */
--color-error-bg: rgba(186, 26, 26, 0.08);
--color-error-border: rgba(186, 26, 26, 0.22);
+ /* Focus ring on invalid inputs (0 0 0 2px geometry). */
+ --color-error-ring: rgba(186, 26, 26, 0.15);
/* Badges */
--badge-success-bg: #e8f5e9;
@@ -207,7 +213,8 @@
--bp-gt-desktop: 1300px; /* + left rail expands to icon+label */
/* ===== Z-index map — keep all UI layers here. */
- --z-rail-sticky: 10; /* sticky elements inside the rails */
+ --z-local-raise: 1; /* raise above overlapping siblings inside a local stacking context */
+ --z-rail-sticky: 10; /* sticky elements inside rails and content columns */
--z-rail: 30; /* left + right rails (below navbar) */
--z-popover: 40; /* in-flow popovers / typeahead dropdowns */
--z-navbar: 50; /* fixed top navbar */
@@ -317,6 +324,9 @@
#f87171) that pair with --color-error text. */
--color-error-bg: rgba(248, 113, 113, 0.12);
--color-error-border: rgba(248, 113, 113, 0.3);
+ /* Focus ring on invalid inputs — lighter red, higher alpha so the ring
+ stays visible over the near-black canvas. */
+ --color-error-ring: rgba(248, 113, 113, 0.25);
/* Badges */
--badge-success-bg: rgba(46, 204, 113, 0.15);
@@ -420,15 +430,13 @@ h1, h2, h3, h4, h5, h6 {
font-feature-settings: 'tnum' 1;
}
-.app-card__label,
.dash-card__title,
.dash-card__preview-label,
.navbar__app-link,
.domain-modal__label,
.domain-modal__dns-label,
.domain-modal__verify-label,
-.username-card__form-label,
-.location-card__type {
+.username-card__form-label {
font-feature-settings: 'case' 1;
}
diff --git a/src/app/styles/workspace.css b/src/app/styles/workspace.css
index 56819df7..662ef1b1 100644
--- a/src/app/styles/workspace.css
+++ b/src/app/styles/workspace.css
@@ -466,7 +466,7 @@
position: absolute;
top: calc(100% + 4px);
left: 0;
- z-index: 10;
+ z-index: var(--z-popover);
list-style: none;
margin: 0;
padding: 6px;
diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx
index dcadd169..fb54ce1a 100644
--- a/src/app/workspace/page.tsx
+++ b/src/app/workspace/page.tsx
@@ -3,7 +3,7 @@ import { Suspense } from "react"
import Workspace from "@/components/workspace/workspace"
export const metadata: Metadata = {
- title: "Workspace — Certified",
+ title: "Workspace",
description:
"Compare navigation structures for stepping between the network, actors, and per-lexicon listings.",
}
diff --git a/src/components/badges/response-buttons.tsx b/src/components/badges/response-buttons.tsx
index 5319d78f..3f62dac1 100644
--- a/src/components/badges/response-buttons.tsx
+++ b/src/components/badges/response-buttons.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useCallback, useState } from "react"
+import { useCallback, useMemo, useState } from "react"
import {
createResponse,
type ResponseState,
@@ -99,12 +99,12 @@ export default function ResponseButtons({
// The set of currently-pressed values for the ToggleGroup. At most one
// of accept/reject is pressed at a time (accepted XOR rejected); an
- // unknown/default state presses neither.
- const pressedValues = isAccepted
- ? ["accepted"]
- : isRejected
- ? ["rejected"]
- : []
+ // unknown/default state presses neither. Memoized so onValueChange
+ // keeps a stable identity across unrelated re-renders.
+ const pressedValues = useMemo(
+ () => (isAccepted ? ["accepted"] : isRejected ? ["rejected"] : []),
+ [isAccepted, isRejected],
+ )
// Translate a toggle into a write. Clicking either button always
// writes its response (matching the original "no reset here" intent),
diff --git a/src/components/badges/response-menu.tsx b/src/components/badges/response-menu.tsx
index b3bce144..40c04311 100644
--- a/src/components/badges/response-menu.tsx
+++ b/src/components/badges/response-menu.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useCallback, useState } from "react"
+import { useCallback, useMemo, useState } from "react"
import { Check, X } from "lucide-react"
import {
createResponse,
@@ -113,12 +113,17 @@ export default function ResponseMenu({
// reject), empty for default/unknown. A toggle of "accepted" routes
// to onAccept and "rejected" to onReject regardless of direction —
// both pressing and un-pressing a value emit it in the diff below.
- const pressedValues =
- state === "accepted"
- ? ["accepted"]
- : state === "rejected"
- ? ["rejected"]
- : []
+ // Memoized so onValueChange keeps a stable identity across unrelated
+ // re-renders.
+ const pressedValues = useMemo(
+ () =>
+ state === "accepted"
+ ? ["accepted"]
+ : state === "rejected"
+ ? ["rejected"]
+ : [],
+ [state],
+ )
const onValueChange = useCallback(
(next: string[]) => {
diff --git a/src/components/context/update-form.tsx b/src/components/context/update-form.tsx
index 628acdf3..66fdde12 100644
--- a/src/components/context/update-form.tsx
+++ b/src/components/context/update-form.tsx
@@ -10,6 +10,7 @@ import Button from "@/components/ui/button"
import ErrorMessage from "@/components/ui/error-message"
import Tooltip from "@/components/ui/tooltip"
import { uploadBlob, buildAvatarUrlFromCid } from "@/lib/atproto/profile"
+import { invalidateContextUpdates } from "@/hooks/use-context-updates"
import {
writeContextUpdate,
resolveAttachment,
@@ -161,6 +162,17 @@ export default function UpdateForm({
rkey: mode === "edit" ? rkey : undefined,
swapRecord: mode === "edit" ? initialCid : undefined,
})
+ // The detail pages read updates through a shared module cache
+ // (use-context-updates). This form lives on its own route, so no
+ // hook instance is mounted to refetch — mark every targeted
+ // subject stale so the page we navigate back to re-fetches
+ // instead of serving the pre-save list.
+ invalidateContextUpdates(subjectUri)
+ for (const s of subjects) {
+ if (typeof s.uri === "string" && s.uri !== subjectUri) {
+ invalidateContextUpdates(s.uri)
+ }
+ }
router.push(backHref)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save update")
diff --git a/src/components/contributor-board/share-embed-dialog.tsx b/src/components/contributor-board/share-embed-dialog.tsx
index 449d7715..e329b55d 100644
--- a/src/components/contributor-board/share-embed-dialog.tsx
+++ b/src/components/contributor-board/share-embed-dialog.tsx
@@ -1,9 +1,9 @@
"use client"
-import { useState } from "react"
import { Copy, Check } from "lucide-react"
import AppDialog, { AppDialogHeader, AppDialogBody } from "@/components/ui/app-dialog"
import Button from "@/components/ui/button"
+import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { recordUrl } from "@/lib/urls"
interface ShareEmbedDialogProps {
@@ -19,16 +19,10 @@ export function ShareEmbedDialog({ did, rkey, onClose }: ShareEmbedDialogProps)
const embedUrl = `${origin}/embed/board/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`
const embedCode = ``
- const [copied, setCopied] = useState(null)
- const copy = async (text: string, key: string) => {
- try {
- await navigator.clipboard.writeText(text)
- setCopied(key)
- window.setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500)
- } catch {
- /* clipboard unavailable — no-op */
- }
- }
+ // One shared-hook instance per copy target so each button shows its
+ // own check mark; the hook auto-resets after 1500ms.
+ const { copied: linkCopied, copy: copyLink } = useCopyToClipboard()
+ const { copied: embedCopied, copy: copyEmbed } = useCopyToClipboard()
return (
@@ -48,9 +42,9 @@ export function ShareEmbedDialog({ did, rkey, onClose }: ShareEmbedDialogProps)
size="icon"
variant="secondary"
aria-label="Copy link"
- onClick={() => copy(shareUrl, "link")}
+ onClick={() => void copyLink(shareUrl)}
>
- {copied === "link" ? : }
+ {linkCopied ? : }
@@ -70,9 +64,9 @@ export function ShareEmbedDialog({ did, rkey, onClose }: ShareEmbedDialogProps)
size="icon"
variant="secondary"
aria-label="Copy embed code"
- onClick={() => copy(embedCode, "embed")}
+ onClick={() => void copyEmbed(embedCode)}
>
- {copied === "embed" ? : }
+ {embedCopied ? : }
diff --git a/src/components/create/contributor-identity-card.tsx b/src/components/create/contributor-identity-card.tsx
index 1b8ea1ba..f8dd64bb 100644
--- a/src/components/create/contributor-identity-card.tsx
+++ b/src/components/create/contributor-identity-card.tsx
@@ -5,7 +5,8 @@ import Avatar from "@/components/ui/avatar"
import LoadingSpinner from "@/components/ui/loading-spinner"
import Tooltip from "@/components/ui/tooltip"
import { useContributorInfo } from "@/hooks/use-contributor-info"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
+import { isDid } from "@/lib/utils/did"
interface ContributorIdentityCardProps {
/** Normalised identity string (DID or handle without leading `@`). */
@@ -35,10 +36,14 @@ export function ContributorIdentityCard({
}: ContributorIdentityCardProps) {
const { info, isLoading } = useContributorInfo(identity)
- const displayName = info?.displayName || info?.handle || identity
- const handle =
- info?.handle && info.handle !== info.did ? info.handle : null
- const initials = getInitials(info?.displayName ?? null, info?.did ?? identity)
+ // `identity` is a handle-or-DID string: a handle stays the display
+ // fallback while resolution is pending / failed; a DID falls through
+ // to deriveIdentity's canonical truncated-DID fallback.
+ const { displayName, handle, initials } = deriveIdentity(
+ info,
+ info?.did ?? identity,
+ { fallbackLabel: isDid(identity) ? undefined : identity },
+ )
return (
(null)
const [selectedExistingUri, setSelectedExistingUri] = useState("")
- useEffect(() => {
- const controller = new AbortController()
+ // Adjust state during render when the source repo changes (first mount
+ // already starts loading via the initializers), so the effect holds
+ // only the listRecords lifecycle.
+ const [prevOwnDid, setPrevOwnDid] = useState(ownDid)
+ if (prevOwnDid !== ownDid) {
+ setPrevOwnDid(ownDid)
setMyLocationsLoading(true)
setMyLocationsError(null)
+ }
+
+ useEffect(() => {
+ const controller = new AbortController()
const params = new URLSearchParams({
repo: ownDid,
collection: "app.certified.location",
@@ -132,7 +141,7 @@ export default function LocationPickerDialog({
const display =
split?.name ||
rawName ||
- rec.uri.split("/").pop() ||
+ rkeyFromUri(rec.uri) ||
"(unnamed location)"
const lt =
typeof rec.value?.locationType === "string"
@@ -177,6 +186,7 @@ export default function LocationPickerDialog({
if (mode !== "new" || fieldMode !== "search") return
const trimmed = name.trim()
if (trimmed.length < 2) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- debounced geocode keyed on the typed name: this clears now-stale suggestions when input drops below 2 chars and bails out when already empty; onChange/pick/map handlers don't cover every write path
setSuggestions([])
return
}
diff --git a/src/components/dashboard/custom-domain-modal.tsx b/src/components/dashboard/custom-domain-modal.tsx
index 970ffe76..d8ab5649 100644
--- a/src/components/dashboard/custom-domain-modal.tsx
+++ b/src/components/dashboard/custom-domain-modal.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Globe, Copy, Check, AlertCircle, CheckCircle2 } from "lucide-react";
import { authFetch } from "@/lib/auth/fetch";
+import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { clearSessionCache } from "@/hooks/use-session";
import Button from "@/components/ui/button";
import AppDialog, { AppDialogHeader } from "@/components/ui/app-dialog";
@@ -30,7 +31,10 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain
const [step, setStep] = useState("enter-domain");
const [domain, setDomain] = useState("");
- const [copied, setCopied] = useState<"host" | "value" | null>(null);
+ // One shared-hook instance per copy target so each DNS field shows its
+ // own check mark; the hook auto-resets after 2000ms.
+ const { copied: hostCopied, copy: copyHost } = useCopyToClipboard(2000);
+ const { copied: valueCopied, copy: copyValue } = useCopyToClipboard(2000);
const [isVerifying, setIsVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(null);
const [isSuccess, setIsSuccess] = useState(false);
@@ -42,7 +46,6 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain
if (isOpen) {
setStep("enter-domain");
setDomain("");
- setCopied(null);
setIsVerifying(false);
setVerifyError(null);
setIsSuccess(false);
@@ -66,16 +69,6 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain
setStep("dns-setup");
};
- const handleCopy = async (text: string, which: "host" | "value") => {
- try {
- await navigator.clipboard.writeText(text);
- setCopied(which);
- setTimeout(() => setCopied(null), 2000);
- } catch {
- // Fallback: select text
- }
- };
-
const handleVerify = async () => {
setIsVerifying(true);
setVerifyError(null);
@@ -220,11 +213,11 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain
handleCopy(dnsHost, "host")}
+ onClick={() => void copyHost(dnsHost)}
aria-label="Copy host"
type="button"
>
- {copied === "host" ? : }
+ {hostCopied ? : }
@@ -236,11 +229,11 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain
handleCopy(dnsValue, "value")}
+ onClick={() => void copyValue(dnsValue)}
aria-label="Copy value"
type="button"
>
- {copied === "value" ? : }
+ {valueCopied ? : }
diff --git a/src/components/dev/mock-fetch-provider.tsx b/src/components/dev/mock-fetch-provider.tsx
index 6d78cef5..1ebb1629 100644
--- a/src/components/dev/mock-fetch-provider.tsx
+++ b/src/components/dev/mock-fetch-provider.tsx
@@ -12,6 +12,7 @@
* Routing:
* - `/api/auth/session` → fixture session `{ did }`
* - `/api/indexer` (POST) → dispatched by `operationName`
+ * - `/api/indexer?op=…` (GET) → same dispatch, op from the query string
* - `/api/resolve-did` (GET) → single resolved profile
* - `/api/resolve-dids` (POST) → batched resolved profiles
* - `/api/xrpc/...` → getSession / getRecord / listRecords
@@ -380,6 +381,14 @@ function installMockFetch(
return json({ ok: true })
}
if (path === "/api/indexer") {
+ // GET variant (`/api/indexer?op=` — the edge-cacheable
+ // counts): the operation rides in the query string and there
+ // is no body. Dispatch it through the same op switch as POST;
+ // the response body is identical by contract.
+ const opParam = url.searchParams.get("op")
+ if (opParam) {
+ return indexerResponse({ operationName: opParam }, { empty, managed })
+ }
let parsed: IndexerBody = {}
try {
const text =
diff --git a/src/components/endorsements/__tests__/endorsement-subject-row.test.tsx b/src/components/endorsements/__tests__/endorsement-subject-row.test.tsx
new file mode 100644
index 00000000..f77747c1
--- /dev/null
+++ b/src/components/endorsements/__tests__/endorsement-subject-row.test.tsx
@@ -0,0 +1,208 @@
+import { describe, it, expect, afterEach, vi } from "vitest"
+import { render, cleanup, fireEvent } from "@testing-library/react"
+
+import EndorsementSubjectRow, {
+ type EndorsementSubjectRowClasses,
+} from "../endorsement-subject-row"
+import type { AuthorInfo } from "@/hooks/use-author-info"
+
+// The shared subject row replaced three hand-rolled copies
+// (endorsement-row, endorsement-lists' ListItemRow, and
+// profile-endorsements' EndorsementRowBody) whose loading states,
+// identity fallbacks, and semantics had drifted apart. These
+// tests pin the unified behavior: skeleton only while info resolves,
+// deriveIdentity fallbacks (truncated DID, no @did handles), optional
+// note, full dateTime + tooltip on the date, and the trailing slot.
+
+const info: AuthorInfo = {
+ did: "did:plc:abcdefghijklmnopqrstuvwx",
+ handle: "alice.test",
+ displayName: "Alice",
+ avatarUrl: null,
+}
+
+const CLASSES: EndorsementSubjectRowClasses = {
+ main: "t-main",
+ meta: "t-meta",
+ name: "t-name",
+ handle: "t-handle",
+ note: "t-note",
+ date: "t-date",
+}
+
+const CREATED_AT = "2024-01-02T03:04:05.000Z"
+
+afterEach(() => {
+ cleanup()
+})
+
+describe("EndorsementSubjectRow", () => {
+ it("renders name, @handle and a profile link from resolved info", () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector(".t-name")?.textContent).toBe("Alice")
+ expect(container.querySelector(".t-handle")?.textContent).toBe(
+ "@alice.test",
+ )
+ const link = container.querySelector("a.t-main")
+ expect(link?.getAttribute("href")).toBe("/alice.test")
+ // Resolved info → avatar, not the loading skeleton.
+ expect(container.querySelector(".animate-pulse")).toBeNull()
+ })
+
+ it("shows the avatar skeleton only while info is loading", () => {
+ const loading = render(
+ ,
+ )
+ expect(loading.container.querySelector(".animate-pulse")).not.toBeNull()
+ // Canonical no-info fallback: truncated DID, never the raw DID.
+ expect(loading.container.querySelector(".t-name")?.textContent).toBe(
+ "did:plc:abcdefgh…stuvwx",
+ )
+ cleanup()
+
+ // Loading finished without info → still the truncated-DID row, no
+ // permanent skeleton.
+ const settled = render(
+ ,
+ )
+ expect(settled.container.querySelector(".animate-pulse")).toBeNull()
+ expect(settled.container.querySelector(".t-name")?.textContent).toBe(
+ "did:plc:abcdefgh…stuvwx",
+ )
+ })
+
+ it("treats a DID-valued handle as no handle", () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector(".t-handle")).toBeNull()
+ expect(container.querySelector(".t-name")?.textContent).toBe(
+ "did:plc:abcdefgh…stuvwx",
+ )
+ })
+
+ it("renders the note only when supplied", () => {
+ const bare = render(
+ ,
+ )
+ expect(bare.container.querySelector(".t-note")).toBeNull()
+ cleanup()
+
+ const noted = render(
+ ,
+ )
+ expect(noted.container.querySelector(".t-note")?.textContent).toBe(
+ "Great work",
+ )
+ })
+
+ it("emits a with dateTime, tooltip title, and the short date", () => {
+ const { container } = render(
+ ,
+ )
+ const time = container.querySelector("time.t-date")
+ expect(time?.getAttribute("dateTime")).toBe(CREATED_AT)
+ expect(time?.getAttribute("title")).toBe(
+ new Date(CREATED_AT).toLocaleString(),
+ )
+ expect(time?.textContent).toBe("2024-01-02")
+ // Default placement: the date is a sibling of the link, not link
+ // content.
+ expect(time?.closest("a")).toBeNull()
+ })
+
+ it("stacks the date inside the meta column with dateInMeta", () => {
+ const { container } = render(
+ ,
+ )
+ const time = container.querySelector("time.t-date")
+ expect(time?.parentElement?.classList.contains("t-meta")).toBe(true)
+ // No second copy outside the link.
+ expect(container.querySelectorAll("time.t-date")).toHaveLength(1)
+ })
+
+ it("renders the trailing slot (revoke button) and forwards clicks", () => {
+ const onRevoke = vi.fn()
+ const { getByRole } = render(
+
+ x
+
+ }
+ />,
+ )
+ const button = getByRole("button", { name: "Revoke endorsement of Alice" })
+ fireEvent.click(button)
+ expect(onRevoke).toHaveBeenCalledTimes(1)
+ })
+
+ it("renders an unlinked 'Unknown' row when the DID is null", () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector("a")).toBeNull()
+ expect(container.querySelector("div.t-main")).not.toBeNull()
+ expect(container.querySelector(".t-name")?.textContent).toBe("Unknown")
+ })
+})
diff --git a/src/components/endorsements/endorsement-row.tsx b/src/components/endorsements/endorsement-row.tsx
index 01497f1d..38a2900f 100644
--- a/src/components/endorsements/endorsement-row.tsx
+++ b/src/components/endorsements/endorsement-row.tsx
@@ -1,15 +1,13 @@
"use client"
-import Link from "next/link"
-import { profileUrl } from "@/lib/urls"
import { X } from "lucide-react"
-import Avatar from "@/components/ui/avatar"
import LoadingSpinner from "@/components/ui/loading-spinner"
-import Skeleton from "@/components/ui/skeleton"
import Tooltip from "@/components/ui/tooltip"
+import EndorsementSubjectRow, {
+ type EndorsementSubjectRowClasses,
+} from "@/components/endorsements/endorsement-subject-row"
import { useAuthorInfo } from "@/hooks/use-author-info"
-import { getInitials } from "@/lib/utils/initials"
-import { formatShortDate } from "@/lib/utils/format-date"
+import { deriveIdentity } from "@/lib/utils/identity"
interface EndorsementRowProps {
/** The DID of the endorsed account. */
@@ -27,11 +25,21 @@ interface EndorsementRowProps {
readonly isRevoking?: boolean
}
+const ROW_CLASSES: EndorsementSubjectRowClasses = {
+ main: "endorsement-row__main",
+ meta: "endorsement-row__meta",
+ name: "endorsement-row__name",
+ handle: "endorsement-row__handle",
+ note: "endorsement-row__note",
+ date: "endorsement-row__date",
+}
+
/**
* Single row in an endorsements list. Hydrates the subject DID into
* avatar + display name + handle via `useAuthorInfo` (same hook
- * powering activity card bylines), and links through to the
- * subject's profile page. Optionally shows a revoke button.
+ * powering activity card bylines) and renders the shared
+ * `EndorsementSubjectRow`, which links through to the subject's
+ * profile page. Optionally shows a revoke button.
*/
export default function EndorsementRow({
subjectDid,
@@ -41,53 +49,35 @@ export default function EndorsementRow({
isRevoking,
}: EndorsementRowProps) {
const { info, isLoading } = useAuthorInfo(subjectDid)
-
- const displayName = info?.displayName || info?.handle || subjectDid
- const handle = info?.handle && info.handle !== info.did ? info.handle : null
- const initials = getInitials(info?.displayName, subjectDid)
- const href = profileUrl(info?.handle || subjectDid)
+ // Same derivation the shared row renders, so the revoke aria-label
+ // matches the visible display name.
+ const { displayName } = deriveIdentity(info, subjectDid)
return (
-
- {isLoading && !info ? (
-
- ) : (
-
- )}
-
- {displayName}
- {handle ? (
- @{handle}
- ) : null}
- {note ? {note} : null}
-
-
-
- {formatShortDate(createdAt)}
-
- {onRevoke ? (
-
-
- {isRevoking ? : }
-
-
- ) : null}
+
+
+ {isRevoking ? : }
+
+
+ ) : null
+ }
+ />
)
}
diff --git a/src/components/endorsements/endorsement-subject-row.tsx b/src/components/endorsements/endorsement-subject-row.tsx
new file mode 100644
index 00000000..ba633e29
--- /dev/null
+++ b/src/components/endorsements/endorsement-subject-row.tsx
@@ -0,0 +1,127 @@
+"use client"
+
+import Link from "next/link"
+import Avatar from "@/components/ui/avatar"
+import Skeleton from "@/components/ui/skeleton"
+import { deriveIdentity } from "@/lib/utils/identity"
+import { formatShortDate } from "@/lib/utils/format-date"
+import type { AuthorInfo } from "@/hooks/use-author-info"
+
+/** Per-surface BEM class hooks. Each endorsement surface keeps its
+ * existing stylesheet; the shared row only owns the markup shape. */
+export interface EndorsementSubjectRowClasses {
+ /** The ` ` (or href-less ``) wrapping avatar + text. */
+ main: string
+ /** The name / handle / note column next to the avatar. */
+ meta: string
+ name: string
+ handle: string
+ note: string
+ date: string
+}
+
+export interface EndorsementSubjectRowProps {
+ /** Subject (or issuer) DID. Null renders an unlinked "Unknown" row. */
+ did: string | null
+ /** Resolved profile info. Hydration stays in the caller — the
+ * Received tab prefers indexer-embedded issuer data over a fresh
+ * PDS resolve, so the row must never fetch on its own. */
+ info: AuthorInfo | null
+ /** True while `info` is still resolving — gates the avatar skeleton. */
+ isLoading?: boolean
+ /** ISO timestamp of when the endorsement was created. */
+ createdAt: string
+ /** Optional issuer-provided note (badge.award.note). */
+ note?: string
+ /** @default "md" */
+ avatarSize?: "sm" | "md"
+ /** Card surfaces stack the date inside the meta column; list
+ * surfaces right-align it as a sibling of the link. */
+ dateInMeta?: boolean
+ classes: EndorsementSubjectRowClasses
+ /** Trailing control (revoke button / response menu), rendered last. */
+ trailing?: React.ReactNode
+}
+
+const skeletonPx = { sm: 32, md: 48 } as const
+
+/**
+ * Canonical endorsement subject row: avatar + display name + @handle +
+ * optional note + created date, linking through to the subject's
+ * profile. One markup shape for the standalone /endorsements page, the
+ * profile lists panel, and the profile endorsements list view — so the
+ * identity fallbacks (`deriveIdentity`), the avatar-skeleton gate, and
+ * the `
` semantics stay in lockstep across all three. The date
+ * renders outside the profile link (it isn't link content); callers own
+ * the surrounding `` so checkboxes / data-attributes stay local.
+ */
+export default function EndorsementSubjectRow({
+ did,
+ info,
+ isLoading = false,
+ createdAt,
+ note,
+ avatarSize = "md",
+ dateInMeta = false,
+ classes,
+ trailing,
+}: EndorsementSubjectRowProps) {
+ const identity = deriveIdentity(
+ info,
+ did ?? "",
+ did ? undefined : { fallbackLabel: "Unknown" },
+ )
+ const href = did ? identity.profileHref : null
+
+ const time = (
+
+ {formatShortDate(createdAt)}
+
+ )
+
+ const body = (
+ <>
+ {isLoading && !info ? (
+
+ ) : (
+
+ )}
+
+ {identity.displayName}
+ {identity.handle ? (
+ @{identity.handle}
+ ) : null}
+ {dateInMeta ? time : null}
+ {note ? {note} : null}
+
+ >
+ )
+
+ return (
+ <>
+ {href ? (
+
+ {body}
+
+ ) : (
+ {body}
+ )}
+ {dateInMeta ? null : time}
+ {trailing ?? null}
+ >
+ )
+}
diff --git a/src/components/endorsements/new-endorsement-panel.tsx b/src/components/endorsements/new-endorsement-panel.tsx
index 78fa3e8a..38391824 100644
--- a/src/components/endorsements/new-endorsement-panel.tsx
+++ b/src/components/endorsements/new-endorsement-panel.tsx
@@ -7,7 +7,7 @@ import Avatar from "@/components/ui/avatar"
import Button from "@/components/ui/button"
import Tooltip from "@/components/ui/tooltip"
import { useAuthorInfo } from "@/hooks/use-author-info"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
import { createEndorsementAward } from "@/lib/atproto/badges"
interface NewEndorsementPanelProps {
@@ -265,9 +265,12 @@ interface RecipientRowProps {
* badge on the right, with an X to remove if not yet submitted. */
function RecipientRow({ did, handle, status, canRemove, onRemove }: RecipientRowProps) {
const { info } = useAuthorInfo(did)
- const displayName = info?.displayName || info?.handle || handle || did
- const resolvedHandle = info?.handle && info.handle !== info.did ? info.handle : handle
- const initials = getInitials(info?.displayName, did)
+ // The caller-supplied `handle` (typed into the recipient picker)
+ // backfills the chain while the resolver hasn't caught up yet.
+ const identity = deriveIdentity(info, did, { fallbackLabel: handle })
+ const displayName = identity.displayName
+ const resolvedHandle = identity.handle ?? handle
+ const initials = identity.initials
return (
diff --git a/src/components/explore-page/__tests__/explore-search-field.test.tsx b/src/components/explore-page/__tests__/explore-search-field.test.tsx
new file mode 100644
index 00000000..057a687d
--- /dev/null
+++ b/src/components/explore-page/__tests__/explore-search-field.test.tsx
@@ -0,0 +1,137 @@
+import { describe, it, expect, vi, afterEach } from "vitest"
+import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"
+import ExploreSearchField from "../explore-search-field"
+
+// The search field owns the keystroke state so typing never re-renders
+// the explore chrome + results tree (perf finding: keystroke state was
+// hoisted into ExploreMain/ExploreAllBlocks). These tests pin the
+// debounce contract the extraction must preserve: one commit 350ms
+// after typing stops, `null` for a cleared input, external `?q=`
+// changes synced in, and the component's own committed write NOT
+// bounced back into the input.
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+describe("ExploreSearchField", () => {
+ it("commits the debounced query once typing stops", () => {
+ vi.useFakeTimers()
+ const onCommit = vi.fn()
+ render(
+ ,
+ )
+ const input = screen.getByRole("searchbox", { name: "Search…" })
+ fireEvent.change(input, { target: { value: "so" } })
+ fireEvent.change(input, { target: { value: "soil" } })
+ // Mid-debounce: nothing committed yet.
+ act(() => {
+ vi.advanceTimersByTime(200)
+ })
+ expect(onCommit).not.toHaveBeenCalled()
+ act(() => {
+ vi.advanceTimersByTime(350)
+ })
+ expect(onCommit).toHaveBeenCalledTimes(1)
+ expect(onCommit).toHaveBeenCalledWith("soil")
+ })
+
+ it("commits null when the input is cleared", () => {
+ vi.useFakeTimers()
+ const onCommit = vi.fn()
+ render(
+ ,
+ )
+ const input = screen.getByRole("searchbox", { name: "Search…" })
+ fireEvent.change(input, { target: { value: "" } })
+ act(() => {
+ vi.advanceTimersByTime(350)
+ })
+ expect(onCommit).toHaveBeenCalledWith(null)
+ })
+
+ it("syncs an external URL change into the input", () => {
+ const onCommit = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+ // e.g. back/forward or a filter switch that rewrites `?q=`.
+ rerender(
+ ,
+ )
+ const input = screen.getByRole("searchbox", {
+ name: "Search…",
+ })
+ expect(input.value).toBe("mangrove")
+ })
+
+ it("commits through the latest onCommit when the prop changes mid-debounce", () => {
+ vi.useFakeTimers()
+ const staleCommit = vi.fn()
+ const freshCommit = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+ const input = screen.getByRole("searchbox", { name: "Search…" })
+ fireEvent.change(input, { target: { value: "coral" } })
+ // A sort / filter / view click during the 350ms window re-renders
+ // the parent with a NEW onCommit whose setUrl closes over the fresh
+ // URLSearchParams. The pending timer must commit through that one —
+ // the stale closure would rebuild the URL from the pre-click
+ // snapshot and revert the click.
+ rerender(
+ ,
+ )
+ act(() => {
+ vi.advanceTimersByTime(350)
+ })
+ expect(staleCommit).not.toHaveBeenCalled()
+ expect(freshCommit).toHaveBeenCalledTimes(1)
+ expect(freshCommit).toHaveBeenCalledWith("coral")
+ })
+
+ it("does not stomp a newer keystroke when its own write echoes back", () => {
+ vi.useFakeTimers()
+ const onCommit = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+ const input = screen.getByRole("searchbox", {
+ name: "Search…",
+ })
+ fireEvent.change(input, { target: { value: "kelp" } })
+ act(() => {
+ vi.advanceTimersByTime(350)
+ })
+ expect(onCommit).toHaveBeenCalledWith("kelp")
+ // Extra keystroke lands before the URL round-trip completes…
+ fireEvent.change(input, { target: { value: "kelp f" } })
+ // …then the committed value echoes back via the `search` prop. The
+ // lastWroteToUrl guard must keep the newer keystroke on screen.
+ rerender(
+ ,
+ )
+ expect(input.value).toBe("kelp f")
+ })
+})
diff --git a/src/components/explore-page/account-list-row.tsx b/src/components/explore-page/account-list-row.tsx
index a73e650a..91862e7c 100644
--- a/src/components/explore-page/account-list-row.tsx
+++ b/src/components/explore-page/account-list-row.tsx
@@ -2,11 +2,10 @@
import { memo } from "react"
import Link from "next/link"
-import { profileUrl } from "@/lib/urls"
import { User } from "lucide-react"
import Avatar from "@/components/ui/avatar"
import { useAuthorInfo } from "@/hooks/use-author-info"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
import { formatShortDate } from "@/lib/utils/format-date"
import { truncateDid } from "@/lib/utils/did"
import type { NetworkActor } from "@/lib/atproto/workspace"
@@ -37,15 +36,13 @@ function AccountListRow({
endorsementMeta?: EndorsementClosureAccount
}) {
const { info } = useAuthorInfo(actor.did)
- const displayName =
- actor.displayName ||
- info?.displayName ||
- info?.handle ||
- truncateDid(actor.did)
- const handle = info?.handle ?? null
- const avatarUrl = actor.avatarUrl || info?.avatarUrl || null
- const initials = getInitials(displayName, handle)
- const profileHref = profileUrl(handle || actor.did)
+ // Record-level name/avatar (from the Certified actor record) outrank
+ // the resolved Bluesky profile — same data the search index returned.
+ const { displayName, handle, initials, profileHref, avatarUrl } =
+ deriveIdentity(info, actor.did, {
+ preferredName: actor.displayName,
+ preferredAvatarUrl: actor.avatarUrl,
+ })
return (
diff --git a/src/components/explore-page/cert-list-row.tsx b/src/components/explore-page/cert-list-row.tsx
index 5657b45a..bfa91e5c 100644
--- a/src/components/explore-page/cert-list-row.tsx
+++ b/src/components/explore-page/cert-list-row.tsx
@@ -8,7 +8,7 @@ import {
resolveActivityImageUrl,
} from "@/lib/atproto/activity"
import { activityDetailHref, parseActivityUri } from "@/lib/atproto/activity-uri"
-import { formatShortDate } from "@/lib/utils/format-date"
+import { formatTimePeriod } from "@/lib/utils/format-date"
import ExploreListRow from "./explore-list-row"
/**
@@ -73,16 +73,3 @@ function CertListRow({
}
export default memo(CertListRow)
-
-function formatTimePeriod(
- start: string | null,
- end: string | null,
-): string | null {
- if (!start && !end) return null
- const s = start ? formatShortDate(start) : null
- const e = end ? formatShortDate(end) : null
- if (s && e) return `${s} – ${e}`
- if (s) return `${s} (ongoing)`
- if (e) return `Until ${e}`
- return null
-}
diff --git a/src/components/explore-page/explore-project-card.tsx b/src/components/explore-page/explore-project-card.tsx
index 88246ef8..ca159d65 100644
--- a/src/components/explore-page/explore-project-card.tsx
+++ b/src/components/explore-page/explore-project-card.tsx
@@ -7,7 +7,12 @@ import { FolderGit2 } from "lucide-react"
import { resolveActivityImageUrl } from "@/lib/atproto/activity"
import { parseAtUri } from "@/lib/atproto/activity-uri"
import { formatShortDate } from "@/lib/utils/format-date"
-import type { CollectionRecord } from "@/lib/atproto/collection"
+import {
+ asString,
+ projectImage,
+ projectTitle,
+ type CollectionRecord,
+} from "@/lib/atproto/collection"
/**
* Compact project card for the /explore Projects grid. Light-weight
@@ -27,20 +32,16 @@ function ExploreProjectCard({
? recordUrl(parsed.did, "project", parsed.rkey)
: "#"
- const title =
- asString(value.title) || asString(value.name) || "Untitled project"
+ const title = projectTitle(value)
const shortDesc = asString(value.shortDescription)
const createdAt = asString(value.createdAt)
const createdLabel = createdAt ? formatShortDate(createdAt) : null
- const rawImage = (value as Record).banner ?? value.image
+ // Wide gallery card — the image-wrap is a hero slot, so this stays
+ // banner-first (an avatar is a small square, never a hero image).
+ const rawImage = projectImage(value, "banner")
const imageUrl =
- rawImage && projectDid
- ? resolveActivityImageUrl(
- rawImage as Parameters[0],
- projectDid,
- )
- : null
+ rawImage && projectDid ? resolveActivityImageUrl(rawImage, projectDid) : null
const [imageFailed, setImageFailed] = useState(false)
const showImage = !!imageUrl && !imageFailed
@@ -84,10 +85,6 @@ function ExploreProjectCard({
export default memo(ExploreProjectCard)
-function asString(v: unknown): string | null {
- return typeof v === "string" && v.length > 0 ? v : null
-}
-
function countItems(items: unknown): number {
return Array.isArray(items) ? items.length : 0
}
diff --git a/src/components/explore-page/explore-results.tsx b/src/components/explore-page/explore-results.tsx
new file mode 100644
index 00000000..4a0bfee3
--- /dev/null
+++ b/src/components/explore-page/explore-results.tsx
@@ -0,0 +1,323 @@
+"use client"
+
+import { useCallback, useMemo } from "react"
+import { FolderGit2, HandCoins, Users } from "lucide-react"
+import CertIcon from "@/components/ui/cert-icon"
+import LoadingSpinner from "@/components/ui/loading-spinner"
+import EmptyState from "@/components/ui/empty-state"
+import ActivityCard from "@/components/feed/activity-card"
+import CertListRow from "./cert-list-row"
+import ExploreUserCard from "./explore-user-card"
+import ExploreProjectCard from "./explore-project-card"
+import ProjectListRow from "./project-list-row"
+import AccountListRow from "./account-list-row"
+import FundingReceiptRow, { FundingReceiptHeader } from "./funding-receipt-row"
+import {
+ matchesConfirmedBy,
+ type ConfirmRole,
+} from "@/lib/atproto/funding-provenance"
+import {
+ EMPTY_DID_SET,
+ type Degree,
+ type ExploreKind,
+ type ListGalleryView,
+ type SortOrder,
+} from "./explore-types"
+import type { useExploreData } from "@/hooks/use-explore"
+import { useMergedFunding } from "@/hooks/use-merged-funding"
+
+/** Render whatever the data hook returned, applying client-side sort
+ * and routing through the right card. */
+export function ResultsArea({
+ kind,
+ data,
+ sort,
+ view,
+ degrees,
+ confirmRoles,
+ confirmThirdParties,
+}: {
+ kind: ExploreKind
+ data: ReturnType
+ sort: SortOrder
+ view: ListGalleryView
+ /** Non-null only when the active filter is endorsement-based.
+ * When present, rows whose author's degree isn't in the set are
+ * filtered out — the loader fetched the full closure up to
+ * `max(degrees)`, this trims the subset the user actually wants
+ * to see. */
+ degrees: Set | null
+ /** Funding only — the selected "Confirmed by" role buckets + third-party
+ * attestor DIDs. Receipts are filtered to the union; with both empty,
+ * nothing shows. */
+ confirmRoles?: ReadonlySet
+ confirmThirdParties?: ReadonlySet
+}) {
+ const closure = data.endorsementClosure
+ const degreeMatches = useCallback(
+ (did: string | null | undefined): boolean => {
+ if (!degrees || !closure) return true
+ if (!did) return false
+ const meta = closure.closureByDid.get(did)
+ if (!meta) return false
+ return degrees.has(meta.degree)
+ },
+ [degrees, closure],
+ )
+
+ // Funding "Confirmed by" filter — memoized so an unrelated re-render (a
+ // keystroke in search, a view toggle) doesn't re-run the O(n) attestation
+ // filter over the whole receipt list. Recomputes only when the loaded
+ // receipts or either selection changes.
+ // Merge optimistic confirmations + collapse matchingReceipt pairs (issue
+ // #186) before applying the "Confirmed by" filter.
+ const mergedFundingReceipts = useMergedFunding(data.fundingReceipts)
+ const filteredFundingReceipts = useMemo(
+ () =>
+ confirmRoles
+ ? mergedFundingReceipts.filter((r) =>
+ matchesConfirmedBy(
+ r.attestations,
+ confirmRoles,
+ confirmThirdParties ?? EMPTY_DID_SET,
+ ),
+ )
+ : mergedFundingReceipts,
+ [mergedFundingReceipts, confirmRoles, confirmThirdParties],
+ )
+
+ // Degree-filtered + sorted lists, memoized so a keystroke in the search
+ // box (local state on the parent) doesn't re-allocate and re-sort the
+ // whole list each render. The underlying arrays are stable references
+ // between keystrokes (they live in useExploreData's state), so these
+ // recompute only when the loaded data, the active degree set, or the
+ // sort order actually changes.
+ const sortedUsers = useMemo(() => {
+ const actors = degrees
+ ? data.users.filter((a) => degreeMatches(a.did))
+ : data.users
+ return sortUsers(actors, sort)
+ }, [data.users, degrees, degreeMatches, sort])
+ const sortedProjects = useMemo(() => {
+ const list = degrees
+ ? data.projects.filter((p) => degreeMatches(projectAuthorDid(p)))
+ : data.projects
+ return sortProjects(list, sort)
+ }, [data.projects, degrees, degreeMatches, sort])
+ const sortedCerts = useMemo(() => {
+ const list = degrees
+ ? data.certs.filter((c) => degreeMatches(data.certDids.get(c.uri) ?? null))
+ : data.certs
+ return sortCerts(list, sort)
+ }, [data.certs, data.certDids, degrees, degreeMatches, sort])
+
+ if (
+ data.isLoading &&
+ data.users.length === 0 &&
+ data.projects.length === 0 &&
+ data.certs.length === 0 &&
+ data.fundingReceipts.length === 0
+ ) {
+ return (
+
+
+
+ )
+ }
+
+ if (kind === "funding") {
+ const receipts = filteredFundingReceipts
+ if (receipts.length === 0) return
+ return (
+
+
+
+
+ {receipts.map((r) => (
+
+
+
+ ))}
+
+ )
+ }
+
+ if (kind === "accounts") {
+ const actors = sortedUsers
+ if (actors.length === 0) return
+ if (view === "list") {
+ return (
+
+ {actors.map((a) => (
+
+
+
+ ))}
+
+ )
+ }
+ return (
+
+ {actors.map((a) => (
+
+
+
+ ))}
+
+ )
+ }
+
+ if (kind === "projects") {
+ const projects = sortedProjects
+ if (projects.length === 0) return
+ if (view === "list") {
+ return (
+
+ {projects.map((p) => {
+ const authorDid = projectAuthorDid(p)
+ const meta = closure && authorDid
+ ? closure.closureByDid.get(authorDid)
+ : undefined
+ return (
+
+
+
+ )
+ })}
+
+ )
+ }
+ return (
+
+ {projects.map((p) => (
+
+
+
+ ))}
+
+ )
+ }
+
+ // certs
+ const certs = sortedCerts
+ const certDids = data.certDids
+ if (certs.length === 0) return
+
+ if (view === "list") {
+ return (
+
+ {certs.map((rec) => {
+ const did = certDids.get(rec.uri) ?? ""
+ return (
+
+
+
+ )
+ })}
+
+ )
+ }
+
+ return (
+
+ {certs.map((rec) => {
+ const did = certDids.get(rec.uri) ?? ""
+ return (
+
+
+
+ )
+ })}
+
+ )
+}
+
+function EmptyResults({ kind }: { kind: ExploreKind }) {
+ const label =
+ kind === "accounts"
+ ? "accounts"
+ : kind === "projects"
+ ? "projects"
+ : kind === "funding"
+ ? "funding receipts"
+ : "activities"
+ const icon =
+ kind === "accounts"
+ ? Users
+ : kind === "projects"
+ ? FolderGit2
+ : kind === "funding"
+ ? HandCoins
+ : CertIcon
+ return (
+
+ )
+}
+
+export function sortUsers(
+ list: T[],
+ sort: SortOrder,
+): T[] {
+ if (sort === "alphabetical") {
+ return [...list].sort((a, b) =>
+ (a.displayName ?? a.did).localeCompare(b.displayName ?? b.did),
+ )
+ }
+ // newest/oldest don't map cleanly to actors (no createdAt on profile
+ // record here); keep insertion order which is roughly recently-indexed.
+ if (sort === "oldest") return [...list].reverse()
+ return list
+}
+
+export function sortProjects<
+ T extends { value: { createdAt?: string; title?: string } },
+>(list: T[], sort: SortOrder): T[] {
+ if (sort === "alphabetical") {
+ return [...list].sort((a, b) =>
+ (a.value.title ?? "").localeCompare(b.value.title ?? ""),
+ )
+ }
+ return [...list].sort((a, b) => {
+ const ac = a.value.createdAt ?? ""
+ const bc = b.value.createdAt ?? ""
+ return sort === "oldest" ? ac.localeCompare(bc) : bc.localeCompare(ac)
+ })
+}
+
+export function sortCerts<
+ T extends { value: { createdAt?: string; title?: string } },
+>(list: T[], sort: SortOrder): T[] {
+ if (sort === "alphabetical") {
+ return [...list].sort((a, b) =>
+ (a.value.title ?? "").localeCompare(b.value.title ?? ""),
+ )
+ }
+ return [...list].sort((a, b) => {
+ const ac = a.value.createdAt ?? ""
+ const bc = b.value.createdAt ?? ""
+ return sort === "oldest" ? ac.localeCompare(bc) : bc.localeCompare(ac)
+ })
+}
+
+/**
+ * Extract the author DID from an AT-URI of the form
+ * `at:////`. Returns null on a malformed
+ * URI so callers can skip the row's endorsement decoration
+ * silently rather than crashing the render.
+ */
+function projectAuthorDid(p: { uri: string }): string | null {
+ if (!p.uri.startsWith("at://")) return null
+ const tail = p.uri.slice("at://".length)
+ const slash = tail.indexOf("/")
+ return slash >= 0 ? tail.slice(0, slash) : null
+}
diff --git a/src/components/explore-page/explore-search-field.tsx b/src/components/explore-page/explore-search-field.tsx
new file mode 100644
index 00000000..ae9efda1
--- /dev/null
+++ b/src/components/explore-page/explore-search-field.tsx
@@ -0,0 +1,84 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { TextSearch } from "lucide-react"
+import Input from "@/components/ui/input"
+
+/**
+ * The explore search input, debounced against the URL's `?q=` param.
+ * Owns the per-keystroke local state so typing re-renders only this
+ * component — the parent (ExploreMain / ExploreAllBlocks, which
+ * renders the whole chrome + results tree) re-renders once per
+ * committed URL change, not per keystroke.
+ */
+export default function ExploreSearchField({
+ search,
+ placeholder,
+ onCommit,
+}: {
+ /** The committed value from the URL (`?q=`). External changes —
+ * back/forward, a filter switch that clears `q` — sync into the
+ * input; our own debounced writes are recognised and skipped. */
+ search: string
+ placeholder: string
+ /** Commits the debounced query to the URL — receives the trimmed
+ * patch value (`null` clears the param). */
+ onCommit: (q: string | null) => void
+}) {
+ // Local search debounce: keep typing snappy, hit indexer once typing stops.
+ const [localQuery, setLocalQuery] = useState(search)
+ // Remember the value we last wrote to the URL so the URL→local
+ // sync below can tell our own debounce writes apart from external
+ // URL changes (back/forward, filter switch that clears `q`). Without
+ // this, the sync effect fires every time we write — and if the user
+ // typed an extra keystroke between scheduling the write and the URL
+ // commit, that keystroke gets stomped (it shows on screen briefly,
+ // then the URL→local sync overwrites localQuery with the older URL
+ // value). Symptom: "not all keystrokes are recognised when results
+ // come in."
+ const lastWroteToUrlRef = useRef(null)
+ useEffect(() => {
+ if (search === lastWroteToUrlRef.current) return
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional external-sync escape hatch: back/forward or a filter switch rewrites `?q=` and must overwrite the local draft; a render-time adjustment would need to read lastWroteToUrlRef during render (react-hooks/refs)
+ setLocalQuery(search)
+ }, [search])
+ // Latest-prop mirrors for the debounce timer. The timeout callback
+ // must read the CURRENT `search` / `onCommit`, not the ones captured
+ // at the last keystroke's render: the parents' onCommit closes over a
+ // URLSearchParams snapshot, so a stale one would rebuild the URL from
+ // a pre-click state and silently revert a sort / filter / view change
+ // made during the 350ms window. Updated in an effect (never during
+ // render — react-hooks/refs).
+ const searchRef = useRef(search)
+ const onCommitRef = useRef(onCommit)
+ useEffect(() => {
+ searchRef.current = search
+ onCommitRef.current = onCommit
+ }, [search, onCommit])
+ useEffect(() => {
+ // Debounce restarts on keystrokes only — the timer reads `search` /
+ // `onCommit` through the refs above, so it always sees the latest
+ // values without restarting per URL change.
+ const t = setTimeout(() => {
+ if (localQuery !== searchRef.current) {
+ lastWroteToUrlRef.current = localQuery
+ onCommitRef.current(localQuery || null)
+ }
+ }, 350)
+ return () => clearTimeout(t)
+ }, [localQuery])
+
+ return (
+
+ }
+ placeholder={placeholder}
+ value={localQuery}
+ onChange={(e) => setLocalQuery(e.target.value)}
+ aria-label={placeholder}
+ />
+
+ )
+}
diff --git a/src/components/explore-page/explore-types.ts b/src/components/explore-page/explore-types.ts
index 3cbdc3d2..5fef7437 100644
--- a/src/components/explore-page/explore-types.ts
+++ b/src/components/explore-page/explore-types.ts
@@ -8,6 +8,15 @@ export type ExploreView = ExploreKind | "all"
export type SortOrder = "newest" | "oldest" | "alphabetical"
+export type ListGalleryView = "list" | "gallery"
+
+/** Endorsement-graph ring — 1st / 2nd / 3rd degree. */
+export type Degree = 1 | 2 | 3
+
+/** Shared empty set for the funding "Confirmed by" third-party axis when
+ * none are selected (avoids re-allocating on every receipt filter pass). */
+export const EMPTY_DID_SET: ReadonlySet = new Set()
+
export interface FilterOption {
key: string
label: string
@@ -87,7 +96,7 @@ export function filtersForView(view: ExploreView): FilterOption[] {
return filtersForKind(view)
}
-export function defaultFilterForKind(kind: ExploreKind): string {
+function defaultFilterForKind(kind: ExploreKind): string {
// Funding has no curated Ma Earth front door — its only filter is the
// plain "all" listing, so default there.
if (kind === "funding") return "all"
diff --git a/src/components/explore-page/explore-user-card.tsx b/src/components/explore-page/explore-user-card.tsx
index ee016687..fbc23729 100644
--- a/src/components/explore-page/explore-user-card.tsx
+++ b/src/components/explore-page/explore-user-card.tsx
@@ -2,11 +2,10 @@
import { memo } from "react"
import Link from "next/link"
-import { profileUrl } from "@/lib/urls"
import { User } from "lucide-react"
import Avatar from "@/components/ui/avatar"
import { useAuthorInfo } from "@/hooks/use-author-info"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
import { truncateDid } from "@/lib/utils/did"
import type { NetworkActor } from "@/lib/atproto/workspace"
@@ -33,18 +32,16 @@ function ExploreUserCard({
actor: NetworkActor
}) {
const { info } = useAuthorInfo(actor.did)
- // Prefer the Certified-record display name, fall back to the
- // Bluesky profile name, fall back to the handle, fall back to a
- // truncated DID.
- const displayName =
- actor.displayName || info?.displayName || info?.handle || truncateDid(actor.did)
- const handle = info?.handle ?? null
- const avatarUrl = actor.avatarUrl || info?.avatarUrl || null
+ // Record-level name/avatar (from the Certified actor record) outrank
+ // the resolved Bluesky profile; unresolved DIDs fall back to the
+ // truncated form. The profile href prefers the handle so the URL is
+ // readable.
+ const { displayName, handle, initials, profileHref, avatarUrl } =
+ deriveIdentity(info, actor.did, {
+ preferredName: actor.displayName,
+ preferredAvatarUrl: actor.avatarUrl,
+ })
const description = actor.description ?? null
- const initials = getInitials(displayName, handle)
- // Profile route accepts either a handle or a DID in the [handle]
- // slot — prefer the handle so the URL is readable.
- const profileHref = profileUrl(handle || actor.did)
return (
diff --git a/src/components/explore-page/explore.tsx b/src/components/explore-page/explore.tsx
index d66762ca..874d887d 100644
--- a/src/components/explore-page/explore.tsx
+++ b/src/components/explore-page/explore.tsx
@@ -1,30 +1,18 @@
"use client"
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useCallback, useMemo, useState } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import {
ArrowUpDown,
ChevronDown,
ChevronRight,
- Filter as FilterIcon,
FolderGit2,
HandCoins,
LayoutGrid,
List as ListIcon,
- TextSearch,
Users,
} from "lucide-react"
-import {
- DEFAULT_HIDDEN_CERT_LABELS,
- DEFAULT_HIDDEN_ORG_LABELS,
- HYPERLABEL_DISPLAY_LABELS,
- HYPERLABEL_DISPLAY_ORDER,
- HYPERLABEL_TIERS,
- type HyperlabelTier,
-} from "@/lib/atproto/labels"
import CertIcon from "@/components/ui/cert-icon"
-import Input from "@/components/ui/input"
-import Checkbox from "@/components/ui/checkbox"
import {
Popover as UiPopover,
PopoverContent,
@@ -34,16 +22,19 @@ import {
import LoadingSpinner from "@/components/ui/loading-spinner"
import Tooltip from "@/components/ui/tooltip"
import SegmentedControl, { ToggleGroup } from "@/components/ui/segmented-control"
-import EmptyState from "@/components/ui/empty-state"
import SharedLoadMoreSentinel from "@/components/ui/load-more-sentinel"
-import ActivityCard from "@/components/feed/activity-card"
import CertListRow from "./cert-list-row"
-import ExploreUserCard from "./explore-user-card"
-import ExploreProjectCard from "./explore-project-card"
import ProjectListRow from "./project-list-row"
import AccountListRow from "./account-list-row"
import FundingReceiptRow, { FundingReceiptHeader } from "./funding-receipt-row"
import FundingConfirmedByPopover from "./funding-confirmed-by-popover"
+import ExploreSearchField from "./explore-search-field"
+import {
+ EMPTY_SELECTION_SENTINEL,
+ QualityFilterPopover,
+ useQualityFilters,
+} from "./quality-filters"
+import { ResultsArea, sortCerts, sortProjects, sortUsers } from "./explore-results"
import {
DEFAULT_CONFIRM_ROLES,
matchesConfirmedBy,
@@ -51,13 +42,16 @@ import {
} from "@/lib/atproto/funding-provenance"
import { useFundingConfirmedBy } from "@/hooks/use-funding-confirmed-by"
import {
+ EMPTY_DID_SET,
SUB_OPTIONS,
defaultFilterForView,
filtersForView,
parseSubForKind,
viewFilterToKindFilter,
+ type Degree,
type ExploreKind,
type FilterOption,
+ type ListGalleryView,
type SortOrder,
} from "./explore-types"
import { useExploreData } from "@/hooks/use-explore"
@@ -74,64 +68,17 @@ const SORT_LABEL: Record = {
alphabetical: "Alphabetical",
}
-/** Sentinel slug for the "no label yet" checkbox that sits at the
- * end of every labeler popover. Backed by an `includeLabels` /
- * `excludeLabels` swap at the loader (see comment on
- * `excludeCertLabels` / `includeCertLabels` below). */
-const UNLABELED_SLUG = "unlabeled" as const
-type UnlabeledSlug = typeof UNLABELED_SLUG
-
-const UNLABELED_LABEL = "Not labeled yet"
-
-/**
- * Orglabeler tier slugs used in the URL `?orgQuality=` param. These are
- * also the exact kebab-case label values the indexer stores (per issue
- * #145), so a slug is sent straight to the indexer as the org `labels` /
- * `excludeLabels` value — no slug↔value mapping needed. */
-const ORG_TIER_SLUGS = ["high-quality", "standard", "likely-test"] as const
-type OrgTierSlug = (typeof ORG_TIER_SLUGS)[number]
-
-const ORG_TIER_DISPLAY_LABEL: Record = {
- "high-quality": "High quality",
- standard: "Standard",
- "likely-test": "Likely test",
-}
-
-/** Default org-quality set when `?orgQuality=` is missing —
- * everything except the labels listed in DEFAULT_HIDDEN_ORG_LABELS
- * (today only "likely-test"). Matches the home feed's policy. */
-const DEFAULT_ORG_TIER_SLUGS: readonly OrgTierSlug[] = ORG_TIER_SLUGS.filter(
- (slug) => !DEFAULT_HIDDEN_ORG_LABELS.includes(slug),
-)
-
function parseSort(v: string | null): SortOrder {
if (v === "newest" || v === "oldest" || v === "alphabetical") return v
return "newest"
}
-type ListGalleryView = "list" | "gallery"
function parseView(v: string | null): ListGalleryView {
return v === "gallery" ? "gallery" : "list"
}
-type Degree = 1 | 2 | 3
-
const ALL_DEGREES: readonly Degree[] = [1, 2, 3] as const
-/**
- * Sentinel for an explicitly-empty selection. Without this, a writer
- * that puts `""` into the URL would be normalised away by setUrl
- * (which deletes empty values), and the next read would resolve to
- * the default set — making "deselect all" indistinguishable from "no
- * preference" for the user. We pick `-` because it never collides
- * with a legitimate value across degrees / quality / orgQuality.
- */
-const EMPTY_SELECTION_SENTINEL = "-"
-
-/** Shared empty set for the funding "Confirmed by" third-party axis when
- * none are selected (avoids re-allocating on every receipt filter pass). */
-const EMPTY_DID_SET: ReadonlySet = new Set()
-
/** Default funding "Confirmed by" selection — all role buckets, no third
* parties — i.e. show only receipts confirmed by the sender, recipient, or
* both. The single-kind funding view starts here (the user can change it);
@@ -209,284 +156,6 @@ function isEndorsementFilter(kind: ExploreKind, filter: string): boolean {
* via the on-page dropdown, which drives `?show=`. {@link ExploreAll}
* branches that into the three-block layout or a single-category pane.
*/
-/**
- * URL-backed quality-filter state shared by the single-kind view
- * (`ExploreMain`) and the combined All view (`ExploreAllBlocks`). Owns
- * the `?quality=` (cert / Activity Labeler tiers) and `?orgQuality=`
- * (Orglabeler tiers) params, derives the include/exclude label arrays
- * the loaders pass to the indexer, and exposes the toggle/reset
- * handlers + "is default" flags the popover renders against.
- */
-function useQualityFilters() {
- const pathname = usePathname()
- const searchParams = useSearchParams()
- const router = useRouter()
-
- const setUrl = useCallback(
- (patch: Record) => {
- const params = new URLSearchParams(searchParams?.toString() ?? "")
- for (const [k, v] of Object.entries(patch)) {
- if (v === null || v === "") params.delete(k)
- else params.set(k, v)
- }
- const qs = params.toString()
- router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
- },
- [pathname, searchParams, router],
- )
-
- // Cert quality (Activity Labeler tiers) — INCLUDED set, with the
- // synthetic `unlabeled` sentinel. Missing param = home-feed default
- // (every non-hidden tier + unlabeled).
- const qualityParam = searchParams?.get("quality")
- const qualityIncluded = useMemo>(() => {
- if (qualityParam == null) {
- return new Set([
- ...HYPERLABEL_TIERS.filter((t) => !DEFAULT_HIDDEN_CERT_LABELS.includes(t)),
- UNLABELED_SLUG,
- ])
- }
- if (qualityParam === EMPTY_SELECTION_SENTINEL) {
- return new Set()
- }
- const valid = new Set([...HYPERLABEL_TIERS, UNLABELED_SLUG])
- return new Set(
- qualityParam
- .split(",")
- .filter((v): v is HyperlabelTier | UnlabeledSlug => valid.has(v)),
- )
- }, [qualityParam])
- const certIncludeUnlabeled = qualityIncluded.has(UNLABELED_SLUG)
- const excludeCertLabels = useMemo(
- () =>
- certIncludeUnlabeled
- ? HYPERLABEL_TIERS.filter((t) => !qualityIncluded.has(t))
- : undefined,
- [qualityIncluded, certIncludeUnlabeled],
- )
- const includeCertLabels = useMemo(
- () =>
- certIncludeUnlabeled
- ? undefined
- : HYPERLABEL_TIERS.filter((t) => qualityIncluded.has(t)),
- [qualityIncluded, certIncludeUnlabeled],
- )
- const qualityIsDefault = useMemo(() => {
- const expectedSize =
- HYPERLABEL_TIERS.length - DEFAULT_HIDDEN_CERT_LABELS.length + 1
- if (qualityIncluded.size !== expectedSize) return false
- if (!qualityIncluded.has(UNLABELED_SLUG)) return false
- for (const t of HYPERLABEL_TIERS) {
- const shouldBeIncluded = !DEFAULT_HIDDEN_CERT_LABELS.includes(t)
- if (qualityIncluded.has(t) !== shouldBeIncluded) return false
- }
- return true
- }, [qualityIncluded])
-
- // Org quality (Orglabeler tiers) — same pattern.
- const orgQualityParam = searchParams?.get("orgQuality")
- const orgQualityIncluded = useMemo>(() => {
- if (orgQualityParam == null) {
- return new Set([
- ...DEFAULT_ORG_TIER_SLUGS,
- UNLABELED_SLUG,
- ])
- }
- if (orgQualityParam === EMPTY_SELECTION_SENTINEL) {
- return new Set()
- }
- const valid = new Set([...ORG_TIER_SLUGS, UNLABELED_SLUG])
- return new Set(
- orgQualityParam
- .split(",")
- .filter((v): v is OrgTierSlug | UnlabeledSlug => valid.has(v)),
- )
- }, [orgQualityParam])
- const orgIncludeUnlabeled = orgQualityIncluded.has(UNLABELED_SLUG)
- const excludeOrgLabels = useMemo(
- () =>
- orgIncludeUnlabeled
- ? ORG_TIER_SLUGS.filter((slug) => !orgQualityIncluded.has(slug))
- : undefined,
- [orgQualityIncluded, orgIncludeUnlabeled],
- )
- const includeOrgLabels = useMemo(
- () =>
- orgIncludeUnlabeled
- ? undefined
- : ORG_TIER_SLUGS.filter((slug) => orgQualityIncluded.has(slug)),
- [orgQualityIncluded, orgIncludeUnlabeled],
- )
- const orgQualityIsDefault = useMemo(() => {
- if (orgQualityIncluded.size !== DEFAULT_ORG_TIER_SLUGS.length + 1) return false
- if (!orgQualityIncluded.has(UNLABELED_SLUG)) return false
- for (const slug of ORG_TIER_SLUGS) {
- const shouldBeIncluded = DEFAULT_ORG_TIER_SLUGS.includes(slug)
- if (orgQualityIncluded.has(slug) !== shouldBeIncluded) return false
- }
- return true
- }, [orgQualityIncluded])
-
- const onResetQuality = useCallback(() => {
- setUrl({ quality: null, orgQuality: null })
- }, [setUrl])
- const onQualityToggle = useCallback(
- (slug: HyperlabelTier | UnlabeledSlug) => {
- const next = new Set(qualityIncluded)
- if (next.has(slug)) next.delete(slug)
- else next.add(slug)
- const defaultSlugs = new Set([
- ...HYPERLABEL_TIERS.filter((t) => !DEFAULT_HIDDEN_CERT_LABELS.includes(t)),
- UNLABELED_SLUG,
- ])
- const isDefault =
- next.size === defaultSlugs.size &&
- Array.from(defaultSlugs).every((s) => next.has(s))
- const ordered: (HyperlabelTier | UnlabeledSlug)[] = [
- ...HYPERLABEL_TIERS.filter((t) => next.has(t)),
- ...(next.has(UNLABELED_SLUG) ? [UNLABELED_SLUG] : []),
- ]
- const value = isDefault
- ? null
- : ordered.length === 0
- ? EMPTY_SELECTION_SENTINEL
- : ordered.join(",")
- setUrl({ quality: value })
- },
- [qualityIncluded, setUrl],
- )
- const onOrgQualityToggle = useCallback(
- (slug: OrgTierSlug | UnlabeledSlug) => {
- const next = new Set(orgQualityIncluded)
- if (next.has(slug)) next.delete(slug)
- else next.add(slug)
- const defaultSlugs = new Set([
- ...DEFAULT_ORG_TIER_SLUGS,
- UNLABELED_SLUG,
- ])
- const isDefault =
- next.size === defaultSlugs.size &&
- Array.from(defaultSlugs).every((s) => next.has(s))
- const ordered: (OrgTierSlug | UnlabeledSlug)[] = [
- ...ORG_TIER_SLUGS.filter((s) => next.has(s)),
- ...(next.has(UNLABELED_SLUG) ? [UNLABELED_SLUG] : []),
- ]
- const value = isDefault
- ? null
- : ordered.length === 0
- ? EMPTY_SELECTION_SENTINEL
- : ordered.join(",")
- setUrl({ orgQuality: value })
- },
- [orgQualityIncluded, setUrl],
- )
-
- return {
- qualityIncluded,
- orgQualityIncluded,
- excludeCertLabels,
- includeCertLabels,
- excludeOrgLabels,
- includeOrgLabels,
- qualityIsDefault,
- orgQualityIsDefault,
- onQualityToggle,
- onOrgQualityToggle,
- onResetQuality,
- }
-}
-
-type QualityFilters = ReturnType
-
-/**
- * The quality-filter popover (trigger + content). `showCertSection`
- * adds the Activity-quality (cert) section above Account quality — true
- * on the certs single-kind view and on the All view (which includes
- * activities); false on accounts/projects single-kind views, where only
- * the author-org tier applies.
- */
-function QualityFilterPopover({
- q,
- showCertSection,
- open,
- onOpenChange,
-}: {
- q: QualityFilters
- showCertSection: boolean
- open: boolean
- onOpenChange: (v: boolean) => void
-}) {
- const filtered =
- (showCertSection && !q.qualityIsDefault) || !q.orgQualityIsDefault
- return (
-
-
-
-
-
-
-
-
-
- {showCertSection ? (
- <>
- Activity quality
- {HYPERLABEL_DISPLAY_ORDER.map((tier) => (
-
- q.onQualityToggle(tier)}
- />
-
- ))}
-
- q.onQualityToggle(UNLABELED_SLUG)}
- />
-
-
- >
- ) : null}
- Account quality
- {ORG_TIER_SLUGS.map((slug) => (
-
- q.onOrgQualityToggle(slug)}
- />
-
- ))}
-
- q.onOrgQualityToggle(UNLABELED_SLUG)}
- />
-
-
-
- Reset to default
-
-
-
- )
-}
-
export default function Explore() {
// Register the page title in the top bar's title slot — mirrors the
// convention every other top-level page uses (Apps, Settings…).
@@ -587,6 +256,14 @@ function ExploreMain({
[pathname, searchParams, router],
)
+ // Declared before the callbacks below that close over the setters —
+ // the react-hooks lint rejects accessing a state variable above its
+ // declaration.
+ const [sortOpen, setSortOpen] = useState(false)
+ const [qualityOpen, setQualityOpen] = useState(false)
+ const [confirmedByOpen, setConfirmedByOpen] = useState(false)
+ const [subPrefixOpen, setSubPrefixOpen] = useState(false)
+
// Read the target's `data-*` attribute instead of capturing the
// iteration variable in a closure. The SWC minifier (Next 16's
// default prod-build pipeline) was hoisting the loop variable out of
@@ -690,37 +367,13 @@ function ExploreMain({
[setUrl],
)
- // Local search debounce: keep typing snappy, hit indexer once typing stops.
- const [localQuery, setLocalQuery] = useState(search)
- // Remember the value we last wrote to the URL so the URL→local
- // sync below can tell our own debounce writes apart from external
- // URL changes (back/forward, filter switch that clears `q`). Without
- // this, the sync effect fires every time we write — and if the user
- // typed an extra keystroke between scheduling the write and the URL
- // commit, that keystroke gets stomped (it shows on screen briefly,
- // then the URL→local sync overwrites localQuery with the older URL
- // value). Symptom: "not all keystrokes are recognised when results
- // come in."
- const lastWroteToUrlRef = useRef(null)
- useEffect(() => {
- if (search === lastWroteToUrlRef.current) return
- setLocalQuery(search)
- }, [search])
- useEffect(() => {
- const t = setTimeout(() => {
- if (localQuery !== search) {
- lastWroteToUrlRef.current = localQuery
- setUrl({ q: localQuery || null })
- }
- }, 350)
- return () => clearTimeout(t)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [localQuery])
-
- const [sortOpen, setSortOpen] = useState(false)
- const [qualityOpen, setQualityOpen] = useState(false)
- const [confirmedByOpen, setConfirmedByOpen] = useState(false)
- const [subPrefixOpen, setSubPrefixOpen] = useState(false)
+ // Debounced search — ExploreSearchField owns the keystroke state +
+ // URL sync so a keystroke re-renders only the input, not this whole
+ // chrome + results tree.
+ const onSearchCommit = useCallback(
+ (q: string | null) => setUrl({ q }),
+ [setUrl],
+ )
return (
@@ -744,19 +397,11 @@ function ExploreMain({
/>
) : null}
-
-
- }
- placeholder={searchPlaceholder(kind)}
- value={localQuery}
- onChange={(e) => setLocalQuery(e.target.value)}
- aria-label={searchPlaceholder(kind)}
- />
-
+
{mobilePillsShow !== undefined && mobilePillsSetShow ? (
(null)
- useEffect(() => {
- if (search === lastWroteToUrlRef.current) return
- setLocalQuery(search)
- }, [search])
- useEffect(() => {
- const t = setTimeout(() => {
- if (localQuery !== search) {
- lastWroteToUrlRef.current = localQuery
- setUrl({ q: localQuery || null })
- }
- }, 350)
- return () => clearTimeout(t)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [localQuery])
+ // Debounced search — ExploreSearchField owns the keystroke state so
+ // typing re-renders the input alone, not the four section blocks.
+ const onSearchCommit = useCallback(
+ (q: string | null) => setUrl({ q }),
+ [setUrl],
+ )
// Three independent loaders — one per kind. Each maps the unified
// All-view filter to that kind's concrete filter key. `sub` is pinned
@@ -1170,6 +803,9 @@ function ExploreAllBlocks() {
filter: viewFilterToKindFilter(filter, "activities"),
sub: "all",
search,
+ // 2x the block cap — a short server page still fills the block
+ // after any client-side trimming, without fetching 50 rows for 5.
+ pageSize: ALL_VIEW_BLOCK_SIZE * 2,
// Activities carry both their own cert tier and their author org's
// tier, so both quality axes apply.
excludeCertLabels: quality.excludeCertLabels,
@@ -1182,6 +818,7 @@ function ExploreAllBlocks() {
filter: viewFilterToKindFilter(filter, "projects"),
sub: "all",
search,
+ pageSize: ALL_VIEW_BLOCK_SIZE * 2,
// Projects + accounts filter by the author org's tier only.
excludeOrgLabels: quality.excludeOrgLabels,
includeOrgLabels: quality.includeOrgLabels,
@@ -1191,12 +828,16 @@ function ExploreAllBlocks() {
filter: viewFilterToKindFilter(filter, "accounts"),
sub: "all",
search,
+ pageSize: ALL_VIEW_BLOCK_SIZE * 2,
excludeOrgLabels: quality.excludeOrgLabels,
includeOrgLabels: quality.includeOrgLabels,
})
// Funding has no social-graph / featured filters and no quality axis;
// the loader lists receipts gated to an AT Protocol account on either
// side, so it always runs with the plain "all" filter.
+ // Keeps the default PAGE_SIZE window (no pageSize cap): the fixed
+ // "Confirmed by" filter below is client-side and can drop most
+ // receipts, so a trimmed page could leave the block short.
const funding = useExploreData({
kind: "funding",
filter: "all",
@@ -1252,17 +893,11 @@ function ExploreAllBlocks() {
/>
-
- }
- placeholder="Search all of Explore…"
- value={localQuery}
- onChange={(e) => setLocalQuery(e.target.value)}
- aria-label="Search all of Explore…"
- />
-
+
@@ -1833,299 +1468,3 @@ function searchPlaceholder(kind: ExploreKind): string {
if (kind === "funding") return "Funding receipts"
return "Search activities…"
}
-
-/** Render whatever the data hook returned, applying client-side sort
- * and routing through the right card. */
-function ResultsArea({
- kind,
- data,
- sort,
- view,
- degrees,
- confirmRoles,
- confirmThirdParties,
-}: {
- kind: ExploreKind
- data: ReturnType
- sort: SortOrder
- view: ListGalleryView
- /** Non-null only when the active filter is endorsement-based.
- * When present, rows whose author's degree isn't in the set are
- * filtered out — the loader fetched the full closure up to
- * `max(degrees)`, this trims the subset the user actually wants
- * to see. */
- degrees: Set | null
- /** Funding only — the selected "Confirmed by" role buckets + third-party
- * attestor DIDs. Receipts are filtered to the union; with both empty,
- * nothing shows. */
- confirmRoles?: ReadonlySet
- confirmThirdParties?: ReadonlySet
-}) {
- const closure = data.endorsementClosure
- const degreeMatches = useCallback(
- (did: string | null | undefined): boolean => {
- if (!degrees || !closure) return true
- if (!did) return false
- const meta = closure.closureByDid.get(did)
- if (!meta) return false
- return degrees.has(meta.degree)
- },
- [degrees, closure],
- )
-
- // Funding "Confirmed by" filter — memoized so an unrelated re-render (a
- // keystroke in search, a view toggle) doesn't re-run the O(n) attestation
- // filter over the whole receipt list. Recomputes only when the loaded
- // receipts or either selection changes.
- // Merge optimistic confirmations + collapse matchingReceipt pairs (issue
- // #186) before applying the "Confirmed by" filter.
- const mergedFundingReceipts = useMergedFunding(data.fundingReceipts)
- const filteredFundingReceipts = useMemo(
- () =>
- confirmRoles
- ? mergedFundingReceipts.filter((r) =>
- matchesConfirmedBy(
- r.attestations,
- confirmRoles,
- confirmThirdParties ?? EMPTY_DID_SET,
- ),
- )
- : mergedFundingReceipts,
- [mergedFundingReceipts, confirmRoles, confirmThirdParties],
- )
-
- // Degree-filtered + sorted lists, memoized so a keystroke in the search
- // box (local state on the parent) doesn't re-allocate and re-sort the
- // whole list each render. The underlying arrays are stable references
- // between keystrokes (they live in useExploreData's state), so these
- // recompute only when the loaded data, the active degree set, or the
- // sort order actually changes.
- const sortedUsers = useMemo(() => {
- const actors = degrees
- ? data.users.filter((a) => degreeMatches(a.did))
- : data.users
- return sortUsers(actors, sort)
- }, [data.users, degrees, degreeMatches, sort])
- const sortedProjects = useMemo(() => {
- const list = degrees
- ? data.projects.filter((p) => degreeMatches(projectAuthorDid(p)))
- : data.projects
- return sortProjects(list, sort)
- }, [data.projects, degrees, degreeMatches, sort])
- const sortedCerts = useMemo(() => {
- const list = degrees
- ? data.certs.filter((c) => degreeMatches(data.certDids.get(c.uri) ?? null))
- : data.certs
- return sortCerts(list, sort)
- }, [data.certs, data.certDids, degrees, degreeMatches, sort])
-
- if (
- data.isLoading &&
- data.users.length === 0 &&
- data.projects.length === 0 &&
- data.certs.length === 0 &&
- data.fundingReceipts.length === 0
- ) {
- return (
-
-
-
- )
- }
-
- if (kind === "funding") {
- const receipts = filteredFundingReceipts
- if (receipts.length === 0) return
- return (
-
-
-
-
- {receipts.map((r) => (
-
-
-
- ))}
-
- )
- }
-
- if (kind === "accounts") {
- const actors = sortedUsers
- if (actors.length === 0) return
- if (view === "list") {
- return (
-
- {actors.map((a) => (
-
-
-
- ))}
-
- )
- }
- return (
-
- {actors.map((a) => (
-
-
-
- ))}
-
- )
- }
-
- if (kind === "projects") {
- const projects = sortedProjects
- if (projects.length === 0) return
- if (view === "list") {
- return (
-
- {projects.map((p) => {
- const authorDid = projectAuthorDid(p)
- const meta = closure && authorDid
- ? closure.closureByDid.get(authorDid)
- : undefined
- return (
-
-
-
- )
- })}
-
- )
- }
- return (
-
- {projects.map((p) => (
-
-
-
- ))}
-
- )
- }
-
- // certs
- const certs = sortedCerts
- const certDids = data.certDids
- if (certs.length === 0) return
-
- if (view === "list") {
- return (
-
- {certs.map((rec) => {
- const did = certDids.get(rec.uri) ?? ""
- return (
-
-
-
- )
- })}
-
- )
- }
-
- return (
-
- {certs.map((rec) => {
- const did = certDids.get(rec.uri) ?? ""
- return (
-
-
-
- )
- })}
-
- )
-}
-
-function EmptyResults({ kind }: { kind: ExploreKind }) {
- const label =
- kind === "accounts"
- ? "accounts"
- : kind === "projects"
- ? "projects"
- : kind === "funding"
- ? "funding receipts"
- : "activities"
- const icon =
- kind === "accounts"
- ? Users
- : kind === "projects"
- ? FolderGit2
- : kind === "funding"
- ? HandCoins
- : CertIcon
- return (
-
- )
-}
-
-function sortUsers(
- list: T[],
- sort: SortOrder,
-): T[] {
- if (sort === "alphabetical") {
- return [...list].sort((a, b) =>
- (a.displayName ?? a.did).localeCompare(b.displayName ?? b.did),
- )
- }
- // newest/oldest don't map cleanly to actors (no createdAt on profile
- // record here); keep insertion order which is roughly recently-indexed.
- if (sort === "oldest") return [...list].reverse()
- return list
-}
-
-function sortProjects<
- T extends { value: { createdAt?: string; title?: string } },
->(list: T[], sort: SortOrder): T[] {
- if (sort === "alphabetical") {
- return [...list].sort((a, b) =>
- (a.value.title ?? "").localeCompare(b.value.title ?? ""),
- )
- }
- return [...list].sort((a, b) => {
- const ac = a.value.createdAt ?? ""
- const bc = b.value.createdAt ?? ""
- return sort === "oldest" ? ac.localeCompare(bc) : bc.localeCompare(ac)
- })
-}
-
-function sortCerts<
- T extends { value: { createdAt?: string; title?: string } },
->(list: T[], sort: SortOrder): T[] {
- if (sort === "alphabetical") {
- return [...list].sort((a, b) =>
- (a.value.title ?? "").localeCompare(b.value.title ?? ""),
- )
- }
- return [...list].sort((a, b) => {
- const ac = a.value.createdAt ?? ""
- const bc = b.value.createdAt ?? ""
- return sort === "oldest" ? ac.localeCompare(bc) : bc.localeCompare(ac)
- })
-}
-
-/**
- * Extract the author DID from an AT-URI of the form
- * `at:////`. Returns null on a malformed
- * URI so callers can skip the row's endorsement decoration
- * silently rather than crashing the render.
- */
-function projectAuthorDid(p: { uri: string }): string | null {
- if (!p.uri.startsWith("at://")) return null
- const tail = p.uri.slice("at://".length)
- const slash = tail.indexOf("/")
- return slash >= 0 ? tail.slice(0, slash) : null
-}
diff --git a/src/components/explore-page/project-list-row.tsx b/src/components/explore-page/project-list-row.tsx
index 4dff3a30..ed5af15c 100644
--- a/src/components/explore-page/project-list-row.tsx
+++ b/src/components/explore-page/project-list-row.tsx
@@ -7,7 +7,12 @@ import { resolveActivityImageUrl } from "@/lib/atproto/activity"
import { parseAtUri } from "@/lib/atproto/activity-uri"
import { useLocation } from "@/hooks/use-location"
import { useAuthorInfo } from "@/hooks/use-author-info"
-import type { CollectionRecord } from "@/lib/atproto/collection"
+import {
+ asString,
+ projectImage,
+ projectTitle,
+ type CollectionRecord,
+} from "@/lib/atproto/collection"
import type { EndorsementClosureAccount } from "@/lib/atproto/indexer"
import ExploreListRow from "./explore-list-row"
@@ -34,23 +39,14 @@ function ProjectListRow({
? recordUrl(parsed.did, "project", parsed.rkey)
: null
- const title =
- asString(value.title) || asString(value.name) || "Untitled project"
+ const title = projectTitle(value)
const createdAt = asString(value.createdAt)
- // Priority: avatar (the project's primary identity image) →
- // image (legacy field on older records) → banner (decorative
- // hero). Mirrors the home feed's CollectionPreview precedence so
- // the same project reads identically across surfaces.
- const v = value as Record
- const rawImage = v.avatar ?? v.image ?? v.banner
+ // Compact row thumbnail — avatar-first (`projectImage` thumb slot),
+ // so the same project reads identically across feed + list surfaces.
+ const rawImage = projectImage(value, "thumb")
const imageUrl =
- rawImage && did
- ? resolveActivityImageUrl(
- rawImage as Parameters[0],
- did,
- )
- : null
+ rawImage && did ? resolveActivityImageUrl(rawImage, did) : null
// Author byline for the mobile "by Display Name" subtitle. Desktop
// keeps the avatar byline column (rendered by ExploreListRow); this
@@ -117,10 +113,6 @@ function ProjectListRow({
export default memo(ProjectListRow)
-function asString(v: unknown): string | null {
- return typeof v === "string" && v.length > 0 ? v : null
-}
-
function countItems(items: unknown): number {
return Array.isArray(items) ? items.length : 0
}
diff --git a/src/components/explore-page/quality-filters.tsx b/src/components/explore-page/quality-filters.tsx
new file mode 100644
index 00000000..2f7cdfb7
--- /dev/null
+++ b/src/components/explore-page/quality-filters.tsx
@@ -0,0 +1,338 @@
+"use client"
+
+import { useCallback, useMemo } from "react"
+import { usePathname, useRouter, useSearchParams } from "next/navigation"
+import { Filter as FilterIcon } from "lucide-react"
+import {
+ DEFAULT_HIDDEN_CERT_LABELS,
+ DEFAULT_HIDDEN_ORG_LABELS,
+ HYPERLABEL_DISPLAY_LABELS,
+ HYPERLABEL_DISPLAY_ORDER,
+ HYPERLABEL_TIERS,
+ type HyperlabelTier,
+} from "@/lib/atproto/labels"
+import Checkbox from "@/components/ui/checkbox"
+import {
+ Popover as UiPopover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import Tooltip from "@/components/ui/tooltip"
+
+/** Sentinel slug for the "no label yet" checkbox that sits at the
+ * end of every labeler popover. Backed by an `includeLabels` /
+ * `excludeLabels` swap at the loader (see comment on
+ * `excludeCertLabels` / `includeCertLabels` below). */
+const UNLABELED_SLUG = "unlabeled" as const
+type UnlabeledSlug = typeof UNLABELED_SLUG
+
+const UNLABELED_LABEL = "Not labeled yet"
+
+/**
+ * Orglabeler tier slugs used in the URL `?orgQuality=` param. These are
+ * also the exact kebab-case label values the indexer stores (per issue
+ * #145), so a slug is sent straight to the indexer as the org `labels` /
+ * `excludeLabels` value — no slug↔value mapping needed. */
+const ORG_TIER_SLUGS = ["high-quality", "standard", "likely-test"] as const
+type OrgTierSlug = (typeof ORG_TIER_SLUGS)[number]
+
+const ORG_TIER_DISPLAY_LABEL: Record = {
+ "high-quality": "High quality",
+ standard: "Standard",
+ "likely-test": "Likely test",
+}
+
+/** Default org-quality set when `?orgQuality=` is missing —
+ * everything except the labels listed in DEFAULT_HIDDEN_ORG_LABELS
+ * (today only "likely-test"). Matches the home feed's policy. */
+const DEFAULT_ORG_TIER_SLUGS: readonly OrgTierSlug[] = ORG_TIER_SLUGS.filter(
+ (slug) => !DEFAULT_HIDDEN_ORG_LABELS.includes(slug),
+)
+
+/**
+ * Sentinel for an explicitly-empty selection. Without this, a writer
+ * that puts `""` into the URL would be normalised away by setUrl
+ * (which deletes empty values), and the next read would resolve to
+ * the default set — making "deselect all" indistinguishable from "no
+ * preference" for the user. We pick `-` because it never collides
+ * with a legitimate value across degrees / quality / orgQuality.
+ */
+export const EMPTY_SELECTION_SENTINEL = "-"
+
+/**
+ * URL-backed quality-filter state shared by the single-kind view
+ * (`ExploreMain`) and the combined All view (`ExploreAllBlocks`). Owns
+ * the `?quality=` (cert / Activity Labeler tiers) and `?orgQuality=`
+ * (Orglabeler tiers) params, derives the include/exclude label arrays
+ * the loaders pass to the indexer, and exposes the toggle/reset
+ * handlers + "is default" flags the popover renders against.
+ */
+export function useQualityFilters() {
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ const setUrl = useCallback(
+ (patch: Record) => {
+ const params = new URLSearchParams(searchParams?.toString() ?? "")
+ for (const [k, v] of Object.entries(patch)) {
+ if (v === null || v === "") params.delete(k)
+ else params.set(k, v)
+ }
+ const qs = params.toString()
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
+ },
+ [pathname, searchParams, router],
+ )
+
+ // Cert quality (Activity Labeler tiers) — INCLUDED set, with the
+ // synthetic `unlabeled` sentinel. Missing param = home-feed default
+ // (every non-hidden tier + unlabeled).
+ const qualityParam = searchParams?.get("quality")
+ const qualityIncluded = useMemo>(() => {
+ if (qualityParam == null) {
+ return new Set([
+ ...HYPERLABEL_TIERS.filter((t) => !DEFAULT_HIDDEN_CERT_LABELS.includes(t)),
+ UNLABELED_SLUG,
+ ])
+ }
+ if (qualityParam === EMPTY_SELECTION_SENTINEL) {
+ return new Set()
+ }
+ const valid = new Set([...HYPERLABEL_TIERS, UNLABELED_SLUG])
+ return new Set(
+ qualityParam
+ .split(",")
+ .filter((v): v is HyperlabelTier | UnlabeledSlug => valid.has(v)),
+ )
+ }, [qualityParam])
+ const certIncludeUnlabeled = qualityIncluded.has(UNLABELED_SLUG)
+ const excludeCertLabels = useMemo(
+ () =>
+ certIncludeUnlabeled
+ ? HYPERLABEL_TIERS.filter((t) => !qualityIncluded.has(t))
+ : undefined,
+ [qualityIncluded, certIncludeUnlabeled],
+ )
+ const includeCertLabels = useMemo(
+ () =>
+ certIncludeUnlabeled
+ ? undefined
+ : HYPERLABEL_TIERS.filter((t) => qualityIncluded.has(t)),
+ [qualityIncluded, certIncludeUnlabeled],
+ )
+ const qualityIsDefault = useMemo(() => {
+ const expectedSize =
+ HYPERLABEL_TIERS.length - DEFAULT_HIDDEN_CERT_LABELS.length + 1
+ if (qualityIncluded.size !== expectedSize) return false
+ if (!qualityIncluded.has(UNLABELED_SLUG)) return false
+ for (const t of HYPERLABEL_TIERS) {
+ const shouldBeIncluded = !DEFAULT_HIDDEN_CERT_LABELS.includes(t)
+ if (qualityIncluded.has(t) !== shouldBeIncluded) return false
+ }
+ return true
+ }, [qualityIncluded])
+
+ // Org quality (Orglabeler tiers) — same pattern.
+ const orgQualityParam = searchParams?.get("orgQuality")
+ const orgQualityIncluded = useMemo>(() => {
+ if (orgQualityParam == null) {
+ return new Set([
+ ...DEFAULT_ORG_TIER_SLUGS,
+ UNLABELED_SLUG,
+ ])
+ }
+ if (orgQualityParam === EMPTY_SELECTION_SENTINEL) {
+ return new Set()
+ }
+ const valid = new Set([...ORG_TIER_SLUGS, UNLABELED_SLUG])
+ return new Set(
+ orgQualityParam
+ .split(",")
+ .filter((v): v is OrgTierSlug | UnlabeledSlug => valid.has(v)),
+ )
+ }, [orgQualityParam])
+ const orgIncludeUnlabeled = orgQualityIncluded.has(UNLABELED_SLUG)
+ const excludeOrgLabels = useMemo(
+ () =>
+ orgIncludeUnlabeled
+ ? ORG_TIER_SLUGS.filter((slug) => !orgQualityIncluded.has(slug))
+ : undefined,
+ [orgQualityIncluded, orgIncludeUnlabeled],
+ )
+ const includeOrgLabels = useMemo(
+ () =>
+ orgIncludeUnlabeled
+ ? undefined
+ : ORG_TIER_SLUGS.filter((slug) => orgQualityIncluded.has(slug)),
+ [orgQualityIncluded, orgIncludeUnlabeled],
+ )
+ const orgQualityIsDefault = useMemo(() => {
+ if (orgQualityIncluded.size !== DEFAULT_ORG_TIER_SLUGS.length + 1) return false
+ if (!orgQualityIncluded.has(UNLABELED_SLUG)) return false
+ for (const slug of ORG_TIER_SLUGS) {
+ const shouldBeIncluded = DEFAULT_ORG_TIER_SLUGS.includes(slug)
+ if (orgQualityIncluded.has(slug) !== shouldBeIncluded) return false
+ }
+ return true
+ }, [orgQualityIncluded])
+
+ const onResetQuality = useCallback(() => {
+ setUrl({ quality: null, orgQuality: null })
+ }, [setUrl])
+ const onQualityToggle = useCallback(
+ (slug: HyperlabelTier | UnlabeledSlug) => {
+ const next = new Set(qualityIncluded)
+ if (next.has(slug)) next.delete(slug)
+ else next.add(slug)
+ const defaultSlugs = new Set([
+ ...HYPERLABEL_TIERS.filter((t) => !DEFAULT_HIDDEN_CERT_LABELS.includes(t)),
+ UNLABELED_SLUG,
+ ])
+ const isDefault =
+ next.size === defaultSlugs.size &&
+ Array.from(defaultSlugs).every((s) => next.has(s))
+ const ordered: (HyperlabelTier | UnlabeledSlug)[] = [
+ ...HYPERLABEL_TIERS.filter((t) => next.has(t)),
+ ...(next.has(UNLABELED_SLUG) ? [UNLABELED_SLUG] : []),
+ ]
+ const value = isDefault
+ ? null
+ : ordered.length === 0
+ ? EMPTY_SELECTION_SENTINEL
+ : ordered.join(",")
+ setUrl({ quality: value })
+ },
+ [qualityIncluded, setUrl],
+ )
+ const onOrgQualityToggle = useCallback(
+ (slug: OrgTierSlug | UnlabeledSlug) => {
+ const next = new Set(orgQualityIncluded)
+ if (next.has(slug)) next.delete(slug)
+ else next.add(slug)
+ const defaultSlugs = new Set([
+ ...DEFAULT_ORG_TIER_SLUGS,
+ UNLABELED_SLUG,
+ ])
+ const isDefault =
+ next.size === defaultSlugs.size &&
+ Array.from(defaultSlugs).every((s) => next.has(s))
+ const ordered: (OrgTierSlug | UnlabeledSlug)[] = [
+ ...ORG_TIER_SLUGS.filter((s) => next.has(s)),
+ ...(next.has(UNLABELED_SLUG) ? [UNLABELED_SLUG] : []),
+ ]
+ const value = isDefault
+ ? null
+ : ordered.length === 0
+ ? EMPTY_SELECTION_SENTINEL
+ : ordered.join(",")
+ setUrl({ orgQuality: value })
+ },
+ [orgQualityIncluded, setUrl],
+ )
+
+ return {
+ qualityIncluded,
+ orgQualityIncluded,
+ excludeCertLabels,
+ includeCertLabels,
+ excludeOrgLabels,
+ includeOrgLabels,
+ qualityIsDefault,
+ orgQualityIsDefault,
+ onQualityToggle,
+ onOrgQualityToggle,
+ onResetQuality,
+ }
+}
+
+export type QualityFilters = ReturnType
+
+/**
+ * The quality-filter popover (trigger + content). `showCertSection`
+ * adds the Activity-quality (cert) section above Account quality — true
+ * on the certs single-kind view and on the All view (which includes
+ * activities); false on accounts/projects single-kind views, where only
+ * the author-org tier applies.
+ */
+export function QualityFilterPopover({
+ q,
+ showCertSection,
+ open,
+ onOpenChange,
+}: {
+ q: QualityFilters
+ showCertSection: boolean
+ open: boolean
+ onOpenChange: (v: boolean) => void
+}) {
+ const filtered =
+ (showCertSection && !q.qualityIsDefault) || !q.orgQualityIsDefault
+ return (
+
+
+
+
+
+
+
+
+
+ {showCertSection ? (
+ <>
+ Activity quality
+ {HYPERLABEL_DISPLAY_ORDER.map((tier) => (
+
+ q.onQualityToggle(tier)}
+ />
+
+ ))}
+
+ q.onQualityToggle(UNLABELED_SLUG)}
+ />
+
+
+ >
+ ) : null}
+ Account quality
+ {ORG_TIER_SLUGS.map((slug) => (
+
+ q.onOrgQualityToggle(slug)}
+ />
+
+ ))}
+
+ q.onOrgQualityToggle(UNLABELED_SLUG)}
+ />
+
+
+
+ Reset to default
+
+
+
+ )
+}
diff --git a/src/components/feed/activity-author.tsx b/src/components/feed/activity-author.tsx
index aa8a4c50..6f9147cb 100644
--- a/src/components/feed/activity-author.tsx
+++ b/src/components/feed/activity-author.tsx
@@ -1,12 +1,11 @@
"use client"
import type { ReactNode } from "react"
-import { profileUrl } from "@/lib/urls"
import Link from "next/link"
import Avatar from "@/components/ui/avatar"
import Skeleton from "@/components/ui/skeleton"
import { useAuthorInfo } from "@/hooks/use-author-info"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
interface ActivityAuthorProps {
/** DID of the user who created the activity claim. */
@@ -43,9 +42,10 @@ export default function ActivityAuthor({ did, nameSuffix }: ActivityAuthorProps)
)
}
- const displayName = info.displayName || info.handle || "Anonymous"
- const initials = getInitials(info.displayName, info.handle)
- const profileHref = profileUrl(info.handle || did)
+ const { displayName, handle, initials, profileHref } = deriveIdentity(
+ info,
+ did,
+ )
return (
{displayName}
{nameSuffix}
- {info.handle ? (
- @{info.handle}
+ {handle ? (
+ @{handle}
) : null}
diff --git a/src/components/feed/activity-detail.tsx b/src/components/feed/activity-detail.tsx
index 48c1c099..420bde42 100644
--- a/src/components/feed/activity-detail.tsx
+++ b/src/components/feed/activity-detail.tsx
@@ -1,17 +1,14 @@
"use client"
import {
- memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
- type ReactNode,
} from "react"
import { profileUrl, recordUrl } from "@/lib/urls"
import { usePageTitle, usePageDesktopTitle, usePageRecordMenu } from "@/lib/navbar-context"
-import Link from "next/link"
import dynamic from "next/dynamic"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import DeleteRecordDialog from "@/components/ui/delete-record-dialog"
@@ -22,12 +19,9 @@ import {
ChevronDown,
FileText,
MapPin,
- MoreVertical,
- Pencil,
Plus,
RefreshCw,
Target,
- Trash2,
Users,
} from "lucide-react"
import CertIcon from "@/components/ui/cert-icon"
@@ -38,23 +32,15 @@ import {
resolveActivityImageUrl,
evaluateWorkScope,
} from "@/lib/atproto/activity"
-import {
- useContributorInfo,
- isAtprotoIdentity,
-} from "@/hooks/use-contributor-info"
-import { useContributorInformationRecord } from "@/hooks/use-contributor-information-record"
import { useScrollTopOnTabChange } from "@/hooks/use-scroll-top-on-tab-change"
import { useRights } from "@/hooks/use-rights"
-import { getInitials } from "@/lib/utils/initials"
import { formatShortDate } from "@/lib/utils/format-date"
-import Avatar from "@/components/ui/avatar"
import Input from "@/components/ui/input"
import LoadingSpinner from "@/components/ui/loading-spinner"
import EditBanner from "@/components/ui/edit-banner"
import Banner from "@/components/ui/banner"
import { TabPanelTransition } from "@/components/ui/tab-panel-transition"
import { CERT_DETAIL_TABS } from "@/lib/detail-tabs"
-import { useCertProjects } from "@/hooks/use-cert-projects"
import { useActivityFunding } from "@/hooks/use-activity-funding"
import { useContextUpdates } from "@/hooks/use-context-updates"
import { useMergedFunding } from "@/hooks/use-merged-funding"
@@ -68,20 +54,24 @@ import FundingReceiptFormModal from "@/components/funding/funding-receipt-form-m
import FundingIdentityChoiceDialog from "@/components/funding/funding-identity-choice-dialog"
import RightsDetailModal from "@/components/feed/rights-detail-modal"
import Button from "@/components/ui/button"
-import {
- Popover,
- PopoverTrigger,
- PopoverContent,
- PopoverItem,
-} from "@/components/ui/popover"
import { useFundingConfirmedBy } from "@/hooks/use-funding-confirmed-by"
-import { useAuthorInfo } from "@/hooks/use-author-info"
import { TransitionLink } from "@/lib/view-transitions"
import LeafletDocument, {
isRenderableDescription,
} from "@/components/leaflet/leaflet-document"
import LeafletEditor from "@/components/leaflet/leaflet-editor-dynamic"
import CertLocationsMap from "./cert-locations-map"
+import {
+ ContributorWeightHeader,
+ ContributorRow,
+ SlimTabHeadline,
+ CertHeadlineColumns,
+} from "./cert-detail-parts"
+import {
+ contributorKey,
+ contributionRoleText,
+ buildWeightPercents,
+} from "@/lib/atproto/contributor-display"
import ContextUpdates from "@/components/context/context-updates"
import {
uploadBlob,
@@ -90,14 +80,10 @@ import {
import { putCertRecord } from "@/lib/atproto/cert"
import { InvalidSwapError } from "@/lib/atproto/repo-write"
import { saveWithSwap } from "@/lib/atproto/save-with-swap"
-import { saveDraft, clearDraft } from "@/lib/utils/swap-drafts"
import { asLinearDocument } from "@/lib/leaflet/guards"
import { isEmptyLongDescription } from "@/lib/leaflet/guards"
import type { LinearDocument } from "@/lib/leaflet/types"
-import type {
- ActivityContributor as ActivityContributorType,
- ClaimActivity,
-} from "@/lib/atproto/activity-types"
+import type { ClaimActivity } from "@/lib/atproto/activity-types"
import type { HypercertsSmallImage } from "@/lib/atproto/types"
import type { BlobRef } from "@atproto/api"
import AddToListMenu from "@/components/lists/add-to-list-menu"
@@ -125,71 +111,11 @@ interface ActivityDetailProps {
handle: string | null
}
-/**
- * Stable React key for a contributor row. Contributors carry no id of
- * their own, so we use the strong-ref URI / inline identity plus the
- * position to disambiguate duplicates — avoids the `key={i}` antipattern.
- */
-function contributorKey(c: ActivityContributorType, index: number): string {
- const id = c.contributorIdentity as unknown
- if (id && typeof id === "object") {
- const obj = id as Record
- if (typeof obj.uri === "string") return `${obj.uri}#${index}`
- if (typeof obj.identity === "string") return `${obj.identity}#${index}`
- }
- if (typeof id === "string") return `${id}#${index}`
- return `contributor-${index}`
-}
-
-/**
- * Extract role text defensively. The lexicon types this as an object
- * but some records store it as a bare string. `"role" in details`
- * throws when `details` is a primitive, so we type-check at runtime.
- */
-function contributionRoleText(details: unknown): string | null {
- if (typeof details === "string") return details
- if (!details || typeof details !== "object") return null
- const obj = details as Record
- return typeof obj.role === "string" ? obj.role : null
-}
-
// Single date format used throughout this view: "Mon D, YYYY".
// Identical output to lib/utils/format-date.ts#formatShortDate, which
// also handles invalid input by returning the raw string.
const formatDate = formatShortDate
-/**
- * Normalise contributor weights to a percent out of 100. The
- * lexicon stores `contributionWeight` as a free-form string so a
- * record can hold values like "1", "0.25", or "high". This helper
- * sums every parseable numeric weight and rewrites each as
- * `round(weight / total * 100)`, returning a map from contributor
- * index to display string. Non-numeric weights are left out of the
- * map; the caller falls back to the raw value so they still
- * render. When no weights parse (or the sum is zero) the returned
- * map is empty — every row falls back to its raw weight.
- */
-function buildWeightPercents(
- contribs: readonly ActivityContributorType[],
-): Map {
- const out = new Map()
- const parsed: Array<{ idx: number; n: number }> = []
- let total = 0
- contribs.forEach((c, idx) => {
- const raw = c.contributionWeight?.trim() ?? ""
- if (!raw) return
- const n = parseFloat(raw)
- if (!Number.isFinite(n) || n < 0) return
- parsed.push({ idx, n })
- total += n
- })
- if (total <= 0) return out
- for (const { idx, n } of parsed) {
- out.set(idx, `${Math.round((n / total) * 100)}`)
- }
- return out
-}
-
/**
* Detail view of a single activity claim.
*
@@ -265,12 +191,25 @@ export default function ActivityDetail({
const [descriptionExpanded, setDescriptionExpanded] = useState(false)
// ClaimActivity doesn't carry its own rkey. The page route at
- // /activity/[did]/[rkey] does, and we want to pass it to the
+ // /[actor]/[type]/[rkey] does, and we want to pass it to the
// Projects section. Rather than threading another prop from the
// page (the page file is carved out beyond the breadcrumb wiring),
- // we read the last pathname segment client-side — same value the
- // page already decoded via `useParams`.
- const rkey = useRouteRkey()
+ // we derive it from the trailing pathname segment — same value the
+ // page already decoded via `useParams`. Derived during render (not
+ // in a mount effect) so rkey is available on the first commit: the
+ // funding/updates/projects fetches and the record-menu publication
+ // start immediately instead of after a guaranteed second render.
+ const pathname = usePathname()
+ const rkey = useMemo(() => {
+ const segments = (pathname ?? "").split("/").filter(Boolean)
+ const last = segments[segments.length - 1]
+ if (!last) return null
+ try {
+ return decodeURIComponent(last)
+ } catch {
+ return last
+ }
+ }, [pathname])
const { name: rightsName, isLoading: rightsLoading } = useRights(
value.rights?.uri ?? null,
@@ -406,8 +345,8 @@ export default function ActivityDetail({
// Tab strip on the top bar (back-row) drives which slice of the
// record renders in the right pane. Keep the left aside identical
- // across all tabs.
- const pathname = usePathname()
+ // across all tabs. (`pathname` is read above, where rkey derives
+ // from it.)
const searchParams = useSearchParams()
const tabParam = searchParams?.get("tab") ?? "overview"
const activeTab:
@@ -958,24 +897,18 @@ export default function ActivityDetail({
})
if (!result.ok) {
- saveDraft(sessionDid, "org.hypercerts.claim.activity", rkey, {
- title: trimmedTitle,
- shortDescription: trimmedShort,
- description: drafts.description,
- })
if (result.reason === "conflict") {
setSaveError(
- `Someone else saved while you were editing — conflicts on ${result.conflictingFields.join(", ")}. Your draft is saved locally; refresh and re-apply.`,
+ `Someone else saved while you were editing — conflicts on ${result.conflictingFields.join(", ")}. Refresh to see the latest version and re-apply your changes.`,
)
} else {
setSaveError(
- "Couldn't auto-merge after several retries — your draft is saved locally; refresh to see the latest version.",
+ "Couldn't auto-merge after several retries — refresh to see the latest version and try again.",
)
}
return
}
- clearDraft(sessionDid, "org.hypercerts.claim.activity", rkey)
if (nextSaved) setLocalValue(nextSaved)
if (pendingImagePreviewUrl) {
setLocalImageUrl((prev) => {
@@ -1817,491 +1750,3 @@ export default function ActivityDetail({
>
)
}
-
-/**
- * Read the trailing rkey segment off the current URL. The cert detail
- * page sits at `/activity/[did]/[rkey]`, so we slice the last
- * pathname segment — decoded so it matches what the page already
- * normalised through `decodeURIComponent`. Returns null until the
- * window object is available (SSR pass).
- */
-function useRouteRkey(): string | null {
- const [rkey, setRkey] = useState(null)
- useEffect(() => {
- if (typeof window === "undefined") return
- const segments = window.location.pathname.split("/").filter(Boolean)
- const last = segments[segments.length - 1]
- if (!last) {
- setRkey(null)
- return
- }
- try {
- setRkey(decodeURIComponent(last))
- } catch {
- setRkey(last)
- }
- }, [])
- return rkey
-}
-
-/**
- * Right-aligned `%` column heading rendered above a contributors
- * list when at least one row carries a `contributionWeight`. The
- * pill-shaped weight chips below align to the row's right edge, so
- * the `%` sits over that column to label what the numbers mean.
- * Hovering surfaces the full sentence via a native browser tooltip
- * (`title`); the `aria-label` mirrors the same text for AT.
- */
-function ContributorWeightHeader() {
- return (
-
- %
-
- )
-}
-
-/* ---------- Contributor row ----------
- *
- * Compact row for the cert detail contributors grid. Resolves the
- * contributor identity the same way `ActivityContributor` does — see
- * `useContributorInfo` / `useContributorInformationRecord` — but renders with
- * the `cert-detail__contributor-*` class set so it inherits the new
- * pill-hover styling rather than the older `activity-detail__contributor-*`
- * rules in feed.css.
- */
-
-interface ContributorRowProps {
- readonly contributor: ActivityContributorType
- readonly role: string | null
- readonly weight: string | null
-}
-
-function classifyContributorIdentity(id: unknown): {
- inlineIdentity: string | null
- strongRefUri: string | null
-} {
- if (id == null) return { inlineIdentity: null, strongRefUri: null }
- if (typeof id === "string") {
- return { inlineIdentity: id, strongRefUri: null }
- }
- if (typeof id !== "object") {
- return { inlineIdentity: null, strongRefUri: null }
- }
- const obj = id as Record
- if (typeof obj.identity === "string") {
- return { inlineIdentity: obj.identity, strongRefUri: null }
- }
- if (typeof obj.uri === "string" && obj.uri.startsWith("at://")) {
- return { inlineIdentity: null, strongRefUri: obj.uri }
- }
- return { inlineIdentity: null, strongRefUri: null }
-}
-
-const ContributorRow = memo(function ContributorRow({
- contributor,
- role,
- weight,
-}: ContributorRowProps) {
- const { inlineIdentity, strongRefUri } = classifyContributorIdentity(
- contributor.contributorIdentity,
- )
-
- const { record: contribInfo, isLoading: contribInfoLoading } =
- useContributorInformationRecord(strongRefUri)
-
- const atprotoCandidate =
- inlineIdentity ??
- (contribInfo?.identifier && isAtprotoIdentity(contribInfo.identifier)
- ? contribInfo.identifier
- : null)
-
- const { info, isLoading: atprotoLoading } =
- useContributorInfo(atprotoCandidate)
-
- const isLoading = contribInfoLoading || atprotoLoading
-
- const fallbackLabel = strongRefUri ? "Unknown contributor" : "Anonymous"
- const displayName =
- info?.displayName ||
- contribInfo?.displayName ||
- (inlineIdentity && !isAtprotoIdentity(inlineIdentity)
- ? inlineIdentity
- : null) ||
- fallbackLabel
-
- const handle = info?.handle && info.handle !== info.did ? info.handle : null
- const avatarUrl = info?.avatarUrl || contribInfo?.image?.uri || null
- const profileHref = info?.did
- ? profileUrl(info.handle || info.did)
- : null
- const initials = getInitials(
- info?.displayName || contribInfo?.displayName || null,
- handle,
- )
-
- const hasAnyHydratedField =
- !!info?.did ||
- !!contribInfo?.displayName ||
- !!contribInfo?.image?.uri ||
- !!inlineIdentity
-
- if (isLoading && !hasAnyHydratedField) {
- return (
-
-
-
- {weight ? (
- {weight}
- ) : null}
-
- )
- }
-
- const body = (
- <>
-
-
-
- {displayName}
- {role ? (
- · {role}
- ) : null}
-
- {handle ? (
- @{handle}
- ) : null}
-
- >
- )
-
- return (
-
- {profileHref ? (
-
- {body}
-
- ) : (
-
- {body}
-
- )}
- {weight ? (
- {weight}
- ) : null}
-
- )
-})
-
-/**
- * Slim headline used by every tab except Overview (Description /
- * Contributors / Funding / Updates): the activity title with the author
- * pulled onto the same row (right-aligned, no "Author" label) and the owner
- * actions (Edit / Delete) collapsed into a three-dot menu. No date-created /
- * project byline — that detail lives on the Overview tab's full headline.
- */
-function SlimTabHeadline({
- did,
- title,
- isCreator,
- editHref,
- editAsGroupLabel,
- onEditAsGroup,
- onDelete,
-}: {
- did: string
- title: string
- isCreator: boolean
- editHref: string
- /** Display label of the group the viewer may edit as, or null. */
- editAsGroupLabel: string | null
- onEditAsGroup: () => void
- onDelete: () => void
-}) {
- const router = useRouter()
- const { info, isLoading: authorLoading } = useAuthorInfo(did)
- const showMenu = isCreator || !!editAsGroupLabel
-
- const displayName = info?.displayName || info?.handle || "Anonymous"
- const profileHref = profileUrl(info?.handle || did)
-
- return (
-
- )
-}
-
-/**
- * Three-column byline below the cert title — invisible grid (no
- * borders, no card chrome) with three small labelled cells:
- *
- * Date created · Author · Project
- *
- * Each cell carries the same `cert-detail__meta-label` styling used
- * in the aside meta list so the three blocks read as a peer of the
- * Work scope / Locations / Rights metadata that lives on the right.
- *
- * "Project" surfaces the first project that contains this cert
- * (via the existing `useCertProjects` hook, same data source as the
- * main-pane Projects section below — module-cached so the lookup
- * doesn't double-fire). When the cert isn't in any project the
- * column renders a quiet em-dash so the three columns stay aligned.
- *
- * Below ~640px the grid collapses to a single-column stack — the
- * column track widths can't shrink further without truncating the
- * author handle or the project title past readability.
- */
-function CertHeadlineColumns({
- did,
- rkey,
- createdAt,
- formattedDate,
- action,
-}: {
- did: string
- rkey: string | null
- createdAt: string
- formattedDate: string
- /** Trailing control (the three-dot menu) shown on the author's row,
- * right-aligned, on mobile. */
- action?: ReactNode
-}) {
- const { info, isLoading: authorLoading } = useAuthorInfo(did)
- const { projects } = useCertProjects(did, rkey)
-
- return (
-
-
-
Author
- {authorLoading || !info ? (
-
- ) : (
- (() => {
- const displayName = info.displayName || info.handle || "Anonymous"
- const initials = getInitials(info.displayName, info.handle)
- const profileHref = profileUrl(info.handle || did)
- return (
-
-
-
-
- {displayName}
-
- {info.handle ? (
-
- @{info.handle}
-
- ) : null}
-
-
- )
- })()
- )}
-
-
- {action ? (
-
{action}
- ) : null}
-
-
- Date created
-
- {formattedDate}
-
-
-
-
-
Project
- {projects.length === 0 ? (
-
- —
-
- ) : (
- (() => {
- // First-project preview — same scope-rule the Projects
- // section in the main pane uses (single primary
- // association for the heads-up byline). A "+N more"
- // count surfaces when the cert belongs to additional
- // projects so the reader knows to scroll down to the
- // full list.
- const first = projects[0]
- const remaining = projects.length - 1
- const firstParts = first.uri.match(
- /^at:\/\/([^/]+)\/[^/]+\/(.+)$/,
- )
- const firstHref = firstParts
- ? recordUrl(firstParts[1], "project", firstParts[2])
- : null
- const v = first.value as Record
- const title =
- (typeof v.title === "string" && v.title.length > 0
- ? v.title
- : null) ||
- (typeof v.name === "string" && v.name.length > 0
- ? v.name
- : null) ||
- "Untitled project"
- // Image precedence mirrors the home-feed CollectionPreview
- // and explore-page ProjectListRow: avatar (primary
- // identity image) → image (legacy field) → banner
- // (decorative). Resolved against the project's own DID
- // so foreign-PDS blobs come through the xrpc proxy.
- const projectDid = firstParts ? firstParts[1] : ""
- const rawImage = v.avatar ?? v.image ?? v.banner
- const imageUrl =
- rawImage && projectDid
- ? resolveActivityImageUrl(
- rawImage as Parameters[0],
- projectDid,
- )
- : null
- const thumb = (
-
- {imageUrl ? (
- /* eslint-disable-next-line @next/next/no-img-element */
-
- ) : null}
-
- )
- const innerBody = (
- <>
- {thumb}
-
- {title}
-
- >
- )
- const label = firstHref ? (
-
- {innerBody}
-
- ) : (
-
- {innerBody}
-
- )
- return (
-
- {label}
- {remaining > 0 ? (
- +{remaining}
- ) : null}
-
- )
- })()
- )}
-
-
- )
-}
-
diff --git a/src/components/feed/activity-edit-route.tsx b/src/components/feed/activity-edit-route.tsx
index af39bdab..c770c308 100644
--- a/src/components/feed/activity-edit-route.tsx
+++ b/src/components/feed/activity-edit-route.tsx
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
-import { recordUrl } from "@/lib/urls"
+import { parseAtUri, recordUrl, rkeyFromUri } from "@/lib/urls"
import { useRouter } from "next/navigation"
import {
Calendar,
@@ -157,14 +157,6 @@ interface ResolvedLocationRow {
name: string
}
-const AT_URI_RE = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/
-
-function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null {
- const m = AT_URI_RE.exec(uri)
- if (!m) return null
- return { did: m[1], collection: m[2], rkey: m[3] }
-}
-
/**
* `/{actor}/activity/{rkey}/edit` — full-page cert editor. `actor` is
* resolved to a DID by the parent route; this component takes the resolved
@@ -247,9 +239,9 @@ export default function ActivityEditRoute({
// Rights options — same listRecords call /create uses.
// -------------------------------------------------------------------
useEffect(() => {
+ // No sync resets here: this []-dep effect runs once right after
+ // mount and the useState initializers are already true/null.
const controller = new AbortController()
- setRightsLoading(true)
- setRightsLoadError(null)
const qs = new URLSearchParams({
repo: RIGHTS_PUBLISHER_DID,
collection: RIGHTS_COLLECTION,
@@ -272,7 +264,7 @@ export default function ActivityEditRoute({
typeof rec.value?.rightsName === "string"
? rec.value.rightsName.trim()
: ""
- const fallback = rec.uri.split("/").pop() ?? "(unnamed rights)"
+ const fallback = rkeyFromUri(rec.uri) || "(unnamed rights)"
return {
ref: { uri: rec.uri, cid: rec.cid },
name: rawName || fallback,
@@ -303,6 +295,7 @@ export default function ActivityEditRoute({
if (seededRef.current) return
if (!activity) return
const v = activity.value
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot ref-guarded (seededRef) seeding of editable form state + swap-record cid baseline after the async record load; re-running would clobber user edits. Long-term fix is a key-remount form child, tracked separately
setTitle(v.title ?? "")
setShortDescription(v.shortDescription ?? "")
setStartDate(
@@ -344,13 +337,23 @@ export default function ActivityEditRoute({
// location record itself (one getRecord per strongRef) — we use the
// same shape `LocationPickerDialog` emits so the row UI is uniform.
// -------------------------------------------------------------------
+ // The refs-empty reset is adjusted during render when the record
+ // identity changes (keyed on uri|cid, not object identity, which
+ // isn't render-stable); initial state is already []. Only the async
+ // name hydration lives in the effect.
+ const activityKey = activity ? `${activity.uri}|${activity.cid}` : null
+ const [prevActivityKey, setPrevActivityKey] = useState(activityKey)
+ if (prevActivityKey !== activityKey) {
+ setPrevActivityKey(activityKey)
+ if (activity && (activity.value.locations ?? []).length === 0) {
+ setLocations([])
+ }
+ }
+
useEffect(() => {
if (!activity) return
const refs = activity.value.locations ?? []
- if (refs.length === 0) {
- setLocations([])
- return
- }
+ if (refs.length === 0) return
let aborted = false
Promise.all(
refs.map(async (ref): Promise => {
@@ -365,15 +368,15 @@ export default function ActivityEditRoute({
const res = await authFetch(
`/api/xrpc/com/atproto/repo/getRecord?${qs.toString()}`,
)
- if (!res.ok) return { ref, name: ref.uri.split("/").pop() ?? ref.uri }
+ if (!res.ok) return { ref, name: rkeyFromUri(ref.uri) || ref.uri }
const data = (await res.json()) as { value?: { name?: string } }
const raw = data.value?.name?.trim() ?? ""
const split = splitLocationName(raw)
const name =
- split.name || raw || ref.uri.split("/").pop() || "Location"
+ split.name || raw || rkeyFromUri(ref.uri) || "Location"
return { ref, name }
} catch {
- return { ref, name: ref.uri.split("/").pop() ?? ref.uri }
+ return { ref, name: rkeyFromUri(ref.uri) || ref.uri }
}
}),
).then((rows) => {
@@ -401,6 +404,7 @@ export default function ActivityEditRoute({
// Clear save error whenever any field changes.
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- deliberate cross-field watcher clearing the save error on any edit; setError(null) bails out (no render) whenever error is already null, so cost is one render only while an error is showing
setError(null)
}, [
title,
diff --git a/src/components/feed/cert-detail-parts.tsx b/src/components/feed/cert-detail-parts.tsx
new file mode 100644
index 00000000..6b45d163
--- /dev/null
+++ b/src/components/feed/cert-detail-parts.tsx
@@ -0,0 +1,482 @@
+"use client"
+
+/**
+ * Presentational parts of the cert detail page, extracted from
+ * `activity-detail.tsx`: the contributor list pieces plus the two
+ * headline variants. None of them share state with `ActivityDetail`
+ * — everything crosses the seam through enumerable props.
+ */
+
+import { memo, type ReactNode } from "react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { MoreVertical, Pencil, Trash2 } from "lucide-react"
+import { parseAtUri, profileUrl, recordUrl } from "@/lib/urls"
+import { resolveActivityImageUrl } from "@/lib/atproto/activity"
+import { projectImage, projectTitle } from "@/lib/atproto/collection"
+import {
+ useContributorInfo,
+ isAtprotoIdentity,
+} from "@/hooks/use-contributor-info"
+import { useContributorInformationRecord } from "@/hooks/use-contributor-information-record"
+import { useAuthorInfo } from "@/hooks/use-author-info"
+import { useCertProjects } from "@/hooks/use-cert-projects"
+import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
+import Avatar from "@/components/ui/avatar"
+import Button from "@/components/ui/button"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+ PopoverItem,
+} from "@/components/ui/popover"
+import type { ActivityContributor as ActivityContributorType } from "@/lib/atproto/activity-types"
+
+/**
+ * Right-aligned `%` column heading rendered above a contributors
+ * list when at least one row carries a `contributionWeight`. The
+ * pill-shaped weight chips below align to the row's right edge, so
+ * the `%` sits over that column to label what the numbers mean.
+ * Hovering surfaces the full sentence via a native browser tooltip
+ * (`title`); the `aria-label` mirrors the same text for AT.
+ */
+export function ContributorWeightHeader() {
+ return (
+
+ %
+
+ )
+}
+
+/* ---------- Contributor row ----------
+ *
+ * Compact row for the cert detail contributors grid. Resolves the
+ * contributor identity the same way `ActivityContributor` does — see
+ * `useContributorInfo` / `useContributorInformationRecord` — but renders with
+ * the `cert-detail__contributor-*` class set so it inherits the new
+ * pill-hover styling rather than the older `activity-detail__contributor-*`
+ * rules in feed.css.
+ */
+
+interface ContributorRowProps {
+ readonly contributor: ActivityContributorType
+ readonly role: string | null
+ readonly weight: string | null
+}
+
+function classifyContributorIdentity(id: unknown): {
+ inlineIdentity: string | null
+ strongRefUri: string | null
+} {
+ if (id == null) return { inlineIdentity: null, strongRefUri: null }
+ if (typeof id === "string") {
+ return { inlineIdentity: id, strongRefUri: null }
+ }
+ if (typeof id !== "object") {
+ return { inlineIdentity: null, strongRefUri: null }
+ }
+ const obj = id as Record
+ if (typeof obj.identity === "string") {
+ return { inlineIdentity: obj.identity, strongRefUri: null }
+ }
+ if (typeof obj.uri === "string" && obj.uri.startsWith("at://")) {
+ return { inlineIdentity: null, strongRefUri: obj.uri }
+ }
+ return { inlineIdentity: null, strongRefUri: null }
+}
+
+export const ContributorRow = memo(function ContributorRow({
+ contributor,
+ role,
+ weight,
+}: ContributorRowProps) {
+ const { inlineIdentity, strongRefUri } = classifyContributorIdentity(
+ contributor.contributorIdentity,
+ )
+
+ const { record: contribInfo, isLoading: contribInfoLoading } =
+ useContributorInformationRecord(strongRefUri)
+
+ const atprotoCandidate =
+ inlineIdentity ??
+ (contribInfo?.identifier && isAtprotoIdentity(contribInfo.identifier)
+ ? contribInfo.identifier
+ : null)
+
+ const { info, isLoading: atprotoLoading } =
+ useContributorInfo(atprotoCandidate)
+
+ const isLoading = contribInfoLoading || atprotoLoading
+
+ const fallbackLabel = strongRefUri ? "Unknown contributor" : "Anonymous"
+ const displayName =
+ info?.displayName ||
+ contribInfo?.displayName ||
+ (inlineIdentity && !isAtprotoIdentity(inlineIdentity)
+ ? inlineIdentity
+ : null) ||
+ fallbackLabel
+
+ const handle = info?.handle && info.handle !== info.did ? info.handle : null
+ const avatarUrl = info?.avatarUrl || contribInfo?.image?.uri || null
+ const profileHref = info?.did
+ ? profileUrl(info.handle || info.did)
+ : null
+ const initials = getInitials(
+ info?.displayName || contribInfo?.displayName || null,
+ handle,
+ )
+
+ const hasAnyHydratedField =
+ !!info?.did ||
+ !!contribInfo?.displayName ||
+ !!contribInfo?.image?.uri ||
+ !!inlineIdentity
+
+ if (isLoading && !hasAnyHydratedField) {
+ return (
+
+
+
+ {weight ? (
+ {weight}
+ ) : null}
+
+ )
+ }
+
+ const body = (
+ <>
+
+
+
+ {displayName}
+ {role ? (
+ · {role}
+ ) : null}
+
+ {handle ? (
+ @{handle}
+ ) : null}
+
+ >
+ )
+
+ return (
+
+ {profileHref ? (
+
+ {body}
+
+ ) : (
+
+ {body}
+
+ )}
+ {weight ? (
+ {weight}
+ ) : null}
+
+ )
+})
+
+/**
+ * Slim headline used by every tab except Overview (Description /
+ * Contributors / Funding / Updates): the activity title with the author
+ * pulled onto the same row (right-aligned, no "Author" label) and the owner
+ * actions (Edit / Delete) collapsed into a three-dot menu. No date-created /
+ * project byline — that detail lives on the Overview tab's full headline.
+ */
+export function SlimTabHeadline({
+ did,
+ title,
+ isCreator,
+ editHref,
+ editAsGroupLabel,
+ onEditAsGroup,
+ onDelete,
+}: {
+ did: string
+ title: string
+ isCreator: boolean
+ editHref: string
+ /** Display label of the group the viewer may edit as, or null. */
+ editAsGroupLabel: string | null
+ onEditAsGroup: () => void
+ onDelete: () => void
+}) {
+ const router = useRouter()
+ const { info, isLoading: authorLoading } = useAuthorInfo(did)
+ const showMenu = isCreator || !!editAsGroupLabel
+
+ const { displayName, handle, initials, profileHref } = deriveIdentity(
+ info,
+ did,
+ )
+
+ return (
+
+ )
+}
+
+/**
+ * Three-column byline below the cert title — invisible grid (no
+ * borders, no card chrome) with three small labelled cells:
+ *
+ * Date created · Author · Project
+ *
+ * Each cell carries the same `cert-detail__meta-label` styling used
+ * in the aside meta list so the three blocks read as a peer of the
+ * Work scope / Locations / Rights metadata that lives on the right.
+ *
+ * "Project" surfaces the first project that contains this cert
+ * (via the existing `useCertProjects` hook, same data source as the
+ * main-pane Projects section below — module-cached so the lookup
+ * doesn't double-fire). When the cert isn't in any project the
+ * column renders a quiet em-dash so the three columns stay aligned.
+ *
+ * Below ~640px the grid collapses to a single-column stack — the
+ * column track widths can't shrink further without truncating the
+ * author handle or the project title past readability.
+ */
+export function CertHeadlineColumns({
+ did,
+ rkey,
+ createdAt,
+ formattedDate,
+ action,
+}: {
+ did: string
+ rkey: string | null
+ createdAt: string
+ formattedDate: string
+ /** Trailing control (the three-dot menu) shown on the author's row,
+ * right-aligned, on mobile. */
+ action?: ReactNode
+}) {
+ const { info, isLoading: authorLoading } = useAuthorInfo(did)
+ const { projects } = useCertProjects(did, rkey)
+
+ return (
+
+
+
Author
+ {authorLoading || !info ? (
+
+ ) : (
+ (() => {
+ const { displayName, handle, initials, profileHref } =
+ deriveIdentity(info, did)
+ return (
+
+
+
+
+ {displayName}
+
+ {handle ? (
+
+ @{handle}
+
+ ) : null}
+
+
+ )
+ })()
+ )}
+
+
+ {action ? (
+
{action}
+ ) : null}
+
+
+ Date created
+
+ {formattedDate}
+
+
+
+
+
Project
+ {projects.length === 0 ? (
+
+ —
+
+ ) : (
+ (() => {
+ // First-project preview — same scope-rule the Projects
+ // section in the main pane uses (single primary
+ // association for the heads-up byline). A "+N more"
+ // count surfaces when the cert belongs to additional
+ // projects so the reader knows to scroll down to the
+ // full list.
+ const first = projects[0]
+ const remaining = projects.length - 1
+ const firstParts = parseAtUri(first.uri)
+ const firstHref = firstParts
+ ? recordUrl(firstParts.did, "project", firstParts.rkey)
+ : null
+ const title = projectTitle(first.value)
+ // Compact byline thumb — avatar-first (`projectImage`
+ // thumb slot), same read as the home-feed and explore
+ // rows. Resolved against the project's own DID so
+ // foreign-PDS blobs come through the xrpc proxy.
+ const projectDid = firstParts?.did ?? ""
+ const rawImage = projectImage(first.value, "thumb")
+ const imageUrl =
+ rawImage && projectDid
+ ? resolveActivityImageUrl(rawImage, projectDid)
+ : null
+ const thumb = (
+
+ {imageUrl ? (
+ /* eslint-disable-next-line @next/next/no-img-element -- dynamic bsky-CDN/blob URL; next/image remotePatterns limited to **.certified.app */
+
+ ) : null}
+
+ )
+ const innerBody = (
+ <>
+ {thumb}
+
+ {title}
+
+ >
+ )
+ const label = firstHref ? (
+
+ {innerBody}
+
+ ) : (
+
+ {innerBody}
+
+ )
+ return (
+
+ {label}
+ {remaining > 0 ? (
+ +{remaining}
+ ) : null}
+
+ )
+ })()
+ )}
+
+
+ )
+}
diff --git a/src/components/home/__tests__/cert-preview-location-icon.test.tsx b/src/components/home/__tests__/cert-preview-location-icon.test.tsx
index af63bc54..2ee3d178 100644
--- a/src/components/home/__tests__/cert-preview-location-icon.test.tsx
+++ b/src/components/home/__tests__/cert-preview-location-icon.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect, afterEach } from "vitest"
import { render, screen, cleanup, within } from "@testing-library/react"
-import { CertPreview } from "../home-feed"
+import { CertPreview } from "../home-feed-rows"
import type { ActivityRecord } from "@/lib/atproto/activity-types"
// bug-010: the feed PreviewCard's MapPin was gated on
diff --git a/src/components/home/__tests__/endorsement-group-row.test.tsx b/src/components/home/__tests__/endorsement-group-row.test.tsx
new file mode 100644
index 00000000..7503337f
--- /dev/null
+++ b/src/components/home/__tests__/endorsement-group-row.test.tsx
@@ -0,0 +1,110 @@
+import { describe, it, expect, afterEach, vi } from "vitest"
+import { render, screen, cleanup, fireEvent } from "@testing-library/react"
+
+import { EndorsementGroupRow } from "../home-feed-rows"
+import type { EndorsementGroupItem } from "@/lib/utils/group-feed"
+import type { FeedActor } from "@/lib/atproto/follower-events"
+
+// useAuthorInfo goes through the batched DID resolver (network). Stub
+// it so rows render DID-only bylines synchronously; the spy doubles as
+// a render probe for the memo test below.
+const useAuthorInfoMock = vi.fn((_did: string | null) => ({
+ info: null,
+ isLoading: false,
+ error: null,
+}))
+vi.mock("@/hooks/use-author-info", () => ({
+ useAuthorInfo: (did: string | null) => useAuthorInfoMock(did),
+}))
+
+const ACTOR_PROFILE: FeedActor = {
+ did: "did:plc:actor",
+ handle: null,
+ displayName: null,
+ avatarCid: null,
+}
+
+function makeGroup(
+ count: number,
+ overrides?: Partial,
+): EndorsementGroupItem {
+ return {
+ type: "endorsementGroup",
+ key: "at://did:plc:actor/app.certified.feed.endorsement/e0",
+ actor: "did:plc:actor",
+ actorProfile: ACTOR_PROFILE,
+ createdAt: "2026-07-01T00:00:00.000Z",
+ subjectDids: Array.from({ length: count }, (_, i) => `did:plc:subject${i}`),
+ ...overrides,
+ }
+}
+
+afterEach(() => {
+ cleanup()
+ useAuthorInfoMock.mockClear()
+})
+
+describe("EndorsementGroupRow expanded-list windowing", () => {
+ it("caps the initial expansion at one page and pages the rest via Show more", () => {
+ render( )
+
+ fireEvent.click(screen.getByRole("button", { name: /show all/i }))
+ expect(screen.getAllByRole("listitem")).toHaveLength(50)
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /show more \(70 remaining\)/i }),
+ )
+ expect(screen.getAllByRole("listitem")).toHaveLength(100)
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /show more \(20 remaining\)/i }),
+ )
+ expect(screen.getAllByRole("listitem")).toHaveLength(120)
+ expect(screen.queryByRole("button", { name: /show more/i })).toBeNull()
+ })
+
+ it("resets the window on collapse so re-expanding starts at one page", () => {
+ render( )
+
+ fireEvent.click(screen.getByRole("button", { name: /show all/i }))
+ fireEvent.click(
+ screen.getByRole("button", { name: /show more \(70 remaining\)/i }),
+ )
+ expect(screen.getAllByRole("listitem")).toHaveLength(100)
+
+ fireEvent.click(screen.getByRole("button", { name: /show fewer/i }))
+ expect(screen.queryAllByRole("listitem")).toHaveLength(0)
+
+ fireEvent.click(screen.getByRole("button", { name: /show all/i }))
+ expect(screen.getAllByRole("listitem")).toHaveLength(50)
+ })
+
+ it("renders small groups in full with no Show more button", () => {
+ render( )
+
+ fireEvent.click(screen.getByRole("button", { name: /show all/i }))
+ expect(screen.getAllByRole("listitem")).toHaveLength(3)
+ expect(screen.queryByRole("button", { name: /show more/i })).toBeNull()
+ })
+})
+
+describe("EndorsementGroupRow memo comparator", () => {
+ // groupConsecutiveEndorsements rebuilds group objects (and their
+ // subjectDids arrays) on every events change; the memo comparator
+ // must bail on a structurally-equal rebuild and re-render when the
+ // group absorbs another subject.
+ it("skips re-render for a rebuilt-but-equal group and re-renders on growth", () => {
+ const { rerender } = render( )
+ const baseline = useAuthorInfoMock.mock.calls.length
+ expect(baseline).toBeGreaterThan(0)
+
+ // Fresh group + fresh subjectDids array, same values (actorProfile
+ // ref is stable in production — it comes from the stable event).
+ rerender( )
+ expect(useAuthorInfoMock.mock.calls.length).toBe(baseline)
+
+ // One more absorbed subject must invalidate the bail-out.
+ rerender( )
+ expect(useAuthorInfoMock.mock.calls.length).toBeGreaterThan(baseline)
+ })
+})
diff --git a/src/components/home/home-feed-rows.tsx b/src/components/home/home-feed-rows.tsx
new file mode 100644
index 00000000..50bc384e
--- /dev/null
+++ b/src/components/home/home-feed-rows.tsx
@@ -0,0 +1,757 @@
+"use client"
+
+import { memo, useState, type ReactNode, type SyntheticEvent } from "react"
+import { listUrl, profileUrl, recordUrl } from "@/lib/urls"
+import Link from "next/link"
+import { MapPin } from "lucide-react"
+import Avatar from "@/components/ui/avatar"
+import IdentityRow from "@/components/ui/identity-row"
+import Badge, { type BadgeTone } from "@/components/ui/badge"
+import Button from "@/components/ui/button"
+import { useActivity } from "@/hooks/use-activity"
+import { useProject } from "@/hooks/use-project"
+import { useAuthorInfo } from "@/hooks/use-author-info"
+import type { HomeFeedEvent } from "@/hooks/use-home-feed"
+import type { EndorsementGroupItem } from "@/lib/utils/group-feed"
+import { formatRelativeTime, resolveActivityImageUrl } from "@/lib/atproto/activity"
+import type { FeedActor } from "@/lib/atproto/follower-events"
+import { parseAtUri } from "@/lib/atproto/activity-uri"
+import { formatTimePeriod } from "@/lib/utils/format-date"
+import { hideBrokenThumb } from "@/lib/utils/image-fallback"
+import { getInitials } from "@/lib/utils/initials"
+import { buildAvatarUrlFromCid } from "@/lib/atproto/profile"
+import type { ActivityRecord } from "@/lib/atproto/activity-types"
+import {
+ projectImage,
+ projectTitle,
+ type CollectionRecord,
+} from "@/lib/atproto/collection"
+import { TYPED_LIST_TYPES, type TypedListType } from "@/lib/atproto/typed-lists"
+
+/**
+ * Presentational row layer for the home feed: the per-event card
+ * (byline head + verb sentence + record preview) and the grouped
+ * endorsement row. Consumed only by HomeFeedBody in home-feed.tsx;
+ * every component here takes plain data props — no filter or
+ * pagination state crosses the seam.
+ */
+
+/**
+ * Card head shared by single-event and grouped rows — the certs.social
+ * author byline: avatar + display name + @handle linking to the actor's
+ * profile, relative time pinned to the right edge, and the event verb
+ * sentence as a muted second line. The sentence renders OUTSIDE the
+ * byline link because it carries its own links (target cert / project /
+ * account names) — nested anchors are invalid HTML.
+ *
+ * The indexer's denormalised `actorProfile` is empty in practice today
+ * (magic-indexer#130 — profile ingestion not enabled on prod).
+ * `useAuthorInfo` does the per-actor PDS resolve and caches at module
+ * scope. Prefer its data; treat the indexer's actorProfile as a
+ * first-paint hint when present.
+ */
+function FeedCardHead({
+ actor,
+ actorProfile,
+ action,
+ createdAt,
+}: {
+ actor: string
+ actorProfile: FeedActor
+ action: ReactNode
+ createdAt: string
+}) {
+ const { info: lookup } = useAuthorInfo(actor)
+ const actorName =
+ lookup?.displayName ||
+ actorProfile.displayName ||
+ lookup?.handle ||
+ actorProfile.handle ||
+ actor.slice(0, 16)
+ const actorHandle = lookup?.handle || actorProfile.handle || null
+ const actorAvatar =
+ lookup?.avatarUrl ||
+ buildAvatarUrlFromCid(actorProfile.did, actorProfile.avatarCid)
+ const actorInitials = getInitials(
+ lookup?.displayName ?? actorProfile.displayName,
+ actorHandle,
+ )
+ const profileHref = profileUrl(actorHandle || actor)
+
+ return (
+
+ )
+}
+
+// Memoized: useHomeFeed's loadMore appends with a new events-array
+// identity but stable per-event object refs, so unchanged cards bail
+// out of reconciliation on long feeds — the same hazard ActivityCard
+// documents in feed/activity-card.tsx.
+export const HomeFeedRow = memo(function HomeFeedRow({
+ event,
+}: {
+ event: HomeFeedEvent
+}) {
+ return (
+
+ }
+ createdAt={event.createdAt}
+ />
+ {event.kind === "cert.create" ? (
+
+ ) : null}
+ {event.kind === "collection.create" ||
+ event.kind === "project.created_with_cert" ? (
+
+ ) : null}
+ {event.kind === "update.create" ? (
+
+ ) : null}
+
+ )
+})
+
+/** Page size for the expanded subject list. Grouping is designed to
+ * absorb ~1000-endorsement bursts into one row (see MAX_AUTO_LOADS
+ * in home-feed.tsx); mounting that many IdentityRows in a single
+ * commit stalls the main thread, so expansion reveals this many at
+ * a time. Groups of 2-20 (the common case) are unaffected. */
+const GROUP_EXPAND_PAGE = 50
+
+/**
+ * Grouped row: " endorsed and N others" with a "Show
+ * all" toggle that expands an inline list of every endorsed account.
+ *
+ * The head follows the same byline layout as the single-event
+ * HomeFeedRow so the visual rhythm of the feed stays consistent across
+ * mixed single + grouped rows. The first-subject sentence is the row's
+ * primary identity, since subjectDids[0] is the most recent
+ * endorsement in the burst.
+ */
+export const EndorsementGroupRow = memo(
+ function EndorsementGroupRow({ group }: { group: EndorsementGroupItem }) {
+ const othersCount = group.subjectDids.length - 1
+ const [expanded, setExpanded] = useState(false)
+ const [visibleCount, setVisibleCount] = useState(GROUP_EXPAND_PAGE)
+ const remaining = group.subjectDids.length - visibleCount
+
+ return (
+
+
+ endorsed{" "}
+
+ >
+ }
+ />
+ {
+ // Collapse resets the window so re-expanding starts at
+ // one page again.
+ if (expanded) setVisibleCount(GROUP_EXPAND_PAGE)
+ setExpanded(!expanded)
+ }}
+ >
+ {expanded ? "Show fewer" : "Show all"}
+
+ {expanded ? (
+ <>
+
+ {group.subjectDids.slice(0, visibleCount).map((did) => (
+
+
+
+ ))}
+
+ {remaining > 0 ? (
+ setVisibleCount((c) => c + GROUP_EXPAND_PAGE)}
+ >
+ Show more ({remaining} remaining)
+
+ ) : null}
+ >
+ ) : null}
+
+ )
+ },
+ // groupConsecutiveEndorsements rebuilds every group object (and its
+ // subjectDids array) on each events change, so shallow compare never
+ // bails. A group's identity is its key + headline time + actor
+ // profile + subject composition — compare those element-wise. O(n)
+ // only on re-render attempts, trivially cheap vs. the render saved.
+ (prev, next) =>
+ prev.group.key === next.group.key &&
+ prev.group.createdAt === next.group.createdAt &&
+ prev.group.actorProfile === next.group.actorProfile &&
+ prev.group.subjectDids.length === next.group.subjectDids.length &&
+ prev.group.subjectDids.every((d, i) => d === next.group.subjectDids[i]),
+)
+
+function EndorsementGroupSummary({
+ firstDid,
+ othersCount,
+}: {
+ firstDid: string
+ othersCount: number
+}) {
+ const { info } = useAuthorInfo(firstDid)
+ const name = info?.displayName || (info?.handle ? `@${info.handle}` : null)
+ const href = profileUrl(info?.handle || firstDid)
+ return (
+ <>
+
+ {name ?? "an account"}
+
+ {othersCount > 0 ? (
+ <>
+ {" "}
+ and {othersCount} {othersCount === 1 ? "other" : "others"}
+ >
+ ) : null}
+ >
+ )
+}
+
+function EndorsedAccountLink({ did }: { did: string }) {
+ const { info } = useAuthorInfo(did)
+ const href = profileUrl(info?.handle || did)
+ return (
+
+ )
+}
+
+function EventSentence({ event }: { event: HomeFeedEvent }) {
+ switch (event.kind) {
+ case "cert.create":
+ return <>created an activity>
+ case "collection.create":
+ return
+ case "project.created_with_cert":
+ return <>created a project with an activity>
+ case "endorsement.award":
+ case "legacy.endorsement":
+ return
+ case "evaluation.create":
+ return
+ case "measurement.create":
+ return
+ case "hyperboard.create":
+ return <>created a hyperboard>
+ case "update.create":
+ return
+ case "unknown":
+ // The wire kind was known but hydration didn't return a
+ // payload (or it was genuinely unknown). Recover the verb
+ // sentence from the wire kind when we recognise it — losing
+ // the body content is OK; losing the action label isn't.
+ return
+ }
+}
+
+/**
+ * Evaluation / measurement sentence with a clickable cert target.
+ * If hydration didn't surface a targetUri, falls back to plain text
+ * "added a measurement" (no "to " tail).
+ */
+function CertTargetSentence({
+ verb,
+ targetUri,
+}: {
+ verb: string
+ targetUri: string | null
+}) {
+ if (!targetUri) {
+ // Trim the "to" off the verb when there's no link target.
+ return <>{verb.replace(/ to$/, "")}>
+ }
+ const parsed = parseAtUri(targetUri)
+ const href = parsed
+ ? recordUrl(parsed.did, "activity", parsed.rkey)
+ : null
+ return (
+ <>
+ {verb}{" "}
+ {href ? (
+
+
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+/**
+ * Resolves the linked cert's title for use as inline link text.
+ * Falls back to "a cert" while loading or on miss.
+ */
+function CertTargetName({ did, rkey }: { did: string; rkey: string }) {
+ const { activity } = useActivity(did || null, rkey || null)
+ const title =
+ typeof activity?.value.title === "string" && activity.value.title.length > 0
+ ? activity.value.title
+ : null
+ return <>{title ?? "an activity"}>
+}
+
+/**
+ * Generalized verb-and-target sentence that dispatches on the
+ * target URI's collection (NSID) — cert URIs route through the
+ * cert-detail link, project collection URIs through the project
+ * detail link, anything else falls back to plain text. Used by
+ * update.create events, whose target can be either a cert
+ * (`org.hypercerts.claim.activity`) OR a project / endorsements-list
+ * (`org.hypercerts.collection`).
+ *
+ * `fallback` is the text shown when `targetUri` is null (lexicon
+ * didn't populate `subjects[]`) — typically a shorter sentence
+ * without the trailing "to" preposition.
+ */
+function TargetSentence({
+ verb,
+ targetUri,
+ fallback,
+}: {
+ verb: string
+ targetUri: string | null
+ fallback: string
+}) {
+ if (!targetUri) return <>{fallback}>
+ const parsed = parseAtUri(targetUri)
+ if (!parsed) return <>{fallback}>
+ if (parsed.collection === "org.hypercerts.claim.activity") {
+ return
+ }
+ if (parsed.collection === "org.hypercerts.collection") {
+ const href = recordUrl(parsed.did, "project", parsed.rkey)
+ return (
+ <>
+ {verb}{" "}
+
+
+
+ >
+ )
+ }
+ // Unknown lexicon — keep the verb but drop the "to " tail.
+ return <>{fallback}>
+}
+
+/**
+ * Resolves the linked project's title for use as inline link text.
+ * Falls back to "a project" while loading or on miss.
+ */
+function ProjectTargetName({ did, rkey }: { did: string; rkey: string }) {
+ const { project } = useProject(did || null, rkey || null)
+ const title =
+ typeof project?.value.title === "string" && project.value.title.length > 0
+ ? project.value.title
+ : typeof project?.value.name === "string" && project.value.name.length > 0
+ ? project.value.name
+ : null
+ return <>{title ?? "a project"}>
+}
+
+function UnhydratedSentence({ rawKind }: { rawKind: string }) {
+ switch (rawKind) {
+ case "cert.create":
+ return <>created an activity>
+ case "collection.create":
+ return <>created a project>
+ case "project.created_with_cert":
+ return <>created a project with an activity>
+ case "evaluation.create":
+ return <>added an evaluation>
+ case "measurement.create":
+ return <>added a measurement>
+ case "hyperboard.create":
+ return <>created a hyperboard>
+ case "update.create":
+ return <>posted an update>
+ case "endorsement.award":
+ case "legacy.endorsement":
+ return <>endorsed someone>
+ default:
+ return <>did something>
+ }
+}
+
+/**
+ * Sentence for `collection.create` events. The
+ * `org.hypercerts.collection` lexicon carries a `type` discriminator
+ * that the renderer routes off to pick the right verb phrase:
+ *
+ * project → "created a project"
+ * list:endorsements → "created an endorsement list"
+ * list:projects → "created a list of projects"
+ * list:certs → "created a list of certs"
+ * list:accounts → "created a list of accounts"
+ * portfolio → "created a portfolio" (legacy)
+ * unknown → "created a collection" (defensive)
+ *
+ * The collection's title + description + image render in the preview
+ * card immediately below the sentence, so the sentence itself stays
+ * short — naming "what kind" without re-stating "which one".
+ */
+function CollectionSentence({ record }: { record: CollectionRecord }) {
+ const rawType =
+ typeof record.value.type === "string"
+ ? record.value.type.toLowerCase()
+ : null
+
+ switch (rawType) {
+ case "project":
+ return <>created a project>
+ case "list:endorsements":
+ return <>created an endorsement list>
+ case "list:projects":
+ return <>created a list of projects>
+ case "list:certs":
+ return <>created a list of activities>
+ case "list:accounts":
+ return <>created a list of accounts>
+ case "portfolio":
+ return <>created a portfolio>
+ default:
+ return <>created a collection>
+ }
+}
+
+function EndorsementSentence({ subjectDid }: { subjectDid: string }) {
+ const { info } = useAuthorInfo(subjectDid)
+ const name = info?.displayName || (info?.handle ? `@${info.handle}` : null)
+ const href = profileUrl(info?.handle || subjectDid)
+ return (
+ <>
+ endorsed{" "}
+
+ {name ?? "an account"}
+
+ >
+ )
+}
+
+// ---------------------------------- Cert preview ----------------------------
+
+// Square-tag tone per quality label. The Badge square variant treats
+// "warn" as error-toned (red), so draft reads error-tone and
+// likely-test reads neutral — preserving the legacy
+// home-feed__preview-tag look (--warn = red, base = muted).
+const QUALITY_TAGS: Record = {
+ draft: { label: "Draft", tone: "warn" },
+ "likely-test": { label: "Likely test", tone: "neutral" },
+}
+
+function certQualityTags(labels: readonly string[]): { key: string; label: string; tone: BadgeTone }[] {
+ return labels
+ .map((l) => (QUALITY_TAGS[l] ? { key: l, ...QUALITY_TAGS[l] } : null))
+ .filter((x): x is { key: string; label: string; tone: BadgeTone } => !!x)
+}
+
+export function CertPreview({
+ record,
+ uri,
+ labels,
+}: {
+ record: ActivityRecord
+ uri: string
+ labels: readonly string[]
+}) {
+ const parsed = parseAtUri(uri)
+ const href = parsed
+ ? recordUrl(parsed.did, "activity", parsed.rkey)
+ : null
+ const title =
+ typeof record.value.title === "string" && record.value.title.length > 0
+ ? record.value.title
+ : "Untitled activity"
+ const description =
+ typeof record.value.shortDescription === "string" &&
+ record.value.shortDescription.length > 0
+ ? record.value.shortDescription
+ : null
+ const imageUrl =
+ record.value.image && parsed
+ ? resolveActivityImageUrl(record.value.image, parsed.did)
+ : null
+ const period = formatTimePeriod(
+ typeof record.value.startDate === "string" ? record.value.startDate : null,
+ typeof record.value.endDate === "string" ? record.value.endDate : null,
+ )
+ const locationCount = Array.isArray(record.value.locations)
+ ? record.value.locations.length
+ : 0
+
+ return (
+ 0 ? (
+ <>
+
+ {`${locationCount} location${locationCount === 1 ? "" : "s"}`}
+ >
+ ) : null,
+ ].filter((m): m is NonNullable => m !== null && m !== undefined)}
+ />
+ )
+}
+
+// ------------------------------ Collection preview --------------------------
+
+function CollectionPreview({
+ record,
+ uri,
+}: {
+ record: CollectionRecord
+ uri: string
+}) {
+ const parsed = parseAtUri(uri)
+ const v = record.value as Record
+ const collectionType =
+ typeof v.type === "string" ? v.type.toLowerCase() : "project"
+ // Typed lists (projects / accounts / certs) have no record route —
+ // they open in-place on the owner's Lists tab. Everything else
+ // (project, list:endorsements, portfolio) keeps the project link.
+ const href = parsed
+ ? TYPED_LIST_TYPES.includes(collectionType as TypedListType)
+ ? listUrl(parsed.did, parsed.rkey)
+ : recordUrl(parsed.did, "project", parsed.rkey)
+ : null
+ const fallbackTitle =
+ collectionType === "list:endorsements"
+ ? "Untitled list"
+ : collectionType === "portfolio"
+ ? "Untitled portfolio"
+ : "Untitled project"
+ const title = projectTitle(record.value, fallbackTitle)
+ const description =
+ typeof v.shortDescription === "string" && v.shortDescription.length > 0
+ ? v.shortDescription
+ : null
+ // Feed-card thumbnail — avatar-first (`projectImage` thumb slot):
+ // the avatar is the identity image; the banner is the wide hero.
+ const rawImage = projectImage(record.value, "thumb")
+ const imageUrl =
+ rawImage && parsed ? resolveActivityImageUrl(rawImage, parsed.did) : null
+ const itemCount = Array.isArray(v.items) ? v.items.length : 0
+ const itemNoun =
+ collectionType === "list:endorsements"
+ ? itemCount === 1
+ ? "endorsement"
+ : "endorsements"
+ : itemCount === 1
+ ? "activity"
+ : "activities"
+
+ return (
+ 0 ? `${itemCount} ${itemNoun}` : null,
+ ].filter((s): s is string => !!s)}
+ />
+ )
+}
+
+// ----------------------------- Update preview ------------------------------
+
+/**
+ * Card preview for an `update.create` event. Modeled on the project
+ * card: the attachment lexicon's `title` + `shortDescription`
+ * populate the body; the first `image/*` blob in `content[]`
+ * (resolved server-side via the indexer's hydration round-trip)
+ * supplies the thumb when present. The card links to the target
+ * cert / project detail page when `subjects[0]` resolves, matching
+ * the inline "posted an update to " sentence above. When the
+ * attachment has no image content the PreviewCard falls back to
+ * its no-image flow automatically.
+ */
+function UpdatePreview({
+ title,
+ shortDescription,
+ targetUri,
+ imageUrl,
+}: {
+ title: string | null
+ shortDescription: string | null
+ targetUri: string | null
+ imageUrl: string | null
+}) {
+ const parsed = targetUri ? parseAtUri(targetUri) : null
+ const href = parsed
+ ? parsed.collection === "org.hypercerts.claim.activity"
+ ? recordUrl(parsed.did, "activity", parsed.rkey)
+ : parsed.collection === "org.hypercerts.collection"
+ ? recordUrl(parsed.did, "project", parsed.rkey)
+ : null
+ : null
+ return (
+
+ )
+}
+
+// ---------------------------------- Card body ------------------------------
+
+/**
+ * Hide the full-width image block entirely when the blob 404s — the
+ * generic `hideBrokenThumb` only hides the ` `, which would leave
+ * the empty aspect-square container dominating the card.
+ */
+function hideBrokenCardImage(
+ event: SyntheticEvent,
+): void {
+ const wrap = event.currentTarget.closest(
+ ".home-feed__card-image",
+ )
+ if (wrap) wrap.style.display = "none"
+ else hideBrokenThumb(event)
+}
+
+/**
+ * Record body below the byline — the certs.social feed-card layout:
+ * full-width square image (when the record has one), serif headline
+ * title (plus quality tags), 3-line-clamped short description, and a
+ * dot-separated muted meta row. The whole body links to the record's
+ * detail page; only the title underlines on hover so the card still
+ * reads as a card.
+ */
+function PreviewCard({
+ href,
+ title,
+ tags,
+ imageUrl,
+ description,
+ meta,
+}: {
+ href: string | null
+ title: string
+ tags?: { key: string; label: string; tone: BadgeTone }[]
+ imageUrl: string | null
+ description: string | null
+ meta: ReactNode[]
+}) {
+ const body = (
+ <>
+ {imageUrl ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- dynamic bsky-CDN/blob card image URL; next/image remotePatterns limited to **.certified.app */}
+
+
+ ) : null}
+
+ {title}
+ {tags?.map((t) => (
+
+ {t.label}
+
+ ))}
+
+ {description ? (
+ {description}
+ ) : null}
+ {meta.length > 0 ? (
+
+ {meta.map((m, i) => (
+
+ {m}
+
+ ))}
+
+ ) : null}
+ >
+ )
+
+ if (href) {
+ return (
+
+ {body}
+
+ )
+ }
+ return {body}
+}
diff --git a/src/components/home/home-feed.tsx b/src/components/home/home-feed.tsx
index a428a659..a03559f5 100644
--- a/src/components/home/home-feed.tsx
+++ b/src/components/home/home-feed.tsx
@@ -1,43 +1,25 @@
"use client"
import {
+ memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
- type ReactNode,
- type SyntheticEvent,
} from "react"
-import { listUrl, profileUrl, recordUrl } from "@/lib/urls"
-import Link from "next/link"
-import { ChevronDown, Inbox, MapPin, Users } from "lucide-react"
-import Avatar from "@/components/ui/avatar"
-import IdentityRow from "@/components/ui/identity-row"
-import Badge, { type BadgeTone } from "@/components/ui/badge"
+import { ChevronDown, Inbox, Users } from "lucide-react"
import Banner from "@/components/ui/banner"
-import Button from "@/components/ui/button"
import EmptyState from "@/components/ui/empty-state"
import LoadingSpinner from "@/components/ui/loading-spinner"
import LoadMoreSentinel from "@/components/ui/load-more-sentinel"
-import { useActivity } from "@/hooks/use-activity"
-import { useProject } from "@/hooks/use-project"
import { useAuthorInfo } from "@/hooks/use-author-info"
import { useClickOutsideClose } from "@/hooks/use-click-outside-close"
import { useEvaluatorEndorsements } from "@/hooks/use-evaluator-endorsements"
import { useHomeFeed, type HomeFeedEvent } from "@/hooks/use-home-feed"
-import {
- groupConsecutiveEndorsements,
- type EndorsementGroupItem,
-} from "@/lib/utils/group-feed"
+import { groupConsecutiveEndorsements } from "@/lib/utils/group-feed"
import { useFollowing } from "@/hooks/use-following"
-import { formatRelativeTime, resolveActivityImageUrl } from "@/lib/atproto/activity"
-import type { FeedActor } from "@/lib/atproto/follower-events"
-import { parseAtUri } from "@/lib/atproto/activity-uri"
-import { formatShortDate } from "@/lib/utils/format-date"
import { hideBrokenThumb } from "@/lib/utils/image-fallback"
-import { getInitials } from "@/lib/utils/initials"
-import { buildAvatarUrlFromCid } from "@/lib/atproto/profile"
import {
DEFAULT_HIDDEN_CERT_LABELS,
DEFAULT_HIDDEN_ORG_LABELS,
@@ -50,9 +32,7 @@ import {
} from "@/lib/atproto/labels"
import { fetchOrgDidsByLabel } from "@/lib/atproto/workspace"
import { useTrustedEvaluators } from "@/hooks/use-trusted-evaluators"
-import type { ActivityRecord } from "@/lib/atproto/activity-types"
-import type { CollectionRecord } from "@/lib/atproto/collection"
-import { TYPED_LIST_TYPES, type TypedListType } from "@/lib/atproto/typed-lists"
+import { EndorsementGroupRow, HomeFeedRow } from "./home-feed-rows"
const DEFAULT_INCLUDED_TIERS: ReadonlySet = new Set(
HYPERLABEL_TIERS.filter(
@@ -312,7 +292,12 @@ export default function HomeFeed({ activeDid }: { activeDid: string }) {
)
}
-function HomeFeedBody({
+// Memoized: filter state (panel open/close, evaluator + tier checkbox
+// ticks) lives in HomeFeed, so without memo every filter interaction
+// re-executes the full row map. Props are primitives plus a stable
+// events ref and a useCallback-stable loadMore, so the default shallow
+// compare bails correctly.
+const HomeFeedBody = memo(function HomeFeedBody({
followsLoading,
followsError,
followedCount,
@@ -427,7 +412,7 @@ function HomeFeedBody({
) : null}
>
)
-}
+})
function isQualityDefault(included: Set): boolean {
// Default = every tier in DEFAULT_INCLUDED_TIERS, plus unlabeled.
@@ -674,699 +659,3 @@ function EvaluatorRow({
)
}
-
-/**
- * Card head shared by single-event and grouped rows — the certs.social
- * author byline: avatar + display name + @handle linking to the actor's
- * profile, relative time pinned to the right edge, and the event verb
- * sentence as a muted second line. The sentence renders OUTSIDE the
- * byline link because it carries its own links (target cert / project /
- * account names) — nested anchors are invalid HTML.
- *
- * The indexer's denormalised `actorProfile` is empty in practice today
- * (magic-indexer#130 — profile ingestion not enabled on prod).
- * `useAuthorInfo` does the per-actor PDS resolve and caches at module
- * scope. Prefer its data; treat the indexer's actorProfile as a
- * first-paint hint when present.
- */
-function FeedCardHead({
- actor,
- actorProfile,
- action,
- createdAt,
-}: {
- actor: string
- actorProfile: FeedActor
- action: ReactNode
- createdAt: string
-}) {
- const { info: lookup } = useAuthorInfo(actor)
- const actorName =
- lookup?.displayName ||
- actorProfile.displayName ||
- lookup?.handle ||
- actorProfile.handle ||
- actor.slice(0, 16)
- const actorHandle = lookup?.handle || actorProfile.handle || null
- const actorAvatar =
- lookup?.avatarUrl ||
- buildAvatarUrlFromCid(actorProfile.did, actorProfile.avatarCid)
- const actorInitials = getInitials(
- lookup?.displayName ?? actorProfile.displayName,
- actorHandle,
- )
- const profileHref = profileUrl(actorHandle || actor)
-
- return (
-
- )
-}
-
-function HomeFeedRow({ event }: { event: HomeFeedEvent }) {
- return (
-
- }
- createdAt={event.createdAt}
- />
- {event.kind === "cert.create" ? (
-
- ) : null}
- {event.kind === "collection.create" ||
- event.kind === "project.created_with_cert" ? (
-
- ) : null}
- {event.kind === "update.create" ? (
-
- ) : null}
-
- )
-}
-
-/**
- * Grouped row: " endorsed and N others" with a "Show
- * all" toggle that expands an inline list of every endorsed account.
- *
- * The head follows the same byline layout as the single-event
- * HomeFeedRow so the visual rhythm of the feed stays consistent across
- * mixed single + grouped rows. The first-subject sentence is the row's
- * primary identity, since subjectDids[0] is the most recent
- * endorsement in the burst.
- */
-function EndorsementGroupRow({ group }: { group: EndorsementGroupItem }) {
- const othersCount = group.subjectDids.length - 1
- const [expanded, setExpanded] = useState(false)
-
- return (
-
-
- endorsed{" "}
-
- >
- }
- />
- setExpanded((v) => !v)}
- >
- {expanded ? "Show fewer" : "Show all"}
-
- {expanded ? (
-
- {group.subjectDids.map((did) => (
-
-
-
- ))}
-
- ) : null}
-
- )
-}
-
-function EndorsementGroupSummary({
- firstDid,
- othersCount,
-}: {
- firstDid: string
- othersCount: number
-}) {
- const { info } = useAuthorInfo(firstDid)
- const name = info?.displayName || (info?.handle ? `@${info.handle}` : null)
- const href = profileUrl(info?.handle || firstDid)
- return (
- <>
-
- {name ?? "an account"}
-
- {othersCount > 0 ? (
- <>
- {" "}
- and {othersCount} {othersCount === 1 ? "other" : "others"}
- >
- ) : null}
- >
- )
-}
-
-function EndorsedAccountLink({ did }: { did: string }) {
- const { info } = useAuthorInfo(did)
- const href = profileUrl(info?.handle || did)
- return (
-
- )
-}
-
-function EventSentence({ event }: { event: HomeFeedEvent }) {
- switch (event.kind) {
- case "cert.create":
- return <>created an activity>
- case "collection.create":
- return
- case "project.created_with_cert":
- return <>created a project with an activity>
- case "endorsement.award":
- case "legacy.endorsement":
- return
- case "evaluation.create":
- return
- case "measurement.create":
- return
- case "hyperboard.create":
- return <>created a hyperboard>
- case "update.create":
- return
- case "unknown":
- // The wire kind was known but hydration didn't return a
- // payload (or it was genuinely unknown). Recover the verb
- // sentence from the wire kind when we recognise it — losing
- // the body content is OK; losing the action label isn't.
- return
- }
-}
-
-/**
- * Evaluation / measurement sentence with a clickable cert target.
- * If hydration didn't surface a targetUri, falls back to plain text
- * "added a measurement" (no "to " tail).
- */
-function CertTargetSentence({
- verb,
- targetUri,
-}: {
- verb: string
- targetUri: string | null
-}) {
- if (!targetUri) {
- // Trim the "to" off the verb when there's no link target.
- return <>{verb.replace(/ to$/, "")}>
- }
- const parsed = parseAtUri(targetUri)
- const href = parsed
- ? recordUrl(parsed.did, "activity", parsed.rkey)
- : null
- return (
- <>
- {verb}{" "}
- {href ? (
-
-
-
- ) : (
-
- )}
- >
- )
-}
-
-/**
- * Resolves the linked cert's title for use as inline link text.
- * Falls back to "a cert" while loading or on miss.
- */
-function CertTargetName({ did, rkey }: { did: string; rkey: string }) {
- const { activity } = useActivity(did || null, rkey || null)
- const title =
- typeof activity?.value.title === "string" && activity.value.title.length > 0
- ? activity.value.title
- : null
- return <>{title ?? "an activity"}>
-}
-
-/**
- * Generalized verb-and-target sentence that dispatches on the
- * target URI's collection (NSID) — cert URIs route through the
- * cert-detail link, project collection URIs through the project
- * detail link, anything else falls back to plain text. Used by
- * update.create events, whose target can be either a cert
- * (`org.hypercerts.claim.activity`) OR a project / endorsements-list
- * (`org.hypercerts.collection`).
- *
- * `fallback` is the text shown when `targetUri` is null (lexicon
- * didn't populate `subjects[]`) — typically a shorter sentence
- * without the trailing "to" preposition.
- */
-function TargetSentence({
- verb,
- targetUri,
- fallback,
-}: {
- verb: string
- targetUri: string | null
- fallback: string
-}) {
- if (!targetUri) return <>{fallback}>
- const parsed = parseAtUri(targetUri)
- if (!parsed) return <>{fallback}>
- if (parsed.collection === "org.hypercerts.claim.activity") {
- return
- }
- if (parsed.collection === "org.hypercerts.collection") {
- const href = recordUrl(parsed.did, "project", parsed.rkey)
- return (
- <>
- {verb}{" "}
-
-
-
- >
- )
- }
- // Unknown lexicon — keep the verb but drop the "to " tail.
- return <>{fallback}>
-}
-
-/**
- * Resolves the linked project's title for use as inline link text.
- * Falls back to "a project" while loading or on miss.
- */
-function ProjectTargetName({ did, rkey }: { did: string; rkey: string }) {
- const { project } = useProject(did || null, rkey || null)
- const title =
- typeof project?.value.title === "string" && project.value.title.length > 0
- ? project.value.title
- : typeof project?.value.name === "string" && project.value.name.length > 0
- ? project.value.name
- : null
- return <>{title ?? "a project"}>
-}
-
-function UnhydratedSentence({ rawKind }: { rawKind: string }) {
- switch (rawKind) {
- case "cert.create":
- return <>created an activity>
- case "collection.create":
- return <>created a project>
- case "project.created_with_cert":
- return <>created a project with an activity>
- case "evaluation.create":
- return <>added an evaluation>
- case "measurement.create":
- return <>added a measurement>
- case "hyperboard.create":
- return <>created a hyperboard>
- case "update.create":
- return <>posted an update>
- case "endorsement.award":
- case "legacy.endorsement":
- return <>endorsed someone>
- default:
- return <>did something>
- }
-}
-
-/**
- * Sentence for `collection.create` events. The
- * `org.hypercerts.collection` lexicon carries a `type` discriminator
- * that the renderer routes off to pick the right verb phrase:
- *
- * project → "created a project"
- * list:endorsements → "created an endorsement list"
- * list:projects → "created a list of projects"
- * list:certs → "created a list of certs"
- * list:accounts → "created a list of accounts"
- * portfolio → "created a portfolio" (legacy)
- * unknown → "created a collection" (defensive)
- *
- * The collection's title + description + image render in the preview
- * card immediately below the sentence, so the sentence itself stays
- * short — naming "what kind" without re-stating "which one".
- */
-function CollectionSentence({ record }: { record: CollectionRecord }) {
- const rawType =
- typeof record.value.type === "string"
- ? record.value.type.toLowerCase()
- : null
-
- switch (rawType) {
- case "project":
- return <>created a project>
- case "list:endorsements":
- return <>created an endorsement list>
- case "list:projects":
- return <>created a list of projects>
- case "list:certs":
- return <>created a list of activities>
- case "list:accounts":
- return <>created a list of accounts>
- case "portfolio":
- return <>created a portfolio>
- default:
- return <>created a collection>
- }
-}
-
-function EndorsementSentence({ subjectDid }: { subjectDid: string }) {
- const { info } = useAuthorInfo(subjectDid)
- const name = info?.displayName || (info?.handle ? `@${info.handle}` : null)
- const href = profileUrl(info?.handle || subjectDid)
- return (
- <>
- endorsed{" "}
-
- {name ?? "an account"}
-
- >
- )
-}
-
-// ---------------------------------- Cert preview ----------------------------
-
-// Square-tag tone per quality label. The Badge square variant treats
-// "warn" as error-toned (red), so draft reads error-tone and
-// likely-test reads neutral — preserving the legacy
-// home-feed__preview-tag look (--warn = red, base = muted).
-const QUALITY_TAGS: Record = {
- draft: { label: "Draft", tone: "warn" },
- "likely-test": { label: "Likely test", tone: "neutral" },
-}
-
-function certQualityTags(labels: readonly string[]): { key: string; label: string; tone: BadgeTone }[] {
- return labels
- .map((l) => (QUALITY_TAGS[l] ? { key: l, ...QUALITY_TAGS[l] } : null))
- .filter((x): x is { key: string; label: string; tone: BadgeTone } => !!x)
-}
-
-export function CertPreview({
- record,
- uri,
- labels,
-}: {
- record: ActivityRecord
- uri: string
- labels: readonly string[]
-}) {
- const parsed = parseAtUri(uri)
- const href = parsed
- ? recordUrl(parsed.did, "activity", parsed.rkey)
- : null
- const title =
- typeof record.value.title === "string" && record.value.title.length > 0
- ? record.value.title
- : "Untitled activity"
- const description =
- typeof record.value.shortDescription === "string" &&
- record.value.shortDescription.length > 0
- ? record.value.shortDescription
- : null
- const imageUrl =
- record.value.image && parsed
- ? resolveActivityImageUrl(record.value.image, parsed.did)
- : null
- const period = formatPeriod(
- typeof record.value.startDate === "string" ? record.value.startDate : null,
- typeof record.value.endDate === "string" ? record.value.endDate : null,
- )
- const locationCount = Array.isArray(record.value.locations)
- ? record.value.locations.length
- : 0
-
- return (
- 0 ? (
- <>
-
- {`${locationCount} location${locationCount === 1 ? "" : "s"}`}
- >
- ) : null,
- ].filter((m): m is NonNullable => m !== null && m !== undefined)}
- />
- )
-}
-
-// ------------------------------ Collection preview --------------------------
-
-function CollectionPreview({
- record,
- uri,
-}: {
- record: CollectionRecord
- uri: string
-}) {
- const parsed = parseAtUri(uri)
- const v = record.value as Record
- const collectionType =
- typeof v.type === "string" ? v.type.toLowerCase() : "project"
- // Typed lists (projects / accounts / certs) have no record route —
- // they open in-place on the owner's Lists tab. Everything else
- // (project, list:endorsements, portfolio) keeps the project link.
- const href = parsed
- ? TYPED_LIST_TYPES.includes(collectionType as TypedListType)
- ? listUrl(parsed.did, parsed.rkey)
- : recordUrl(parsed.did, "project", parsed.rkey)
- : null
- const fallbackTitle =
- collectionType === "list:endorsements"
- ? "Untitled list"
- : collectionType === "portfolio"
- ? "Untitled portfolio"
- : "Untitled project"
- const title =
- (typeof v.title === "string" && v.title.length > 0 ? v.title : null) ||
- (typeof v.name === "string" && v.name.length > 0 ? v.name : null) ||
- fallbackTitle
- const description =
- typeof v.shortDescription === "string" && v.shortDescription.length > 0
- ? v.shortDescription
- : null
- // Priority: avatar (the collection's primary image) → image
- // (legacy field on older records) → banner (decorative). Avatar
- // is the identity image; banner is the wide hero. For a feed
- // card the avatar reads as the project, not the banner.
- const rawImage = v.avatar ?? v.image ?? v.banner
- const imageUrl =
- rawImage && parsed
- ? resolveActivityImageUrl(
- rawImage as Parameters[0],
- parsed.did,
- )
- : null
- const itemCount = Array.isArray(v.items) ? v.items.length : 0
- const itemNoun =
- collectionType === "list:endorsements"
- ? itemCount === 1
- ? "endorsement"
- : "endorsements"
- : itemCount === 1
- ? "activity"
- : "activities"
-
- return (
- 0 ? `${itemCount} ${itemNoun}` : null,
- ].filter((s): s is string => !!s)}
- />
- )
-}
-
-// ----------------------------- Update preview ------------------------------
-
-/**
- * Card preview for an `update.create` event. Modeled on the project
- * card: the attachment lexicon's `title` + `shortDescription`
- * populate the body; the first `image/*` blob in `content[]`
- * (resolved server-side via the indexer's hydration round-trip)
- * supplies the thumb when present. The card links to the target
- * cert / project detail page when `subjects[0]` resolves, matching
- * the inline "posted an update to " sentence above. When the
- * attachment has no image content the PreviewCard falls back to
- * its no-image flow automatically.
- */
-function UpdatePreview({
- title,
- shortDescription,
- targetUri,
- imageUrl,
-}: {
- title: string | null
- shortDescription: string | null
- targetUri: string | null
- imageUrl: string | null
-}) {
- const parsed = targetUri ? parseAtUri(targetUri) : null
- const href = parsed
- ? parsed.collection === "org.hypercerts.claim.activity"
- ? recordUrl(parsed.did, "activity", parsed.rkey)
- : parsed.collection === "org.hypercerts.collection"
- ? recordUrl(parsed.did, "project", parsed.rkey)
- : null
- : null
- return (
-
- )
-}
-
-// ---------------------------------- Card body ------------------------------
-
-/**
- * Hide the full-width image block entirely when the blob 404s — the
- * generic `hideBrokenThumb` only hides the ` `, which would leave
- * the empty aspect-square container dominating the card.
- */
-function hideBrokenCardImage(
- event: SyntheticEvent,
-): void {
- const wrap = event.currentTarget.closest(
- ".home-feed__card-image",
- )
- if (wrap) wrap.style.display = "none"
- else hideBrokenThumb(event)
-}
-
-/**
- * Record body below the byline — the certs.social feed-card layout:
- * full-width square image (when the record has one), serif headline
- * title (plus quality tags), 3-line-clamped short description, and a
- * dot-separated muted meta row. The whole body links to the record's
- * detail page; only the title underlines on hover so the card still
- * reads as a card.
- */
-function PreviewCard({
- href,
- title,
- tags,
- imageUrl,
- description,
- meta,
-}: {
- href: string | null
- title: string
- tags?: { key: string; label: string; tone: BadgeTone }[]
- imageUrl: string | null
- description: string | null
- meta: ReactNode[]
-}) {
- const body = (
- <>
- {imageUrl ? (
-
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- ) : null}
-
- {title}
- {tags?.map((t) => (
-
- {t.label}
-
- ))}
-
- {description ? (
- {description}
- ) : null}
- {meta.length > 0 ? (
-
- {meta.map((m, i) => (
-
- {m}
-
- ))}
-
- ) : null}
- >
- )
-
- if (href) {
- return (
-
- {body}
-
- )
- }
- return {body}
-}
-
-function formatPeriod(
- start: string | null,
- end: string | null,
-): string | null {
- if (!start && !end) return null
- const s = start ? formatShortDate(start) : null
- const e = end ? formatShortDate(end) : null
- if (s && e) return `${s} – ${e}`
- if (s) return `${s} (ongoing)`
- if (e) return `Until ${e}`
- return null
-}
diff --git a/src/components/home/home.tsx b/src/components/home/home.tsx
index 2cb9a154..74de21ef 100644
--- a/src/components/home/home.tsx
+++ b/src/components/home/home.tsx
@@ -22,7 +22,11 @@ import { resolveActivityImageUrl } from "@/lib/atproto/activity"
import { parseAtUri } from "@/lib/atproto/activity-uri"
import { getInitials } from "@/lib/utils/initials"
import { hideBrokenThumb } from "@/lib/utils/image-fallback"
-import type { CollectionRecord } from "@/lib/atproto/collection"
+import {
+ projectImage,
+ projectTitle,
+ type CollectionRecord,
+} from "@/lib/atproto/collection"
import type { ActivityRecord } from "@/lib/atproto/activity-types"
import type { OwnerTag } from "@/lib/atproto/owner-tag"
import type { Group } from "@/lib/groups/types"
@@ -277,20 +281,11 @@ function ProjectRow({
? recordUrl(parsed.did, "project", parsed.rkey)
: "#"
- const title =
- asString(project.value.title) ||
- asString(project.value.name) ||
- "Untitled project"
+ const title = projectTitle(project.value)
- const rawImage =
- (project.value as Record).banner ?? project.value.image
+ const rawImage = projectImage(project.value, "thumb")
const imageUrl =
- rawImage && did
- ? resolveActivityImageUrl(
- rawImage as Parameters[0],
- did,
- )
- : null
+ rawImage && did ? resolveActivityImageUrl(rawImage, did) : null
// Only group-owned records carry a "by {group}" line; personal records
// are the viewer's own, so no byline is shown.
@@ -378,7 +373,3 @@ function CertRow({
)
}
-
-function asString(v: unknown): string | null {
- return typeof v === "string" && v.length > 0 ? v : null
-}
diff --git a/src/components/landing/landing-page.tsx b/src/components/landing/landing-page.tsx
index edd222c0..61622c5d 100644
--- a/src/components/landing/landing-page.tsx
+++ b/src/components/landing/landing-page.tsx
@@ -14,26 +14,36 @@ import FaqSection from "@/components/landing/sections/faq-content";
import ClosingCta from "@/components/landing/sections/closing-cta";
import { CURATED_PROFILES } from "@/lib/constants/curated-profiles";
import { buildProfilePayload } from "@/app/api/resolve-did/resolve-core";
+import { fetchNetworkCountsServer } from "@/lib/atproto/network-counts-server";
/**
* The /welcome landing page. Server component: resolves the three
- * featured profiles for the Explore section at render time (the page
- * is ISR'd — see `revalidate` in src/app/welcome/page.tsx), so the
- * section ships real network content with zero client fetches.
- * buildProfilePayload swallows its own failures; the extra catch
- * guards the page against anything unexpected so a dead indexer or
- * PDS can never take the landing page down.
+ * featured profiles for the Explore section AND the five network
+ * counts for the stats strip at render time (the page is ISR'd — see
+ * `revalidate` in src/app/welcome/page.tsx), so both sections ship
+ * real network content with zero client fetches. Both helpers
+ * swallow their own failures; the extra catches guard the page
+ * against anything unexpected so a dead indexer or PDS can never
+ * take the landing page down.
*/
export default async function LandingPage() {
- const profiles: FeaturedProfile[] = await Promise.all(
- CURATED_PROFILES.map(async (curated) => {
- try {
- return { curated, resolved: await buildProfilePayload(curated.did) };
- } catch {
- return { curated, resolved: null };
- }
- }),
- );
+ const [profiles, counts] = await Promise.all([
+ Promise.all(
+ CURATED_PROFILES.map(
+ async (curated): Promise => {
+ try {
+ return {
+ curated,
+ resolved: await buildProfilePayload(curated.did),
+ };
+ } catch {
+ return { curated, resolved: null };
+ }
+ },
+ ),
+ ),
+ fetchNetworkCountsServer().catch(() => null),
+ ]);
return (
@@ -44,7 +54,7 @@ export default async function LandingPage() {
-
+
diff --git a/src/components/landing/sections/network-stats.tsx b/src/components/landing/sections/network-stats.tsx
index 0262419f..9598d145 100644
--- a/src/components/landing/sections/network-stats.tsx
+++ b/src/components/landing/sections/network-stats.tsx
@@ -2,12 +2,17 @@
import { useEffect, useRef, useState } from "react"
import { useNetworkCounts } from "@/hooks/use-network-counts"
+import type { NetworkCounts } from "@/lib/atproto/indexer"
/**
* Five live network-wide counters on the /welcome landing page —
* Users / Organizations / Projects / Activities / Endorsements.
*
- * Cells render `—` while the indexer fetch is in flight and on
+ * Counts arrive server-side via `initialCounts` (resolved during the
+ * ISR render of the landing page); the client fetch through the
+ * /api/indexer proxy runs only as a fallback for fields the server
+ * couldn't resolve, so the common anonymous visit costs zero count
+ * RPCs. Cells render `—` while a fallback fetch is in flight and on
* per-op failure; otherwise the formatted count with
* `Intl.NumberFormat` thousands separators. The count-up is gated on
* the section entering the viewport (the section sits far down the
@@ -20,8 +25,25 @@ import { useNetworkCounts } from "@/hooks/use-network-counts"
* small uppercase labels, divided by hairlines (see .lp-stats in
* landing.css).
*/
-export default function NetworkStats() {
- const { counts, isLoading } = useNetworkCounts()
+export default function NetworkStats({
+ initialCounts = null,
+}: {
+ initialCounts?: NetworkCounts | null
+}) {
+ const serverComplete =
+ initialCounts !== null &&
+ Object.values(initialCounts).every((v) => v !== null)
+ const { counts: clientCounts, isLoading } = useNetworkCounts(!serverComplete)
+ const counts: NetworkCounts = initialCounts
+ ? {
+ users: initialCounts.users ?? clientCounts.users,
+ organizations:
+ initialCounts.organizations ?? clientCounts.organizations,
+ achievements: initialCounts.achievements ?? clientCounts.achievements,
+ projects: initialCounts.projects ?? clientCounts.projects,
+ endorsements: initialCounts.endorsements ?? clientCounts.endorsements,
+ }
+ : clientCounts
const sectionRef = useRef
(null)
const [inView, setInView] = useState(false)
@@ -132,6 +154,7 @@ function useCountUp(target: number | null, delayMs: number): number | null {
"(prefers-reduced-motion: reduce)",
).matches
if (reduced) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- rAF count-up animation driver; the sync setDisplay writes the terminal value for the prefers-reduced-motion and zero-delta cases — the effect IS the animation's external-system sync
setDisplay(target)
fromRef.current = target
return
diff --git a/src/components/layout/desktop-top-bar.tsx b/src/components/layout/desktop-top-bar.tsx
index 0589c722..d127b7a5 100644
--- a/src/components/layout/desktop-top-bar.tsx
+++ b/src/components/layout/desktop-top-bar.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useEffect, useMemo, useRef, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseActor, profileUrl } from "@/lib/urls"
import {
CERT_DETAIL_TABS,
@@ -267,35 +267,35 @@ export default function DesktopTopBar() {
// Close the switcher on navigation. Positioning, click-outside and
// Esc-to-close are owned by the primitive.
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- close switcher on route change (external router input); covers back/forward nav that no click handler sees; setState bails out when already closed
setSwitcherOpen(false);
}, [pathname]);
// ----- Create-menu effects (mirror the switcher) -----
+ // Anchor the menu's LEFT edge to the trigger's left edge so it opens
+ // to the right of the "+" button (no chance of running off the
+ // viewport's left side when the button sits near the chrome's right
+ // edge — same affordance as GitHub's "+" menu). The initial compute
+ // happens in the trigger's onClick; a stale anchor while closed is
+ // unobservable because the portal gates on createOpen && createAnchor.
+ const computeCreateAnchor = useCallback(() => {
+ const rect = createRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ setCreateAnchor({
+ left: rect.left,
+ top: rect.bottom + 8,
+ });
+ }, []);
+
useEffect(() => {
- if (!createOpen || !createRef.current) {
- setCreateAnchor(null);
- return;
- }
- const compute = () => {
- const rect = createRef.current?.getBoundingClientRect();
- if (!rect) return;
- // Anchor the menu's LEFT edge to the trigger's left edge so it
- // opens to the right of the "+" button (no chance of running off
- // the viewport's left side when the button sits near the chrome's
- // right edge — same affordance as GitHub's "+" menu).
- setCreateAnchor({
- left: rect.left,
- top: rect.bottom + 8,
- });
- };
- compute();
- globalThis.addEventListener("resize", compute);
- globalThis.addEventListener("scroll", compute, true);
+ if (!createOpen) return;
+ globalThis.addEventListener("resize", computeCreateAnchor);
+ globalThis.addEventListener("scroll", computeCreateAnchor, true);
return () => {
- globalThis.removeEventListener("resize", compute);
- globalThis.removeEventListener("scroll", compute, true);
+ globalThis.removeEventListener("resize", computeCreateAnchor);
+ globalThis.removeEventListener("scroll", computeCreateAnchor, true);
};
- }, [createOpen]);
+ }, [createOpen, computeCreateAnchor]);
useEffect(() => {
if (!createOpen) return;
@@ -323,6 +323,7 @@ export default function DesktopTopBar() {
}, [createOpen]);
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- close create-menu on route change; same rationale as the switcher close above
setCreateOpen(false);
}, [pathname]);
@@ -382,6 +383,7 @@ export default function DesktopTopBar() {
{breadcrumb || desktopShownTitle ? (
) : (
+ // eslint-disable-next-line @next/next/no-img-element -- static same-origin SVG wordmark; next/image performs no optimization on SVG sources and the element is CSS-sized (no CLS)
setCreateOpen((v) => !v)}
+ onClick={() => {
+ if (!createOpen) computeCreateAnchor();
+ setCreateOpen((v) => !v);
+ }}
aria-haspopup="menu"
aria-expanded={createOpen}
aria-label="Create new"
@@ -563,6 +568,7 @@ export default function DesktopTopBar() {
className="desktop-top-bar__signin-btn"
aria-label="Sign in"
>
+ {/* eslint-disable-next-line @next/next/no-img-element -- static same-origin SVG sign-in glyph; no optimization benefit from next/image */}
+ {/* eslint-disable-next-line @next/next/no-img-element -- static same-origin SVG wordmark; no optimization benefit from next/image */}
{
// Close dropdowns on navigation
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- close dropdown/switcher on route change (external router input); both setStates bail out when already false
setDropdownOpen(false);
setSwitcherOpen(false);
}, [pathname]);
@@ -85,6 +86,7 @@ const Navbar: React.FC = () => {
// (the hamburger button and mobile sidebar unmount at ≥800px; leftover
// `dropdownOpen` would re-open the drawer on resize back down).
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- clear sheet/sidebar state when crossing the 800px boundary (external matchMedia input); bails out when already false
setDropdownOpen(false);
setSwitcherOpen(false);
}, [isDesktop]);
diff --git a/src/components/lists/add-to-list-menu.tsx b/src/components/lists/add-to-list-menu.tsx
index 91fad3d7..1908bd8c 100644
--- a/src/components/lists/add-to-list-menu.tsx
+++ b/src/components/lists/add-to-list-menu.tsx
@@ -15,7 +15,8 @@ import {
PopoverItem,
} from "@/components/ui/popover"
import { useAuth } from "@/lib/auth/auth-context"
-import { recordUrlFromAtUri } from "@/lib/urls"
+import { recordUrlFromAtUri, rkeyFromUri } from "@/lib/urls"
+import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { useTypedLists } from "@/hooks/use-typed-lists"
import {
itemUriMatchesType,
@@ -77,7 +78,10 @@ export default function AddToListMenu({
const { did: viewerDid, openSignIn } = useAuth()
const [open, setOpen] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
- const [copied, setCopied] = useState<"share" | "uri" | null>(null)
+ // One shared-hook instance per copy target so each menu item shows its
+ // own "Copied" label; the hook auto-resets the flags.
+ const { copied: shareCopied, copy: copyShare } = useCopyToClipboard()
+ const { copied: uriCopied, copy: copyUri } = useCopyToClipboard()
// DID-based web path for sharing — recordUrlFromAtUri maps the
// collection to its friendly route segment and defaults to the URI's
@@ -95,25 +99,16 @@ export default function AddToListMenu({
return shareTab ? `${base}?tab=${encodeURIComponent(shareTab)}` : base
}, [targetUri, shareTab])
- // Reset the brief "Copied" affordance whenever the popover opens
- // again, so the previous run's feedback doesn't leak across opens.
+ // Keep the popover open briefly after a successful copy so the user
+ // sees the confirmation, then auto-close. Driven off the hook's copied
+ // flags — the hook swallows clipboard failures (flag stays false), so
+ // a failed copy leaves the popover open.
+ const anyCopied = shareCopied || uriCopied
useEffect(() => {
- if (open) setCopied(null)
- }, [open])
-
- const copyText = useCallback(async (text: string, which: "share" | "uri") => {
- try {
- await navigator.clipboard.writeText(text)
- setCopied(which)
- // Keep the popover open briefly so the user sees the
- // confirmation, then auto-close.
- window.setTimeout(() => {
- setOpen(false)
- }, 900)
- } catch (err) {
- console.error("Failed to copy:", err)
- }
- }, [])
+ if (!anyCopied) return
+ const t = window.setTimeout(() => setOpen(false), 900)
+ return () => window.clearTimeout(t)
+ }, [anyCopied])
// Shown to logged-out viewers too: "Copy AT URI" works without auth,
// and "Add to list" funnels them to sign-in. Only the URI→type guard
@@ -170,11 +165,11 @@ export default function AddToListMenu({
{sharePath ? (
- copyText(`${window.location.origin}${sharePath}`, "share")
+ void copyShare(`${window.location.origin}${sharePath}`)
}
>
- {copied === "share" ? "Link copied" : "Share"}
+ {shareCopied ? "Link copied" : "Share"}
) : null}
Add to list
- copyText(targetUri, "uri")}>
+ void copyUri(targetUri)}>
- {copied === "uri" ? "Copied" : "Copy AT URI"}
+ {uriCopied ? "Copied" : "Copy AT URI"}
@@ -321,7 +316,7 @@ function AddToListModal({
const targetRef = await resolveTargetRef()
if (!targetRef) throw new Error("Couldn't resolve record CID")
const ref = await createList(targetType, title)
- const rkey = ref.uri.split("/").pop()
+ const rkey = rkeyFromUri(ref.uri)
if (!rkey) throw new Error("New list missing rkey")
await addItem(rkey, targetType, targetRef)
// Optimistic state: preview the new list immediately so it
diff --git a/src/components/onboarding/onboarding-modal.tsx b/src/components/onboarding/onboarding-modal.tsx
index 60c4c4cf..ea21e845 100644
--- a/src/components/onboarding/onboarding-modal.tsx
+++ b/src/components/onboarding/onboarding-modal.tsx
@@ -1,17 +1,17 @@
"use client"
-import { useCallback, useEffect, useRef, useState } from "react"
+import { useCallback, useEffect, useMemo, useState } from "react"
import { profileUrl } from "@/lib/urls"
import AppDialog from "@/components/ui/app-dialog"
import Button from "@/components/ui/button"
import { useAuth } from "@/lib/auth/auth-context"
import { useSession } from "@/hooks/use-session"
-import { useOnboarding } from "@/lib/onboarding/onboarding-context"
+import {
+ useOnboarding,
+ type BlueskySeed,
+} from "@/lib/onboarding/onboarding-context"
import { useSocialGraphSync } from "@/hooks/use-social-graph-sync"
-import StepProfile, {
- type ProfileDraft,
- emptyProfileDraft,
-} from "./steps/step-profile"
+import StepProfile, { type ProfileDraft } from "./steps/step-profile"
import StepGraph, { type GraphIntent } from "./steps/step-graph"
import {
useOnboardingCommit,
@@ -41,6 +41,10 @@ const STEP_TITLES: Record = {
* screen. The footer's primary button is dual-purpose: on Step 1
* it's "Continue", on Step 2 it labels the actual finish action
* based on the selected sync intent.
+ *
+ * The form child mounts fresh per open and per DID (key={did}), so
+ * its useState initializers do the bsky seeding — every open starts
+ * from the seed, and a draft can never leak across account switches.
*/
export default function OnboardingModal() {
const { isOpen, bskySeed, dismissOnboarding, completeOnboarding } =
@@ -48,50 +52,63 @@ export default function OnboardingModal() {
const { did } = useAuth()
const { handle } = useSession()
- const [step, setStep] = useState("profile")
- const [profileDraft, setProfileDraft] = useState(() =>
- emptyProfileDraft(),
+ if (!isOpen) return null
+ if (!did) return null
+
+ return (
+
)
+}
+
+interface OnboardingModalContentProps {
+ readonly did: string
+ readonly handle: string | null
+ readonly bskySeed: BlueskySeed | null
+ /** User skipped or closed the modal (sets the dismissed sentinel). */
+ onDismiss: () => void
+ /** Commit pipeline finished successfully. */
+ onComplete: () => void
+}
+
+function OnboardingModalContent({
+ did,
+ handle,
+ bskySeed,
+ onDismiss,
+ onComplete,
+}: OnboardingModalContentProps) {
+ const [step, setStep] = useState("profile")
+ // Seeded once at mount from the bsky values available when the modal
+ // opened (the auto-popup only fires after resolve-did settles).
+ const [profileDraft, setProfileDraft] = useState(() => ({
+ displayName: bskySeed?.displayName?.trim() ?? "",
+ description: bskySeed?.description?.trim() ?? "",
+ website: "",
+ sourceAvatarUrl: bskySeed?.avatar ?? null,
+ sourceBannerUrl: bskySeed?.banner ?? null,
+ replacementAvatarFile: null,
+ replacementBannerFile: null,
+ }))
const [graphIntent, setGraphIntent] = useState({
kind: "all",
})
const [selected, setSelected] = useState>(() => new Set())
- // Seed the profile draft from bsky values the first time the modal
- // opens for this DID. Subsequent re-opens (banner clicks) keep
- // whatever the user already typed.
- const seededForDid = useRef(null)
- useEffect(() => {
- if (!isOpen) return
- if (!did) return
- if (seededForDid.current === did) return
- seededForDid.current = did
- setProfileDraft({
- displayName: bskySeed?.displayName?.trim() ?? "",
- description: bskySeed?.description?.trim() ?? "",
- website: "",
- sourceAvatarUrl: bskySeed?.avatar ?? null,
- sourceBannerUrl: bskySeed?.banner ?? null,
- replacementAvatarFile: null,
- replacementBannerFile: null,
- })
- setGraphIntent({ kind: "all" })
- setSelected(new Set())
- setStep("profile")
- }, [isOpen, did, bskySeed])
-
- useEffect(() => {
- if (!isOpen) seededForDid.current = null
- }, [isOpen])
-
// Lifted to the modal so Step 2 can render stats inline and the
// commit pipeline shares the same hook instance during in-place
// imports (no double-fetch).
- const sync = useSocialGraphSync(did ?? "", { ownDid: did ?? "" })
+ const sync = useSocialGraphSync(did, { ownDid: did })
const commit = useOnboardingCommit({
did,
- onSuccess: completeOnboarding,
+ onSuccess: onComplete,
})
const runCommit = useCallback(
@@ -138,20 +155,30 @@ export default function OnboardingModal() {
const handleClose = useCallback(() => {
if (commit.state.status === "running") return
- dismissOnboarding()
- }, [commit.state.status, dismissOnboarding])
+ onDismiss()
+ }, [commit.state.status, onDismiss])
- if (!isOpen) return null
- if (!did) return null
+ // Object URL for a replacement avatar, revoked on change/unmount —
+ // creating it inline in the success render leaked one URL per
+ // re-render.
+ const replacementAvatarUrl = useMemo(
+ () =>
+ profileDraft.replacementAvatarFile
+ ? URL.createObjectURL(profileDraft.replacementAvatarFile)
+ : null,
+ [profileDraft.replacementAvatarFile],
+ )
+ useEffect(() => {
+ return () => {
+ if (replacementAvatarUrl) URL.revokeObjectURL(replacementAvatarUrl)
+ }
+ }, [replacementAvatarUrl])
// Success state takes over the whole modal — no steps, no body,
// just celebration. Button does a full reload so resolve-did's
// 10s own-DID cache doesn't serve stale data to the profile page.
if (commit.state.status === "success") {
- const previewUrl =
- (profileDraft.replacementAvatarFile
- ? URL.createObjectURL(profileDraft.replacementAvatarFile)
- : profileDraft.sourceAvatarUrl) || null
+ const previewUrl = replacementAvatarUrl || profileDraft.sourceAvatarUrl || null
const goToProfile = () => {
const target = handle ? profileUrl(handle) : "/"
window.location.assign(target)
@@ -165,6 +192,7 @@ export default function OnboardingModal() {
>
{previewUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element -- previewUrl is a blob: object URL (picked file) or an arbitrary bsky-CDN avatar; next/image supports neither (remotePatterns is limited to **.certified.app)
void
}) {
const { info, isLoading } = useAuthorInfo(did)
- const name = info?.displayName || info?.handle || did
- const handle = info?.handle && info.handle !== info.did ? info.handle : null
+ const { displayName: name, handle, initials } = deriveIdentity(info, did)
return (
@@ -320,7 +319,7 @@ function PickerRow({
)}
diff --git a/src/components/onboarding/steps/step-profile.tsx b/src/components/onboarding/steps/step-profile.tsx
index 87b294c4..5866c9bf 100644
--- a/src/components/onboarding/steps/step-profile.tsx
+++ b/src/components/onboarding/steps/step-profile.tsx
@@ -22,18 +22,6 @@ export interface ProfileDraft {
replacementBannerFile: File | null
}
-export function emptyProfileDraft(): ProfileDraft {
- return {
- displayName: "",
- description: "",
- website: "",
- sourceAvatarUrl: null,
- sourceBannerUrl: null,
- replacementAvatarFile: null,
- replacementBannerFile: null,
- }
-}
-
interface StepProfileProps {
readonly draft: ProfileDraft
onChange: (draft: ProfileDraft) => void
@@ -90,6 +78,7 @@ export default function StepProfile({
{previewBannerUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element -- previewBannerUrl is a blob: object URL or an arbitrary remote bsky banner; next/image supports neither (remotePatterns is limited to **.certified.app)
{previewAvatarUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element -- previewAvatarUrl is a blob: object URL or an arbitrary remote bsky avatar; same remotePatterns constraint as the banner above
- {isLoading && !info ? (
-
- ) : (
-
- )}
-
-
{displayName}
- {handle ? (
-
@{handle}
- ) : null}
-
- {formatShortDate(createdAt)}
-
- {note ? (
-
{note}
- ) : null}
-
- >
+ // Same derivation the shared row renders, so the confirm dialog and
+ // aria-label match the visible display name.
+ const { displayName } = deriveIdentity(
+ info,
+ subjectDid ?? "",
+ subjectDid ? undefined : { fallbackLabel: "Unknown" },
)
return (
- {href ? (
-
- {body}
-
- ) : (
- {body}
- )}
- {revoke ? (
-
- ) : null}
+
+ ) : null
+ }
+ />
)
}
diff --git a/src/components/profile/list-modals.tsx b/src/components/profile/list-modals.tsx
new file mode 100644
index 00000000..46caf517
--- /dev/null
+++ b/src/components/profile/list-modals.tsx
@@ -0,0 +1,698 @@
+"use client"
+
+import { useCallback, useEffect, useRef, useState } from "react"
+import { Search } from "lucide-react"
+import AppDialog, { AppDialogHeader, AppDialogBody } from "@/components/ui/app-dialog"
+import Avatar from "@/components/ui/avatar"
+import Button from "@/components/ui/button"
+import LoadingSpinner from "@/components/ui/loading-spinner"
+import { fetchIndexerActivities, postIndexer } from "@/lib/atproto/indexer"
+import { searchMergedActors } from "@/lib/atproto/actor-search"
+import {
+ ITEM_NSID,
+ LIST_ACCOUNTS_TYPE,
+ LIST_CERTS_TYPE,
+ itemUriMatchesType,
+ resolveRecordCid,
+ type TypedListType,
+} from "@/lib/atproto/typed-lists"
+import { resolveActivityImageUrl } from "@/lib/atproto/activity"
+import { parseAtUri } from "@/lib/atproto/activity-uri"
+import { getInitials } from "@/lib/utils/initials"
+
+// The three modals ProfileLists opens: create/edit a list, bulk-add by
+// pasted at-URI, and search-driven add. Each keeps its own form state;
+// the parent only supplies the list type + membership and the write
+// callbacks.
+
+// ----------------------------- Create / edit modal -----------------------------
+
+export function CreateListModal({
+ mode = "create",
+ type,
+ initialTitle = "",
+ initialDescription = "",
+ onSubmit,
+ onCancel,
+}: {
+ /** `"create"` (default) shows the Create wording; `"edit"` swaps in
+ * the Save wording and pre-fills the title + description. The
+ * underlying form chrome is identical so both modes read as the
+ * same UI surface. */
+ mode?: "create" | "edit"
+ type: TypedListType
+ initialTitle?: string
+ initialDescription?: string
+ onSubmit: (title: string, description?: string) => Promise
+ onCancel: () => void
+}) {
+ const inputRef = useRef(null)
+ const [title, setTitle] = useState(initialTitle)
+ const [description, setDescription] = useState(initialDescription)
+ const [isWriting, setIsWriting] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ }, [])
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (isWriting || !title.trim()) return
+ setIsWriting(true)
+ setError(null)
+ try {
+ await onSubmit(title.trim(), description.trim() || undefined)
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : mode === "edit"
+ ? "Failed to save list"
+ : "Failed to create list",
+ )
+ setIsWriting(false)
+ }
+ }
+
+ const titleCopy = mode === "edit" ? `Edit ${LABELS[type]} list` : `Create ${LABELS[type]} list`
+ const submitCopy = mode === "edit" ? "Save" : "Create list"
+
+ return (
+ !isWriting && onCancel()}
+ disableBackdropClose={isWriting}
+ >
+ !isWriting && onCancel()}
+ disabled={isWriting}
+ />
+
+
+ )
+}
+
+const LABELS: Record = {
+ "list:certs": "activities",
+ "list:projects": "projects",
+ "list:accounts": "accounts",
+}
+
+// ----------------------------- Add-items modal -----------------------------
+
+interface SearchResult {
+ uri: string
+ cid: string
+ title: string
+ subtitle?: string | null
+ imageUrl?: string | null
+ avatarUrl?: string | null
+ initials?: string | null
+}
+
+// ----------------------------- Bulk-paste modal -----------------------------
+
+interface ParseRow {
+ uri: string
+ status: "pending" | "writing" | "added" | "already" | "wrong-type" | "missing" | "error"
+ message?: string
+}
+
+export function PasteUrisModal({
+ type,
+ alreadyIn,
+ onAddMany,
+ onClose,
+}: {
+ type: TypedListType
+ alreadyIn: Set
+ onAddMany: (
+ items: readonly { uri: string; cid: string }[],
+ ) => Promise
+ onClose: () => void
+}) {
+ const [raw, setRaw] = useState("")
+ const [rows, setRows] = useState([])
+ const [running, setRunning] = useState(false)
+ const textareaRef = useRef(null)
+
+ useEffect(() => {
+ textareaRef.current?.focus()
+ }, [])
+
+ const handleRun = useCallback(async () => {
+ if (running) return
+ // Accept commas, newlines, and whitespace as separators so users
+ // can paste a list of URIs from any reasonable source without
+ // hand-formatting it. Dedupe within the input.
+ // Accounts list: accept a bare DID (`did:plc:…`) or an actor URI
+ // (`at://did:plc:…`), with or without a trailing slash, and expand it
+ // to the conventional profile record path
+ // (`at:///app.certified.actor.profile/self`) before validation —
+ // the profile record's rkey is always `self`. For certs / projects the
+ // rkey is record-specific, so those URIs are left untouched.
+ const normalize = (uri: string): string => {
+ if (type !== LIST_ACCOUNTS_TYPE) return uri
+ const m = uri.match(/^(?:at:\/\/)?(did:[a-z]+:[A-Za-z0-9._:-]+)\/?$/)
+ return m ? `at://${m[1]}/${ITEM_NSID[LIST_ACCOUNTS_TYPE]}/self` : uri
+ }
+
+ const parsed = Array.from(
+ new Set(
+ raw
+ .split(/[\s,]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map(normalize),
+ ),
+ )
+ if (parsed.length === 0) return
+
+ const initial: ParseRow[] = parsed.map((uri) => {
+ if (!uri.startsWith("at://")) {
+ return { uri, status: "error", message: "Not an at:// URI" }
+ }
+ if (!itemUriMatchesType(uri, type)) {
+ return { uri, status: "wrong-type", message: `Doesn't match ${ITEM_NSID[type]}` }
+ }
+ if (alreadyIn.has(uri)) {
+ return { uri, status: "already", message: "Already in list" }
+ }
+ return { uri, status: "pending" }
+ })
+ setRows(initial)
+ setRunning(true)
+
+ // Phase 1: parallel CID resolution. Each row flips to "writing"
+ // as its lookup starts, then either "missing" (no record) or
+ // stays writeable. This is the long pole — CID resolution is
+ // a PDS round-trip per URI but the requests fan out in parallel.
+ const writable = initial.filter((r) => r.status === "pending")
+ const resolved = await Promise.all(
+ writable.map(async (row) => {
+ setRows((prev) =>
+ prev.map((r) =>
+ r.uri === row.uri ? { ...r, status: "writing" } : r,
+ ),
+ )
+ try {
+ const cid = await resolveRecordCid(row.uri)
+ if (!cid) {
+ setRows((prev) =>
+ prev.map((r) =>
+ r.uri === row.uri
+ ? { ...r, status: "missing", message: "Record not found on PDS" }
+ : r,
+ ),
+ )
+ return null
+ }
+ return { uri: row.uri, cid }
+ } catch (err) {
+ setRows((prev) =>
+ prev.map((r) =>
+ r.uri === row.uri
+ ? {
+ ...r,
+ status: "error",
+ message: err instanceof Error ? err.message : "Lookup failed",
+ }
+ : r,
+ ),
+ )
+ return null
+ }
+ }),
+ )
+
+ // Phase 2: single bulk append. One getRecord + one putRecord on
+ // the list, regardless of how many items got resolved — much
+ // cheaper than the prior per-item RMW loop. If the swap fails
+ // (concurrent edit), every row that would've been added is
+ // marked error so the viewer can retry.
+ const validItems = resolved.filter(
+ (r): r is { uri: string; cid: string } => r !== null,
+ )
+ if (validItems.length > 0) {
+ try {
+ await onAddMany(validItems)
+ const validUris = new Set(validItems.map((it) => it.uri))
+ setRows((prev) =>
+ prev.map((r) =>
+ validUris.has(r.uri)
+ ? { ...r, status: "added", message: undefined }
+ : r,
+ ),
+ )
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Add failed"
+ const validUris = new Set(validItems.map((it) => it.uri))
+ setRows((prev) =>
+ prev.map((r) =>
+ validUris.has(r.uri) ? { ...r, status: "error", message } : r,
+ ),
+ )
+ }
+ }
+ setRunning(false)
+ }, [alreadyIn, onAddMany, raw, running, type])
+
+ return (
+ !running && onClose()}
+ disableBackdropClose={running}
+ >
+ !running && onClose()}
+ disabled={running}
+ />
+
+
+ Paste at-URIs separated by commas, newlines, or spaces.
+ Only items matching{" "}
+ {ITEM_NSID[type]}{" "}
+ will be added.
+ {type === LIST_ACCOUNTS_TYPE ? (
+ <>
+ {" "}For accounts a bare DID{" "}
+ did:plc:…{" "}
+ (with or without at://)
+ is also accepted — we’ll expand it to the profile record.
+ >
+ ) : null}
+
+ setRaw(e.target.value)}
+ rows={6}
+ placeholder={
+ type === LIST_ACCOUNTS_TYPE
+ ? "did:plc:…, did:plc:…, …"
+ : "at://did:plc:…/…/abc123, at://did:plc:…/…/def456, …"
+ }
+ disabled={running}
+ />
+ {rows.length > 0 ? (
+ <>
+
+
+ {rows.map((r) => (
+
+ {statusLabel(r.status)}
+ {r.uri}
+ {r.message ? (
+ {r.message}
+ ) : null}
+
+ ))}
+
+ >
+ ) : null}
+
+
+ {rows.some((r) => r.status === "added") ? "Done" : "Cancel"}
+
+
+ Add
+
+
+
+
+ )
+}
+
+function statusLabel(s: ParseRow["status"]): string {
+ switch (s) {
+ case "pending":
+ return "Pending"
+ case "writing":
+ return "Writing…"
+ case "added":
+ return "Added"
+ case "already":
+ return "Already in"
+ case "wrong-type":
+ return "Wrong type"
+ case "missing":
+ return "Not found"
+ case "error":
+ return "Error"
+ }
+}
+
+/**
+ * Overall progress strip above the per-row results list. Shows a
+ * spinner + "N of M done" while the run is in flight, and a filled
+ * progress bar that animates from 0% → 100%. After the loop ends
+ * the spinner drops; the counter stays so the viewer has a record
+ * of what landed. `aria-live="polite"` so screen readers get
+ * progress updates without the page yanking focus around.
+ */
+function PasteProgress({
+ rows,
+ running,
+}: {
+ rows: ParseRow[]
+ running: boolean
+}) {
+ const total = rows.length
+ // "Resolved" = anything that's no longer pending / writing. Counts
+ // every terminal state (added / already / wrong-type / missing /
+ // error) so the bar fills as the loop progresses regardless of
+ // whether each row succeeded.
+ const resolved = rows.filter(
+ (r) => r.status !== "pending" && r.status !== "writing",
+ ).length
+ const added = rows.filter((r) => r.status === "added").length
+ const percent = total === 0 ? 0 : Math.round((resolved / total) * 100)
+ return (
+
+
+ {running ? (
+
+ ) : null}
+
+ {running
+ ? `Adding ${resolved} of ${total}…`
+ : `${added} of ${total} added`}
+
+ {percent}%
+
+
+
+ )
+}
+
+export function AddItemsModal({
+ type,
+ alreadyIn,
+ onAdd,
+ onClose,
+}: {
+ type: TypedListType
+ alreadyIn: Set
+ onAdd: (item: { uri: string; cid: string }) => Promise
+ onClose: () => void
+}) {
+ const [query, setQuery] = useState("")
+ const [results, setResults] = useState([])
+ const [searching, setSearching] = useState(false)
+ const [error, setError] = useState(null)
+ const [addingUri, setAddingUri] = useState(null)
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ // Debounced search keyed by query. Empty query clears results
+ // immediately rather than firing a "show me everything" request.
+ useEffect(() => {
+ const trimmed = query.trim()
+ if (trimmed.length === 0) {
+ setResults([])
+ setSearching(false)
+ setError(null)
+ return
+ }
+ setSearching(true)
+ const controller = new AbortController()
+ const handle = window.setTimeout(async () => {
+ try {
+ const next = await runSearch(type, trimmed, controller.signal)
+ if (controller.signal.aborted) return
+ setResults(next)
+ setError(null)
+ } catch (err) {
+ if (controller.signal.aborted) return
+ setError(err instanceof Error ? err.message : "Search failed")
+ setResults([])
+ } finally {
+ if (!controller.signal.aborted) setSearching(false)
+ }
+ }, 250)
+ return () => {
+ controller.abort()
+ window.clearTimeout(handle)
+ }
+ }, [type, query])
+
+ const handleAdd = async (result: SearchResult) => {
+ if (addingUri) return
+ setAddingUri(result.uri)
+ try {
+ // Bluesky's actor search doesn't return profile-record CIDs, so
+ // for account-list items we resolve the CID on click before
+ // writing the strongRef. Cert + project searches already carry
+ // the CID inline from the indexer.
+ const cid = result.cid || (await resolveRecordCid(result.uri))
+ if (!cid) throw new Error("Couldn't resolve record CID")
+ await onAdd({ uri: result.uri, cid })
+ } catch (err) {
+ console.error("Failed to add item:", err)
+ setError(err instanceof Error ? err.message : "Failed to add item")
+ } finally {
+ setAddingUri(null)
+ }
+ }
+
+ return (
+
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder={SEARCH_PLACEHOLDERS[type]}
+ />
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {searching ? (
+
+
+
+ ) : query.trim() && results.length === 0 ? (
+ No matches.
+ ) : results.length > 0 ? (
+
+ {results.map((r) => {
+ const isIn = alreadyIn.has(r.uri)
+ return (
+
+
+
+ {r.imageUrl ? (
+ /* eslint-disable-next-line @next/next/no-img-element -- dynamic bsky-CDN/blob URL; next/image remotePatterns limited to **.certified.app */
+
+ ) : type === LIST_ACCOUNTS_TYPE ? (
+
+ ) : (
+
+ )}
+
+
+ {r.title}
+ {r.subtitle ? (
+ {r.subtitle}
+ ) : null}
+
+
handleAdd(r)}
+ loading={addingUri === r.uri}
+ disabled={isIn || (!!addingUri && addingUri !== r.uri)}
+ >
+ {isIn ? "Added" : "Add"}
+
+
+
+ )
+ })}
+
+ ) : null}
+
+
+ )
+}
+
+const SEARCH_PLACEHOLDERS: Record = {
+ "list:certs": "Search activities by title",
+ "list:projects": "Search projects by title",
+ "list:accounts": "Search accounts by handle or name",
+}
+
+// ----------------------------- Search drivers -----------------------------
+
+async function runSearch(
+ type: TypedListType,
+ query: string,
+ signal: AbortSignal,
+): Promise {
+ if (type === LIST_ACCOUNTS_TYPE) return searchAccounts(query, signal)
+ if (type === LIST_CERTS_TYPE) return searchCerts(query, signal)
+ return searchProjects(query, signal)
+}
+
+async function searchAccounts(query: string, signal: AbortSignal): Promise {
+ // Account-list membership must NOT require a Certified profile, so search
+ // Certified + Bluesky merged (see `searchMergedActors`). Each result
+ // strong-refs the profile record it actually has (`profileNsid`) — rkey
+ // is the literal "self" — so the URI always targets an existing record
+ // and `resolveRecordCid` succeeds on add.
+ const merged = await searchMergedActors(query, signal)
+ return merged.map((a) => ({
+ uri: `at://${a.did}/${a.profileNsid}/self`,
+ cid: "",
+ title: a.displayName || (a.handle ? `@${a.handle}` : a.did),
+ subtitle: a.handle ? `@${a.handle}` : null,
+ avatarUrl: a.avatarUrl,
+ initials: getInitials(a.displayName, a.handle ?? a.did),
+ }))
+}
+
+async function searchCerts(query: string, signal: AbortSignal): Promise {
+ const result = await fetchIndexerActivities({
+ first: 10,
+ search: query,
+ signal,
+ })
+ const out: SearchResult[] = []
+ for (const rec of result.records) {
+ const parsed = parseAtUri(rec.uri)
+ const imageUrl =
+ rec.value.image && parsed
+ ? resolveActivityImageUrl(rec.value.image, parsed.did)
+ : null
+ out.push({
+ uri: rec.uri,
+ cid: rec.cid,
+ title:
+ typeof rec.value.title === "string" && rec.value.title.length > 0
+ ? rec.value.title
+ : "Untitled activity",
+ subtitle: null,
+ imageUrl,
+ })
+ }
+ return out
+}
+
+interface ProjectSearchData {
+ orgHypercertsCollection?: {
+ edges: {
+ node: {
+ uri: string
+ cid: string
+ title: string | null
+ shortDescription: string | null
+ } | null
+ }[]
+ } | null
+}
+
+async function searchProjects(query: string, signal: AbortSignal): Promise {
+ const res = await postIndexer(
+ "Projects",
+ { first: 10, after: null, authors: null, search: query },
+ { signal },
+ )
+ if (!res.ok) throw new Error(`Project search failed: ${res.status}`)
+ const out: SearchResult[] = []
+ for (const edge of res.data?.orgHypercertsCollection?.edges ?? []) {
+ if (!edge.node) continue
+ out.push({
+ uri: edge.node.uri,
+ cid: edge.node.cid,
+ title: edge.node.title?.trim() || "Untitled project",
+ subtitle: edge.node.shortDescription || null,
+ })
+ }
+ return out
+}
diff --git a/src/components/profile/person-card.tsx b/src/components/profile/person-card.tsx
index fd847650..b72eb828 100644
--- a/src/components/profile/person-card.tsx
+++ b/src/components/profile/person-card.tsx
@@ -1,11 +1,10 @@
"use client"
import Link from "next/link"
-import { profileUrl } from "@/lib/urls"
import type { AuthorInfo } from "@/hooks/use-author-info"
import Avatar from "@/components/ui/avatar"
import { formatShortDate } from "@/lib/utils/format-date"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
/**
* Shared person row used by the profile Endorsements and Followers
@@ -43,10 +42,12 @@ export default function PersonCard({
* plug in the per-card revoke / unfollow affordance. */
menu?: React.ReactNode
}) {
- const displayName = info?.displayName || info?.handle || did
- const handle = info?.handle && info.handle !== info.did ? info.handle : null
- const initials = getInitials(info?.displayName, info?.handle ?? did)
- const href = profileUrl(info?.handle || did)
+ const {
+ displayName,
+ handle,
+ initials,
+ profileHref: href,
+ } = deriveIdentity(info, did)
return (
diff --git a/src/components/profile/profile-endorsement-views.tsx b/src/components/profile/profile-endorsement-views.tsx
new file mode 100644
index 00000000..850c2325
--- /dev/null
+++ b/src/components/profile/profile-endorsement-views.tsx
@@ -0,0 +1,847 @@
+"use client"
+
+import { memo, useState } from "react"
+import { Inbox, ThumbsUp, X, type LucideIcon } from "lucide-react"
+import type { GivenEndorsement } from "@/hooks/use-endorsements"
+import type { ReceivedEndorsement } from "@/hooks/use-received-endorsements"
+import { useOwnResponseStates } from "@/hooks/use-own-response-states"
+import { useAuthorInfo, type AuthorInfo } from "@/hooks/use-author-info"
+import { buildAvatarUrlFromCid } from "@/lib/atproto/profile"
+import { deleteEndorsementAward } from "@/lib/atproto/badges"
+import { deriveIdentity } from "@/lib/utils/identity"
+import ResponseMenu from "@/components/badges/response-menu"
+import Button from "@/components/ui/button"
+import Checkbox from "@/components/ui/checkbox"
+import ConfirmDialog from "@/components/ui/confirm-dialog"
+import EmptyState from "@/components/ui/empty-state"
+import EndorsementSubjectRow, {
+ type EndorsementSubjectRowClasses,
+} from "@/components/endorsements/endorsement-subject-row"
+import LoadingSpinner from "@/components/ui/loading-spinner"
+import PersonCard from "@/components/profile/person-card"
+import Tooltip from "@/components/ui/tooltip"
+
+// Presentational views below ProfileEndorsements' fold: the Received /
+// Given grids and lists, their cards and rows, the bulk-action bar, and
+// the pure filter + sort helpers. All state lives in the parent — every
+// unit here receives data plus stable callbacks via props.
+
+export type SortKey =
+ | "created-desc"
+ | "created-asc"
+ | "alpha-asc"
+ | "alpha-desc"
+
+export type ResponseFilterKey = "hide-rejected" | "only-rejected" | "show-all"
+
+// ----------------------------- Received -----------------------------
+
+interface ReceivedGridProps {
+ /** Already filtered + sorted by the parent (`visibleReceived`); the
+ * grid renders it directly instead of recomputing the sort. */
+ visible: ReceivedEndorsement[]
+ /** Size of the pre-search set — drives the "No endorsements yet" vs
+ * "No matches" empty-state split. */
+ total: number
+ isLoading: boolean
+ error: string | null
+ viewerIsOwner: boolean
+ viewerDid: string | null
+ /** Group DID when acting AS this group — accept/reject responses route
+ * to the group's repo. Undefined for personal responses. */
+ targetDid?: string
+ /** Active response filter — used by the empty-state copy so a
+ * zero-results "Show only rejected" view says "No rejected
+ * endorsements yet" instead of the generic "No endorsements
+ * yet." */
+ responseFilter: ResponseFilterKey
+ resolve: ReturnType["resolve"]
+ allResponses: ReturnType["responses"]
+ onAfterWrite: () => void | Promise
+}
+
+export function ReceivedGrid({
+ visible,
+ total,
+ isLoading,
+ error,
+ viewerIsOwner,
+ viewerDid,
+ targetDid,
+ responseFilter,
+ resolve,
+ allResponses,
+ onAfterWrite,
+}: ReceivedGridProps) {
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (error) {
+ return (
+
+
+
+ )
+ }
+ if (visible.length === 0) {
+ // Three empty-state cases:
+ // 1. The "Show only rejected" filter is active with no matches
+ // — phrase the empty state in terms of the filter so the
+ // user knows nothing is missing, the filter just has no
+ // hits yet.
+ // 2. There's a search query / sort filter but the pre-filter
+ // set is non-empty — "No matches."
+ // 3. The user has zero endorsements total — the generic
+ // "No endorsements yet" CTA.
+ const onlyRejectedActive = responseFilter === "only-rejected"
+ const title = onlyRejectedActive
+ ? "No rejected endorsements yet"
+ : total === 0
+ ? "No endorsements yet"
+ : "No matches"
+ const description = onlyRejectedActive
+ ? "Endorsements you reject will appear here."
+ : total === 0
+ ? "Endorsements from other people will appear here."
+ : "No endorsements match your search."
+ return (
+
+
+
+ )
+ }
+ return (
+
+ {visible.map((e) => (
+
+ ))}
+
+ )
+}
+
+const ReceivedCard = memo(function ReceivedCard({
+ endorsement,
+ viewerIsOwner,
+ viewerDid,
+ targetDid,
+ resolve,
+ allResponses,
+ onAfterWrite,
+}: {
+ endorsement: ReceivedEndorsement
+ viewerIsOwner: boolean
+ viewerDid: string | null
+ targetDid?: string
+ resolve: ReturnType["resolve"]
+ allResponses: ReturnType["responses"]
+ onAfterWrite: () => void | Promise
+}) {
+ const { info, isLoading } = useReceivedIssuerInfo(endorsement)
+
+ return (
+
+ ) : null
+ }
+ />
+ )
+})
+
+// ------------------------------ Given -------------------------------
+
+interface GivenGridProps {
+ /** Already filtered + sorted by the parent (`visibleGiven`); the grid
+ * renders it directly instead of recomputing the sort. */
+ visible: GivenEndorsement[]
+ /** Size of the pre-search set — drives the "No endorsements given yet"
+ * vs "No matches" empty-state split. */
+ total: number
+ isLoading: boolean
+ error: string | null
+ /** True when the profile being viewed is the signed-in user's
+ * own profile — i.e. the cards represent endorsements THEY
+ * issued. Controls whether the per-card revoke `×` renders. */
+ viewerIsOwner: boolean
+ viewerDid: string | null
+ /** Group DID when the viewer is acting AS this group — revokes route
+ * to the group repo. Undefined for personal revokes. */
+ targetDid?: string
+ onAfterRevoke: () => void | Promise
+}
+
+export function GivenGrid({
+ visible,
+ total,
+ isLoading,
+ error,
+ viewerIsOwner,
+ viewerDid,
+ targetDid,
+ onAfterRevoke,
+}: GivenGridProps) {
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (error) {
+ return (
+
+
+
+ )
+ }
+ if (visible.length === 0) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+ {visible.map((e) => (
+
+ ))}
+
+ )
+}
+
+const GivenCard = memo(function GivenCard({
+ endorsement,
+ canRevoke,
+ viewerDid,
+ targetDid,
+ onAfterRevoke,
+}: {
+ endorsement: GivenEndorsement
+ canRevoke: boolean
+ viewerDid: string | null
+ targetDid?: string
+ onAfterRevoke: () => void | Promise
+}) {
+ const { info, isLoading } = useAuthorInfo(endorsement.subjectDid)
+ return (
+
+ ) : null
+ }
+ />
+ )
+})
+
+/**
+ * Small `×` revoke affordance shown on the owner's Given grid.
+ * Click → ConfirmDialog ("Revoke endorsement?") → on confirm,
+ * `deleteEndorsementAward` runs and the parent's `onAfterRevoke`
+ * refetches the Given list so the card disappears.
+ */
+function RevokeGivenButton({
+ viewerDid,
+ rkeys,
+ targetDid,
+ subjectDisplay,
+ onAfterRevoke,
+}: {
+ viewerDid: string
+ /** All award rkeys for this recipient — revoke removes every one so a
+ * recipient endorsed more than once disappears in a single click. */
+ rkeys: string[]
+ targetDid?: string
+ subjectDisplay: string
+ onAfterRevoke: () => void | Promise
+}) {
+ const [confirmOpen, setConfirmOpen] = useState(false)
+ const [isRevoking, setIsRevoking] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleConfirm = async () => {
+ if (isRevoking) return
+ setIsRevoking(true)
+ setError(null)
+ try {
+ for (const rkey of rkeys) {
+ await deleteEndorsementAward(viewerDid, rkey, { targetDid })
+ }
+ await onAfterRevoke()
+ setConfirmOpen(false)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to revoke")
+ } finally {
+ setIsRevoking(false)
+ }
+ }
+
+ return (
+ <>
+
+ {
+ // PersonCard's outer Link otherwise catches the click and
+ // navigates to the subject's profile.
+ e.preventDefault()
+ e.stopPropagation()
+ setConfirmOpen(true)
+ }}
+ aria-label={`Revoke endorsement of ${subjectDisplay}`}
+ >
+
+
+
+ {confirmOpen ? (
+ !isRevoking && setConfirmOpen(false)}
+ />
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ >
+ )
+}
+
+// ----------------------- List view + bulk select -----------------------
+
+/**
+ * Compose the issuer's AuthorInfo from the indexer's denormalised block,
+ * filling gaps via `useAuthorInfo`. The indexer's `issuer` join can carry
+ * a handle WITHOUT a displayName/avatar (certified-only orgs have no
+ * bsky-profile join), so we only skip the per-row resolve when the
+ * indexer block is complete. Shared by the received card + list row.
+ */
+function useReceivedIssuerInfo(endorsement: ReceivedEndorsement): {
+ info: AuthorInfo | null
+ isLoading: boolean
+} {
+ const idxIssuer = endorsement.issuer
+ const indexerAvatar = buildAvatarUrlFromCid(
+ idxIssuer?.did ?? endorsement.issuerDid,
+ idxIssuer?.avatarCid,
+ )
+ const indexerComplete = !!(
+ idxIssuer?.handle &&
+ idxIssuer.displayName &&
+ indexerAvatar
+ )
+ const { info: fetched, isLoading } = useAuthorInfo(
+ indexerComplete ? null : endorsement.issuerDid,
+ )
+ const info: AuthorInfo | null =
+ idxIssuer?.handle || idxIssuer?.displayName || indexerAvatar || fetched
+ ? {
+ did: idxIssuer?.did ?? endorsement.issuerDid,
+ handle: idxIssuer?.handle ?? fetched?.handle ?? endorsement.issuerDid,
+ displayName: idxIssuer?.displayName ?? fetched?.displayName ?? null,
+ avatarUrl: indexerAvatar ?? fetched?.avatarUrl ?? null,
+ }
+ : fetched
+ return { info, isLoading }
+}
+
+/** Select-all + bulk-action strip shown above the list view for owners. */
+export function BulkBar({
+ selectedCount,
+ allSelected,
+ anyVisible,
+ onToggleAll,
+ actionLabel,
+ actionIcon: ActionIcon,
+ busy,
+ error,
+ onAction,
+}: {
+ selectedCount: number
+ allSelected: boolean
+ anyVisible: boolean
+ onToggleAll: () => void
+ actionLabel: string
+ actionIcon: LucideIcon
+ busy: boolean
+ error: string | null
+ onAction: () => void
+}) {
+ return (
+
+
0}
+ onChange={onToggleAll}
+ disabled={!anyVisible}
+ aria-label={allSelected ? "Deselect all" : "Select all"}
+ label={selectedCount > 0 ? `${selectedCount} selected` : "Select all"}
+ />
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {actionLabel}
+ {selectedCount > 0 ? ` (${selectedCount})` : ""}
+
+
+
+ )
+}
+
+/** BEM skin the shared subject row wears in the compact list view.
+ * Used by both Given and Received rows. */
+const V2_ROW_CLASSES: EndorsementSubjectRowClasses = {
+ main: "profile-endorsements-v2__row-main",
+ meta: "profile-endorsements-v2__row-text",
+ name: "profile-endorsements-v2__row-name",
+ handle: "profile-endorsements-v2__row-handle",
+ note: "profile-endorsements-v2__row-note",
+ date: "profile-endorsements-v2__row-date",
+}
+
+export function GivenList({
+ visible,
+ total,
+ isLoading,
+ error,
+ viewerIsOwner,
+ viewerDid,
+ targetDid,
+ selectable,
+ selected,
+ onToggleOne,
+ onAfterRevoke,
+}: {
+ visible: GivenEndorsement[]
+ total: number
+ isLoading: boolean
+ error: string | null
+ viewerIsOwner: boolean
+ viewerDid: string | null
+ targetDid?: string
+ selectable: boolean
+ selected: Set
+ onToggleOne: (uri: string) => void
+ onAfterRevoke: () => void | Promise
+}) {
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (error) {
+ return (
+
+ )
+ }
+ if (visible.length === 0) {
+ return (
+
+ )
+ }
+ return (
+
+ {visible.map((e) => (
+
+ ))}
+
+ )
+}
+
+const GivenListRow = memo(function GivenListRow({
+ endorsement,
+ selectable,
+ selected,
+ onToggleOne,
+ canRevoke,
+ viewerDid,
+ targetDid,
+ onAfterRevoke,
+}: {
+ endorsement: GivenEndorsement
+ selectable: boolean
+ selected: boolean
+ onToggleOne: (uri: string) => void
+ canRevoke: boolean
+ viewerDid: string | null
+ targetDid?: string
+ onAfterRevoke: () => void | Promise
+}) {
+ const { info, isLoading } = useAuthorInfo(endorsement.subjectDid)
+ // Same derivation the shared row renders, so aria-labels and the
+ // revoke confirm dialog match the visible display name.
+ const { displayName: display } = deriveIdentity(info, endorsement.subjectDid)
+ return (
+
+ {selectable ? (
+ onToggleOne(endorsement.uri)}
+ aria-label={`Select endorsement of ${display}`}
+ />
+ ) : null}
+
+ ) : null
+ }
+ />
+
+ )
+})
+
+export function ReceivedList({
+ visible,
+ total,
+ isLoading,
+ error,
+ responseFilter,
+ viewerIsOwner,
+ viewerDid,
+ targetDid,
+ resolve,
+ allResponses,
+ selectable,
+ selected,
+ onToggleOne,
+ onAfterWrite,
+}: {
+ visible: ReceivedEndorsement[]
+ total: number
+ isLoading: boolean
+ error: string | null
+ responseFilter: ResponseFilterKey
+ viewerIsOwner: boolean
+ viewerDid: string | null
+ targetDid?: string
+ resolve: ReturnType["resolve"]
+ allResponses: ReturnType["responses"]
+ selectable: boolean
+ selected: Set
+ onToggleOne: (uri: string) => void
+ onAfterWrite: () => void | Promise
+}) {
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (error) {
+ return (
+
+ )
+ }
+ if (visible.length === 0) {
+ const onlyRejectedActive = responseFilter === "only-rejected"
+ return (
+
+ )
+ }
+ return (
+
+ {visible.map((e) => (
+
+ ))}
+
+ )
+}
+
+const ReceivedListRow = memo(function ReceivedListRow({
+ endorsement,
+ selectable,
+ selected,
+ onToggleOne,
+ viewerIsOwner,
+ viewerDid,
+ targetDid,
+ resolve,
+ allResponses,
+ onAfterWrite,
+}: {
+ endorsement: ReceivedEndorsement
+ selectable: boolean
+ selected: boolean
+ onToggleOne: (uri: string) => void
+ viewerIsOwner: boolean
+ viewerDid: string | null
+ targetDid?: string
+ resolve: ReturnType["resolve"]
+ allResponses: ReturnType["responses"]
+ onAfterWrite: () => void | Promise
+}) {
+ const { info, isLoading } = useReceivedIssuerInfo(endorsement)
+ // Same derivation the shared row renders, so aria-labels and the
+ // response menu match the visible display name.
+ const { displayName: display } = deriveIdentity(info, endorsement.issuerDid)
+ return (
+
+ {selectable ? (
+ onToggleOne(endorsement.uri)}
+ aria-label={`Select endorsement from ${display}`}
+ />
+ ) : null}
+
+ ) : null
+ }
+ />
+
+ )
+})
+
+// ----------------------- Filter + sort helpers -----------------------
+
+export function filterAndSortReceived(
+ records: ReceivedEndorsement[],
+ query: string,
+ sort: SortKey,
+ names: Map,
+): ReceivedEndorsement[] {
+ const q = query.trim().toLowerCase()
+ const matches = q
+ ? records.filter((r) => {
+ const note = (r.note ?? "").toLowerCase()
+ const name = names.get(r.issuerDid) ?? r.issuerDid.toLowerCase()
+ return note.includes(q) || name.includes(q)
+ })
+ : records
+
+ const sorted = matches.slice()
+ sorted.sort((a, b) => {
+ switch (sort) {
+ case "created-desc":
+ return compareString(b.createdAt, a.createdAt)
+ case "created-asc":
+ return compareString(a.createdAt, b.createdAt)
+ case "alpha-asc":
+ return (names.get(a.issuerDid) ?? "").localeCompare(
+ names.get(b.issuerDid) ?? "",
+ )
+ case "alpha-desc":
+ return (names.get(b.issuerDid) ?? "").localeCompare(
+ names.get(a.issuerDid) ?? "",
+ )
+ }
+ })
+ return sorted
+}
+
+export function filterAndSortGiven(
+ records: GivenEndorsement[],
+ query: string,
+ sort: SortKey,
+ names: Map,
+): GivenEndorsement[] {
+ const q = query.trim().toLowerCase()
+ const matches = q
+ ? records.filter((r) => {
+ const note = (r.note ?? "").toLowerCase()
+ const name = names.get(r.subjectDid) ?? r.subjectDid.toLowerCase()
+ return note.includes(q) || name.includes(q)
+ })
+ : records
+
+ const sorted = matches.slice()
+ sorted.sort((a, b) => {
+ switch (sort) {
+ case "created-desc":
+ return compareString(b.createdAt, a.createdAt)
+ case "created-asc":
+ return compareString(a.createdAt, b.createdAt)
+ case "alpha-asc":
+ return (names.get(a.subjectDid) ?? "").localeCompare(
+ names.get(b.subjectDid) ?? "",
+ )
+ case "alpha-desc":
+ return (names.get(b.subjectDid) ?? "").localeCompare(
+ names.get(a.subjectDid) ?? "",
+ )
+ }
+ })
+ return sorted
+}
+
+function compareString(a: string, b: string): number {
+ return a < b ? -1 : a > b ? 1 : 0
+}
diff --git a/src/components/profile/profile-endorsements.tsx b/src/components/profile/profile-endorsements.tsx
index 9377b49d..5cde2228 100644
--- a/src/components/profile/profile-endorsements.tsx
+++ b/src/components/profile/profile-endorsements.tsx
@@ -1,30 +1,21 @@
"use client"
-import { memo, useCallback, useEffect, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useState } from "react"
import { useUrlParam } from "@/hooks/use-url-param"
import {
ArrowUpDown,
Ban,
Filter,
- Inbox,
LayoutGrid,
List as ListIcon,
Plus,
Search,
- ThumbsUp,
Trash2,
- X,
- type LucideIcon,
} from "lucide-react"
-import { useGivenEndorsements, type GivenEndorsement } from "@/hooks/use-endorsements"
-import {
- useReceivedEndorsements,
- type ReceivedEndorsement,
-} from "@/hooks/use-received-endorsements"
+import { useGivenEndorsements } from "@/hooks/use-endorsements"
+import { useReceivedEndorsements } from "@/hooks/use-received-endorsements"
import { useOwnResponseStates } from "@/hooks/use-own-response-states"
-import { useAuthorInfo, type AuthorInfo } from "@/hooks/use-author-info"
import { useAuthorNamesMap } from "@/hooks/use-author-names-map"
-import { buildAvatarUrlFromCid } from "@/lib/atproto/profile"
import { useAuth } from "@/lib/auth/auth-context"
import { useOrg } from "@/lib/groups/org-context"
import {
@@ -32,20 +23,21 @@ import {
deleteEndorsementAward,
createResponse,
} from "@/lib/atproto/badges"
-import ResponseMenu from "@/components/badges/response-menu"
-import Avatar from "@/components/ui/avatar"
-import Button from "@/components/ui/button"
-import Checkbox from "@/components/ui/checkbox"
import SegmentedControl from "@/components/ui/segmented-control"
-import { profileUrl } from "@/lib/urls"
-import { getInitials } from "@/lib/utils/initials"
-import Link from "next/link"
import EndorsementLists from "@/components/profile/endorsement-lists"
import EndorsePeopleModal from "@/components/profile/endorse-people-modal"
-import PersonCard from "@/components/profile/person-card"
+import {
+ BulkBar,
+ GivenGrid,
+ GivenList,
+ ReceivedGrid,
+ ReceivedList,
+ filterAndSortGiven,
+ filterAndSortReceived,
+ type ResponseFilterKey,
+ type SortKey,
+} from "@/components/profile/profile-endorsement-views"
import ConfirmDialog from "@/components/ui/confirm-dialog"
-import EmptyState from "@/components/ui/empty-state"
-import LoadingSpinner from "@/components/ui/loading-spinner"
import Badge from "@/components/ui/badge"
import {
Popover,
@@ -64,12 +56,6 @@ interface ProfileEndorsementsProps {
type SubTab = "received" | "given"
type ViewMode = "gallery" | "list"
-type SortKey =
- | "created-desc"
- | "created-asc"
- | "alpha-asc"
- | "alpha-desc"
-
/** Sort options shown in the toolbar. Labels swap between sub-tabs
* because A→Z means different things ("Endorser" vs "Recipient"). */
const RECEIVED_SORT_OPTIONS: { key: SortKey; label: string }[] = [
@@ -86,7 +72,6 @@ const GIVEN_SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: "alpha-desc", label: "Recipient Z → A" },
]
-type ResponseFilterKey = "hide-rejected" | "only-rejected" | "show-all"
const RESPONSE_FILTER_OPTIONS: { key: ResponseFilterKey; label: string }[] = [
{ key: "hide-rejected", label: "Hide rejected" },
{ key: "only-rejected", label: "Show only rejected" },
@@ -211,14 +196,15 @@ export default function ProfileEndorsements({ did }: ProfileEndorsementsProps) {
// Endorse-people modal (own-profile only). The viewer can search
// for one or many people and write a batch of endorsements in a
- // single pass.
+ // single pass. When the viewer can manage this profile, `given` IS
+ // their own endorsement set — no second useGivenEndorsements call
+ // (the hook has no cache; each instance re-runs the whole
+ // listDefinitions/listAwards/per-definition fan-out).
const [isEndorseModalOpen, setIsEndorseModalOpen] = useState(false)
- const ownGivenForModal = useGivenEndorsements(
- canManage ? did : null,
- )
const ownAlreadyEndorsedDids = useMemo(
- () => new Set(ownGivenForModal.endorsements.map((e) => e.subjectDid)),
- [ownGivenForModal.endorsements],
+ () =>
+ new Set(canManage ? given.endorsements.map((e) => e.subjectDid) : []),
+ [canManage, given.endorsements],
)
// The DID set to hydrate names for — issuers for the Received tab,
@@ -632,7 +618,6 @@ export default function ProfileEndorsements({ did }: ProfileEndorsementsProps) {
// its own endorsements — no refresh needed here. Close
// after the refresh so the user sees the spinner clear.
setIsEndorseModalOpen(false)
- await ownGivenForModal.refetch()
await given.refetch()
}}
/>
@@ -645,833 +630,3 @@ function formatCount(n: number): string | null {
if (n === 0) return null
return `${n}`
}
-
-// ----------------------------- Received -----------------------------
-
-interface ReceivedGridProps {
- /** Already filtered + sorted by the parent (`visibleReceived`); the
- * grid renders it directly instead of recomputing the sort. */
- visible: ReceivedEndorsement[]
- /** Size of the pre-search set — drives the "No endorsements yet" vs
- * "No matches" empty-state split. */
- total: number
- isLoading: boolean
- error: string | null
- viewerIsOwner: boolean
- viewerDid: string | null
- /** Group DID when acting AS this group — accept/reject responses route
- * to the group's repo. Undefined for personal responses. */
- targetDid?: string
- /** Active response filter — used by the empty-state copy so a
- * zero-results "Show only rejected" view says "No rejected
- * endorsements yet" instead of the generic "No endorsements
- * yet." */
- responseFilter: ResponseFilterKey
- resolve: ReturnType["resolve"]
- allResponses: ReturnType["responses"]
- onAfterWrite: () => void | Promise
-}
-
-function ReceivedGrid({
- visible,
- total,
- isLoading,
- error,
- viewerIsOwner,
- viewerDid,
- targetDid,
- responseFilter,
- resolve,
- allResponses,
- onAfterWrite,
-}: ReceivedGridProps) {
- if (isLoading) {
- return (
-
-
-
- )
- }
- if (error) {
- return (
-
-
-
- )
- }
- if (visible.length === 0) {
- // Three empty-state cases:
- // 1. The "Show only rejected" filter is active with no matches
- // — phrase the empty state in terms of the filter so the
- // user knows nothing is missing, the filter just has no
- // hits yet.
- // 2. There's a search query / sort filter but the pre-filter
- // set is non-empty — "No matches."
- // 3. The user has zero endorsements total — the generic
- // "No endorsements yet" CTA.
- const onlyRejectedActive = responseFilter === "only-rejected"
- const title = onlyRejectedActive
- ? "No rejected endorsements yet"
- : total === 0
- ? "No endorsements yet"
- : "No matches"
- const description = onlyRejectedActive
- ? "Endorsements you reject will appear here."
- : total === 0
- ? "Endorsements from other people will appear here."
- : "No endorsements match your search."
- return (
-
-
-
- )
- }
- return (
-
- {visible.map((e) => (
-
- ))}
-
- )
-}
-
-const ReceivedCard = memo(function ReceivedCard({
- endorsement,
- viewerIsOwner,
- viewerDid,
- targetDid,
- resolve,
- allResponses,
- onAfterWrite,
-}: {
- endorsement: ReceivedEndorsement
- viewerIsOwner: boolean
- viewerDid: string | null
- targetDid?: string
- resolve: ReturnType["resolve"]
- allResponses: ReturnType["responses"]
- onAfterWrite: () => void | Promise
-}) {
- const { info, isLoading } = useReceivedIssuerInfo(endorsement)
-
- return (
-
- ) : null
- }
- />
- )
-})
-
-// ------------------------------ Given -------------------------------
-
-interface GivenGridProps {
- /** Already filtered + sorted by the parent (`visibleGiven`); the grid
- * renders it directly instead of recomputing the sort. */
- visible: GivenEndorsement[]
- /** Size of the pre-search set — drives the "No endorsements given yet"
- * vs "No matches" empty-state split. */
- total: number
- isLoading: boolean
- error: string | null
- /** True when the profile being viewed is the signed-in user's
- * own profile — i.e. the cards represent endorsements THEY
- * issued. Controls whether the per-card revoke `×` renders. */
- viewerIsOwner: boolean
- viewerDid: string | null
- /** Group DID when the viewer is acting AS this group — revokes route
- * to the group repo. Undefined for personal revokes. */
- targetDid?: string
- onAfterRevoke: () => void | Promise
-}
-
-function GivenGrid({
- visible,
- total,
- isLoading,
- error,
- viewerIsOwner,
- viewerDid,
- targetDid,
- onAfterRevoke,
-}: GivenGridProps) {
- if (isLoading) {
- return (
-
-
-
- )
- }
- if (error) {
- return (
-
-
-
- )
- }
- if (visible.length === 0) {
- return (
-
-
-
- )
- }
- return (
-
- {visible.map((e) => (
-
- ))}
-
- )
-}
-
-const GivenCard = memo(function GivenCard({
- endorsement,
- canRevoke,
- viewerDid,
- targetDid,
- onAfterRevoke,
-}: {
- endorsement: GivenEndorsement
- canRevoke: boolean
- viewerDid: string | null
- targetDid?: string
- onAfterRevoke: () => void | Promise
-}) {
- const { info, isLoading } = useAuthorInfo(endorsement.subjectDid)
- return (
-
- ) : null
- }
- />
- )
-})
-
-/**
- * Small `×` revoke affordance shown on the owner's Given grid.
- * Click → ConfirmDialog ("Revoke endorsement?") → on confirm,
- * `deleteEndorsementAward` runs and the parent's `onAfterRevoke`
- * refetches the Given list so the card disappears.
- */
-function RevokeGivenButton({
- viewerDid,
- rkeys,
- targetDid,
- subjectDisplay,
- onAfterRevoke,
-}: {
- viewerDid: string
- /** All award rkeys for this recipient — revoke removes every one so a
- * recipient endorsed more than once disappears in a single click. */
- rkeys: string[]
- targetDid?: string
- subjectDisplay: string
- onAfterRevoke: () => void | Promise
-}) {
- const [confirmOpen, setConfirmOpen] = useState(false)
- const [isRevoking, setIsRevoking] = useState(false)
- const [error, setError] = useState(null)
-
- const handleConfirm = async () => {
- if (isRevoking) return
- setIsRevoking(true)
- setError(null)
- try {
- for (const rkey of rkeys) {
- await deleteEndorsementAward(viewerDid, rkey, { targetDid })
- }
- await onAfterRevoke()
- setConfirmOpen(false)
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to revoke")
- } finally {
- setIsRevoking(false)
- }
- }
-
- return (
- <>
-
- {
- // PersonCard's outer Link otherwise catches the click and
- // navigates to the subject's profile.
- e.preventDefault()
- e.stopPropagation()
- setConfirmOpen(true)
- }}
- aria-label={`Revoke endorsement of ${subjectDisplay}`}
- >
-
-
-
- {confirmOpen ? (
- !isRevoking && setConfirmOpen(false)}
- />
- ) : null}
- {error ? (
-
- {error}
-
- ) : null}
- >
- )
-}
-
-// ----------------------- List view + bulk select -----------------------
-
-/**
- * Compose the issuer's AuthorInfo from the indexer's denormalised block,
- * filling gaps via `useAuthorInfo`. The indexer's `issuer` join can carry
- * a handle WITHOUT a displayName/avatar (certified-only orgs have no
- * bsky-profile join), so we only skip the per-row resolve when the
- * indexer block is complete. Shared by the received card + list row.
- */
-function useReceivedIssuerInfo(endorsement: ReceivedEndorsement): {
- info: AuthorInfo | null
- isLoading: boolean
-} {
- const idxIssuer = endorsement.issuer
- const indexerAvatar = buildAvatarUrlFromCid(
- idxIssuer?.did ?? endorsement.issuerDid,
- idxIssuer?.avatarCid,
- )
- const indexerComplete = !!(
- idxIssuer?.handle &&
- idxIssuer.displayName &&
- indexerAvatar
- )
- const { info: fetched, isLoading } = useAuthorInfo(
- indexerComplete ? null : endorsement.issuerDid,
- )
- const info: AuthorInfo | null =
- idxIssuer?.handle || idxIssuer?.displayName || indexerAvatar || fetched
- ? {
- did: idxIssuer?.did ?? endorsement.issuerDid,
- handle: idxIssuer?.handle ?? fetched?.handle ?? endorsement.issuerDid,
- displayName: idxIssuer?.displayName ?? fetched?.displayName ?? null,
- avatarUrl: indexerAvatar ?? fetched?.avatarUrl ?? null,
- }
- : fetched
- return { info, isLoading }
-}
-
-/** Select-all + bulk-action strip shown above the list view for owners. */
-function BulkBar({
- selectedCount,
- allSelected,
- anyVisible,
- onToggleAll,
- actionLabel,
- actionIcon: ActionIcon,
- busy,
- error,
- onAction,
-}: {
- selectedCount: number
- allSelected: boolean
- anyVisible: boolean
- onToggleAll: () => void
- actionLabel: string
- actionIcon: LucideIcon
- busy: boolean
- error: string | null
- onAction: () => void
-}) {
- return (
-
-
0}
- onChange={onToggleAll}
- disabled={!anyVisible}
- aria-label={allSelected ? "Deselect all" : "Select all"}
- label={selectedCount > 0 ? `${selectedCount} selected` : "Select all"}
- />
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
- {actionLabel}
- {selectedCount > 0 ? ` (${selectedCount})` : ""}
-
-
-
- )
-}
-
-/** Shared list-row body: avatar + name/handle/note linking to the profile,
- * with the date right-aligned. Used by both Given and Received rows. */
-function EndorsementRowBody({
- did,
- info,
- createdAt,
- note,
-}: {
- did: string
- info: AuthorInfo | null
- createdAt: string
- note?: string
-}) {
- const display = info?.displayName || info?.handle || did
- const handle =
- info?.handle && info.handle !== info.did ? `@${info.handle}` : null
- return (
-
-
-
- {display}
- {handle ? (
- {handle}
- ) : null}
- {note ? (
- {note}
- ) : null}
-
-
- {createdAt.slice(0, 10)}
-
-
- )
-}
-
-function GivenList({
- visible,
- total,
- isLoading,
- error,
- viewerIsOwner,
- viewerDid,
- targetDid,
- selectable,
- selected,
- onToggleOne,
- onAfterRevoke,
-}: {
- visible: GivenEndorsement[]
- total: number
- isLoading: boolean
- error: string | null
- viewerIsOwner: boolean
- viewerDid: string | null
- targetDid?: string
- selectable: boolean
- selected: Set
- onToggleOne: (uri: string) => void
- onAfterRevoke: () => void | Promise
-}) {
- if (isLoading) {
- return (
-
-
-
- )
- }
- if (error) {
- return (
-
- )
- }
- if (visible.length === 0) {
- return (
-
- )
- }
- return (
-
- {visible.map((e) => (
- onToggleOne(e.uri)}
- canRevoke={viewerIsOwner && !!viewerDid}
- viewerDid={viewerDid}
- targetDid={targetDid}
- onAfterRevoke={onAfterRevoke}
- />
- ))}
-
- )
-}
-
-const GivenListRow = memo(function GivenListRow({
- endorsement,
- selectable,
- selected,
- onToggle,
- canRevoke,
- viewerDid,
- targetDid,
- onAfterRevoke,
-}: {
- endorsement: GivenEndorsement
- selectable: boolean
- selected: boolean
- onToggle: () => void
- canRevoke: boolean
- viewerDid: string | null
- targetDid?: string
- onAfterRevoke: () => void | Promise
-}) {
- const { info } = useAuthorInfo(endorsement.subjectDid)
- const display = info?.displayName || info?.handle || endorsement.subjectDid
- return (
-
- {selectable ? (
-
- ) : null}
-
- {canRevoke && viewerDid ? (
-
- ) : null}
-
- )
-})
-
-function ReceivedList({
- visible,
- total,
- isLoading,
- error,
- responseFilter,
- viewerIsOwner,
- viewerDid,
- targetDid,
- resolve,
- allResponses,
- selectable,
- selected,
- onToggleOne,
- onAfterWrite,
-}: {
- visible: ReceivedEndorsement[]
- total: number
- isLoading: boolean
- error: string | null
- responseFilter: ResponseFilterKey
- viewerIsOwner: boolean
- viewerDid: string | null
- targetDid?: string
- resolve: ReturnType["resolve"]
- allResponses: ReturnType["responses"]
- selectable: boolean
- selected: Set
- onToggleOne: (uri: string) => void
- onAfterWrite: () => void | Promise
-}) {
- if (isLoading) {
- return (
-
-
-
- )
- }
- if (error) {
- return (
-
- )
- }
- if (visible.length === 0) {
- const onlyRejectedActive = responseFilter === "only-rejected"
- return (
-
- )
- }
- return (
-
- {visible.map((e) => (
- onToggleOne(e.uri)}
- viewerIsOwner={viewerIsOwner}
- viewerDid={viewerDid}
- targetDid={targetDid}
- resolve={resolve}
- allResponses={allResponses}
- onAfterWrite={onAfterWrite}
- />
- ))}
-
- )
-}
-
-const ReceivedListRow = memo(function ReceivedListRow({
- endorsement,
- selectable,
- selected,
- onToggle,
- viewerIsOwner,
- viewerDid,
- targetDid,
- resolve,
- allResponses,
- onAfterWrite,
-}: {
- endorsement: ReceivedEndorsement
- selectable: boolean
- selected: boolean
- onToggle: () => void
- viewerIsOwner: boolean
- viewerDid: string | null
- targetDid?: string
- resolve: ReturnType["resolve"]
- allResponses: ReturnType["responses"]
- onAfterWrite: () => void | Promise
-}) {
- const { info } = useReceivedIssuerInfo(endorsement)
- const display = info?.displayName || info?.handle || endorsement.issuerDid
- return (
-
- {selectable ? (
-
- ) : null}
-
- {viewerIsOwner ? (
-
- ) : null}
-
- )
-})
-
-// ----------------------- Filter + sort helpers -----------------------
-
-function filterAndSortReceived(
- records: ReceivedEndorsement[],
- query: string,
- sort: SortKey,
- names: Map,
-): ReceivedEndorsement[] {
- const q = query.trim().toLowerCase()
- const matches = q
- ? records.filter((r) => {
- const note = (r.note ?? "").toLowerCase()
- const name = names.get(r.issuerDid) ?? r.issuerDid.toLowerCase()
- return note.includes(q) || name.includes(q)
- })
- : records
-
- const sorted = matches.slice()
- sorted.sort((a, b) => {
- switch (sort) {
- case "created-desc":
- return compareString(b.createdAt, a.createdAt)
- case "created-asc":
- return compareString(a.createdAt, b.createdAt)
- case "alpha-asc":
- return (names.get(a.issuerDid) ?? "").localeCompare(
- names.get(b.issuerDid) ?? "",
- )
- case "alpha-desc":
- return (names.get(b.issuerDid) ?? "").localeCompare(
- names.get(a.issuerDid) ?? "",
- )
- }
- })
- return sorted
-}
-
-function filterAndSortGiven(
- records: GivenEndorsement[],
- query: string,
- sort: SortKey,
- names: Map,
-): GivenEndorsement[] {
- const q = query.trim().toLowerCase()
- const matches = q
- ? records.filter((r) => {
- const note = (r.note ?? "").toLowerCase()
- const name = names.get(r.subjectDid) ?? r.subjectDid.toLowerCase()
- return note.includes(q) || name.includes(q)
- })
- : records
-
- const sorted = matches.slice()
- sorted.sort((a, b) => {
- switch (sort) {
- case "created-desc":
- return compareString(b.createdAt, a.createdAt)
- case "created-asc":
- return compareString(a.createdAt, b.createdAt)
- case "alpha-asc":
- return (names.get(a.subjectDid) ?? "").localeCompare(
- names.get(b.subjectDid) ?? "",
- )
- case "alpha-desc":
- return (names.get(b.subjectDid) ?? "").localeCompare(
- names.get(a.subjectDid) ?? "",
- )
- }
- })
- return sorted
-}
-
-function compareString(a: string, b: string): number {
- return a < b ? -1 : a > b ? 1 : 0
-}
diff --git a/src/components/profile/profile-follow-endorse.tsx b/src/components/profile/profile-follow-endorse.tsx
index 8f670bfb..3e02d2e7 100644
--- a/src/components/profile/profile-follow-endorse.tsx
+++ b/src/components/profile/profile-follow-endorse.tsx
@@ -17,6 +17,7 @@ import {
} from "@/hooks/use-received-endorsements"
import { useEndorsementLists } from "@/hooks/use-endorsement-lists"
import { useAuthorInfo } from "@/hooks/use-author-info"
+import { deriveIdentity } from "@/lib/utils/identity"
import EndorseReasonModal, {
type EndorseReasonActingAs,
} from "@/components/profile/endorse-reason-modal"
@@ -226,8 +227,7 @@ export function EndorseButton({
// group (the reason modal hides the picker too).
const ownLists = useEndorsementLists(activeOrg ? null : viewerDid)
const { info: subjectInfo } = useAuthorInfo(subjectDid)
- const subjectLabel =
- subjectInfo?.displayName || subjectInfo?.handle || subjectDid
+ const subjectLabel = deriveIdentity(subjectInfo, subjectDid).displayName
const [isWriting, setIsWriting] = useState(false)
const [confirmRevoke, setConfirmRevoke] = useState(false)
const [reasonOpen, setReasonOpen] = useState(false)
diff --git a/src/components/profile/profile-followers.tsx b/src/components/profile/profile-followers.tsx
index 9cdccdbd..4d77f7ff 100644
--- a/src/components/profile/profile-followers.tsx
+++ b/src/components/profile/profile-followers.tsx
@@ -15,6 +15,7 @@ import { useFollowers, type FollowerEntry } from "@/hooks/use-followers"
import { useFollowing } from "@/hooks/use-following"
import { useAuthorInfo } from "@/hooks/use-author-info"
import { useAuthorNamesMap } from "@/hooks/use-author-names-map"
+import { deriveIdentity } from "@/lib/utils/identity"
import { useAuth } from "@/lib/auth/auth-context"
import { useOrg } from "@/lib/groups/org-context"
import ConfirmDialog from "@/components/ui/confirm-dialog"
@@ -474,7 +475,7 @@ function FollowingCard({
rkey={record.rkey}
targetDid={targetDid}
subjectDisplay={
- info?.displayName || info?.handle || record.value.subject
+ deriveIdentity(info, record.value.subject).displayName
}
onAfterUnfollow={onAfterUnfollow}
/>
diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx
index d50d6715..7bb1423d 100644
--- a/src/components/profile/profile-header.tsx
+++ b/src/components/profile/profile-header.tsx
@@ -85,11 +85,14 @@ export default function ProfileHeader({
// Track banner load failures so we fall back to the plain gradient
// instead of showing the browser's broken-image icon. Reset the flag
- // when the URL changes (e.g. when the user switches profiles).
+ // during render when the URL changes (e.g. when the user switches
+ // profiles) — React's adjust-state-during-render pattern.
const [bannerFailed, setBannerFailed] = useState(false)
- useEffect(() => {
+ const [prevBannerUrl, setPrevBannerUrl] = useState(bannerUrl)
+ if (prevBannerUrl !== bannerUrl) {
+ setPrevBannerUrl(bannerUrl)
setBannerFailed(false)
- }, [bannerUrl])
+ }
const hasAdminActions = !!editHref || !!settingsHref
diff --git a/src/components/profile/profile-lists.tsx b/src/components/profile/profile-lists.tsx
index f453a4b4..7c144a32 100644
--- a/src/components/profile/profile-lists.tsx
+++ b/src/components/profile/profile-lists.tsx
@@ -1,7 +1,7 @@
"use client"
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
-import { profileUrl, recordUrl } from "@/lib/urls"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { recordUrl, rkeyFromUri } from "@/lib/urls"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useUrlParam } from "@/hooks/use-url-param"
@@ -13,11 +13,9 @@ import {
ListIcon,
Pencil,
Plus,
- Search,
Trash2,
X,
} from "lucide-react"
-import AppDialog, { AppDialogHeader, AppDialogBody } from "@/components/ui/app-dialog"
import Avatar from "@/components/ui/avatar"
import Button from "@/components/ui/button"
import ConfirmDialog from "@/components/ui/confirm-dialog"
@@ -28,22 +26,22 @@ import { useAuthorInfo } from "@/hooks/use-author-info"
import { useActivity } from "@/hooks/use-activity"
import { useProject } from "@/hooks/use-project"
import { useTypedLists } from "@/hooks/use-typed-lists"
-import { fetchIndexerActivities, INDEXER_PROXY_URL } from "@/lib/atproto/indexer"
-import { searchMergedActors } from "@/lib/atproto/actor-search"
import ProjectListRow from "@/components/explore-page/project-list-row"
import {
- ITEM_NSID,
+ AddItemsModal,
+ CreateListModal,
+ PasteUrisModal,
+} from "@/components/profile/list-modals"
+import {
LIST_ACCOUNTS_TYPE,
LIST_CERTS_TYPE,
LIST_PROJECTS_TYPE,
- itemUriMatchesType,
- resolveRecordCid,
type TypedListRecord,
type TypedListType,
} from "@/lib/atproto/typed-lists"
import { resolveActivityImageUrl } from "@/lib/atproto/activity"
import { parseAtUri } from "@/lib/atproto/activity-uri"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
const SECTIONS: { type: TypedListType; title: string; emptyHint: string }[] = [
{ type: LIST_CERTS_TYPE, title: "Activities", emptyHint: "No activity lists yet." },
@@ -159,7 +157,7 @@ export default function ProfileLists({ did, viewerIsOwner }: ProfileListsProps)
onSubmit={async (title, description) => {
const ref = await createList(creating, title, description)
setCreating(null)
- const rkey = ref.uri.split("/").pop() ?? null
+ const rkey = rkeyFromUri(ref.uri) || null
// push: entering the new list creates a back-able history
// entry. The same gesture as clicking an existing row.
if (rkey) setSelectedRkey(rkey)
@@ -556,9 +554,10 @@ function AccountItemRow({
// at://// — the subject DID is the second segment.
const did = uri.split("/")[2] ?? null
const { info } = useAuthorInfo(did)
- const display = info?.displayName || info?.handle || did || "Unknown"
- const initials = getInitials(info?.displayName, info?.handle ?? did ?? undefined)
- const href = did ? profileUrl(info?.handle || did) : null
+ const identity = did ? deriveIdentity(info, did) : null
+ const display = identity?.displayName ?? "Unknown"
+ const initials = identity?.initials ?? "?"
+ const href = identity?.profileHref ?? null
return (
}
title={display}
- subtitle={info?.handle && info.handle !== info.did ? `@${info.handle}` : null}
+ subtitle={identity?.handle ? `@${identity.handle}` : null}
canRemove={canRemove}
onRemove={onRemove}
/>
@@ -751,683 +750,3 @@ function ItemRowShell({
)
}
-
-// ----------------------------- Create / edit modal -----------------------------
-
-function CreateListModal({
- mode = "create",
- type,
- initialTitle = "",
- initialDescription = "",
- onSubmit,
- onCancel,
-}: {
- /** `"create"` (default) shows the Create wording; `"edit"` swaps in
- * the Save wording and pre-fills the title + description. The
- * underlying form chrome is identical so both modes read as the
- * same UI surface. */
- mode?: "create" | "edit"
- type: TypedListType
- initialTitle?: string
- initialDescription?: string
- onSubmit: (title: string, description?: string) => Promise
- onCancel: () => void
-}) {
- const inputRef = useRef(null)
- const [title, setTitle] = useState(initialTitle)
- const [description, setDescription] = useState(initialDescription)
- const [isWriting, setIsWriting] = useState(false)
- const [error, setError] = useState(null)
-
- useEffect(() => {
- inputRef.current?.focus()
- inputRef.current?.select()
- }, [])
-
- const submit = async (e: React.FormEvent) => {
- e.preventDefault()
- if (isWriting || !title.trim()) return
- setIsWriting(true)
- setError(null)
- try {
- await onSubmit(title.trim(), description.trim() || undefined)
- } catch (err) {
- setError(
- err instanceof Error
- ? err.message
- : mode === "edit"
- ? "Failed to save list"
- : "Failed to create list",
- )
- setIsWriting(false)
- }
- }
-
- const titleCopy = mode === "edit" ? `Edit ${LABELS[type]} list` : `Create ${LABELS[type]} list`
- const submitCopy = mode === "edit" ? "Save" : "Create list"
-
- return (
- !isWriting && onCancel()}
- disableBackdropClose={isWriting}
- >
- !isWriting && onCancel()}
- disabled={isWriting}
- />
-
-
- Name
- setTitle(e.target.value)}
- maxLength={120}
- disabled={isWriting}
- placeholder="e.g. Favourite work"
- required
- />
-
-
- Description (optional)
- setDescription(e.target.value)}
- maxLength={500}
- rows={3}
- disabled={isWriting}
- />
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
- Cancel
-
-
- {submitCopy}
-
-
-
-
- )
-}
-
-const LABELS: Record = {
- "list:certs": "activities",
- "list:projects": "projects",
- "list:accounts": "accounts",
-}
-
-// ----------------------------- Add-items modal -----------------------------
-
-interface SearchResult {
- uri: string
- cid: string
- title: string
- subtitle?: string | null
- imageUrl?: string | null
- avatarUrl?: string | null
- initials?: string | null
-}
-
-// ----------------------------- Bulk-paste modal -----------------------------
-
-interface ParseRow {
- uri: string
- status: "pending" | "writing" | "added" | "already" | "wrong-type" | "missing" | "error"
- message?: string
-}
-
-function PasteUrisModal({
- type,
- alreadyIn,
- onAddMany,
- onClose,
-}: {
- type: TypedListType
- alreadyIn: Set
- onAddMany: (
- items: readonly { uri: string; cid: string }[],
- ) => Promise
- onClose: () => void
-}) {
- const [raw, setRaw] = useState("")
- const [rows, setRows] = useState([])
- const [running, setRunning] = useState(false)
- const textareaRef = useRef(null)
-
- useEffect(() => {
- textareaRef.current?.focus()
- }, [])
-
- const handleRun = useCallback(async () => {
- if (running) return
- // Accept commas, newlines, and whitespace as separators so users
- // can paste a list of URIs from any reasonable source without
- // hand-formatting it. Dedupe within the input.
- // Accounts list: accept a bare DID (`did:plc:…`) or an actor URI
- // (`at://did:plc:…`), with or without a trailing slash, and expand it
- // to the conventional profile record path
- // (`at:///app.certified.actor.profile/self`) before validation —
- // the profile record's rkey is always `self`. For certs / projects the
- // rkey is record-specific, so those URIs are left untouched.
- const normalize = (uri: string): string => {
- if (type !== LIST_ACCOUNTS_TYPE) return uri
- const m = uri.match(/^(?:at:\/\/)?(did:[a-z]+:[A-Za-z0-9._:-]+)\/?$/)
- return m ? `at://${m[1]}/${ITEM_NSID[LIST_ACCOUNTS_TYPE]}/self` : uri
- }
-
- const parsed = Array.from(
- new Set(
- raw
- .split(/[\s,]+/)
- .map((s) => s.trim())
- .filter(Boolean)
- .map(normalize),
- ),
- )
- if (parsed.length === 0) return
-
- const initial: ParseRow[] = parsed.map((uri) => {
- if (!uri.startsWith("at://")) {
- return { uri, status: "error", message: "Not an at:// URI" }
- }
- if (!itemUriMatchesType(uri, type)) {
- return { uri, status: "wrong-type", message: `Doesn't match ${ITEM_NSID[type]}` }
- }
- if (alreadyIn.has(uri)) {
- return { uri, status: "already", message: "Already in list" }
- }
- return { uri, status: "pending" }
- })
- setRows(initial)
- setRunning(true)
-
- // Phase 1: parallel CID resolution. Each row flips to "writing"
- // as its lookup starts, then either "missing" (no record) or
- // stays writeable. This is the long pole — CID resolution is
- // a PDS round-trip per URI but the requests fan out in parallel.
- const writable = initial.filter((r) => r.status === "pending")
- const resolved = await Promise.all(
- writable.map(async (row) => {
- setRows((prev) =>
- prev.map((r) =>
- r.uri === row.uri ? { ...r, status: "writing" } : r,
- ),
- )
- try {
- const cid = await resolveRecordCid(row.uri)
- if (!cid) {
- setRows((prev) =>
- prev.map((r) =>
- r.uri === row.uri
- ? { ...r, status: "missing", message: "Record not found on PDS" }
- : r,
- ),
- )
- return null
- }
- return { uri: row.uri, cid }
- } catch (err) {
- setRows((prev) =>
- prev.map((r) =>
- r.uri === row.uri
- ? {
- ...r,
- status: "error",
- message: err instanceof Error ? err.message : "Lookup failed",
- }
- : r,
- ),
- )
- return null
- }
- }),
- )
-
- // Phase 2: single bulk append. One getRecord + one putRecord on
- // the list, regardless of how many items got resolved — much
- // cheaper than the prior per-item RMW loop. If the swap fails
- // (concurrent edit), every row that would've been added is
- // marked error so the viewer can retry.
- const validItems = resolved.filter(
- (r): r is { uri: string; cid: string } => r !== null,
- )
- if (validItems.length > 0) {
- try {
- await onAddMany(validItems)
- const validUris = new Set(validItems.map((it) => it.uri))
- setRows((prev) =>
- prev.map((r) =>
- validUris.has(r.uri)
- ? { ...r, status: "added", message: undefined }
- : r,
- ),
- )
- } catch (err) {
- const message = err instanceof Error ? err.message : "Add failed"
- const validUris = new Set(validItems.map((it) => it.uri))
- setRows((prev) =>
- prev.map((r) =>
- validUris.has(r.uri) ? { ...r, status: "error", message } : r,
- ),
- )
- }
- }
- setRunning(false)
- }, [alreadyIn, onAddMany, raw, running, type])
-
- return (
- !running && onClose()}
- disableBackdropClose={running}
- >
- !running && onClose()}
- disabled={running}
- />
-
-
- Paste at-URIs separated by commas, newlines, or spaces.
- Only items matching{" "}
- {ITEM_NSID[type]}{" "}
- will be added.
- {type === LIST_ACCOUNTS_TYPE ? (
- <>
- {" "}For accounts a bare DID{" "}
- did:plc:…{" "}
- (with or without at://)
- is also accepted — we’ll expand it to the profile record.
- >
- ) : null}
-
- setRaw(e.target.value)}
- rows={6}
- placeholder={
- type === LIST_ACCOUNTS_TYPE
- ? "did:plc:…, did:plc:…, …"
- : "at://did:plc:…/…/abc123, at://did:plc:…/…/def456, …"
- }
- disabled={running}
- />
- {rows.length > 0 ? (
- <>
-
-
- {rows.map((r) => (
-
- {statusLabel(r.status)}
- {r.uri}
- {r.message ? (
- {r.message}
- ) : null}
-
- ))}
-
- >
- ) : null}
-
-
- {rows.some((r) => r.status === "added") ? "Done" : "Cancel"}
-
-
- Add
-
-
-
-
- )
-}
-
-function statusLabel(s: ParseRow["status"]): string {
- switch (s) {
- case "pending":
- return "Pending"
- case "writing":
- return "Writing…"
- case "added":
- return "Added"
- case "already":
- return "Already in"
- case "wrong-type":
- return "Wrong type"
- case "missing":
- return "Not found"
- case "error":
- return "Error"
- }
-}
-
-/**
- * Overall progress strip above the per-row results list. Shows a
- * spinner + "N of M done" while the run is in flight, and a filled
- * progress bar that animates from 0% → 100%. After the loop ends
- * the spinner drops; the counter stays so the viewer has a record
- * of what landed. `aria-live="polite"` so screen readers get
- * progress updates without the page yanking focus around.
- */
-function PasteProgress({
- rows,
- running,
-}: {
- rows: ParseRow[]
- running: boolean
-}) {
- const total = rows.length
- // "Resolved" = anything that's no longer pending / writing. Counts
- // every terminal state (added / already / wrong-type / missing /
- // error) so the bar fills as the loop progresses regardless of
- // whether each row succeeded.
- const resolved = rows.filter(
- (r) => r.status !== "pending" && r.status !== "writing",
- ).length
- const added = rows.filter((r) => r.status === "added").length
- const percent = total === 0 ? 0 : Math.round((resolved / total) * 100)
- return (
-
-
- {running ? (
-
- ) : null}
-
- {running
- ? `Adding ${resolved} of ${total}…`
- : `${added} of ${total} added`}
-
- {percent}%
-
-
-
- )
-}
-
-function AddItemsModal({
- type,
- alreadyIn,
- onAdd,
- onClose,
-}: {
- type: TypedListType
- alreadyIn: Set
- onAdd: (item: { uri: string; cid: string }) => Promise
- onClose: () => void
-}) {
- const [query, setQuery] = useState("")
- const [results, setResults] = useState([])
- const [searching, setSearching] = useState(false)
- const [error, setError] = useState(null)
- const [addingUri, setAddingUri] = useState(null)
- const inputRef = useRef(null)
-
- useEffect(() => {
- inputRef.current?.focus()
- }, [])
-
- // Debounced search keyed by query. Empty query clears results
- // immediately rather than firing a "show me everything" request.
- useEffect(() => {
- const trimmed = query.trim()
- if (trimmed.length === 0) {
- setResults([])
- setSearching(false)
- setError(null)
- return
- }
- setSearching(true)
- const controller = new AbortController()
- const handle = window.setTimeout(async () => {
- try {
- const next = await runSearch(type, trimmed, controller.signal)
- if (controller.signal.aborted) return
- setResults(next)
- setError(null)
- } catch (err) {
- if (controller.signal.aborted) return
- setError(err instanceof Error ? err.message : "Search failed")
- setResults([])
- } finally {
- if (!controller.signal.aborted) setSearching(false)
- }
- }, 250)
- return () => {
- controller.abort()
- window.clearTimeout(handle)
- }
- }, [type, query])
-
- const handleAdd = async (result: SearchResult) => {
- if (addingUri) return
- setAddingUri(result.uri)
- try {
- // Bluesky's actor search doesn't return profile-record CIDs, so
- // for account-list items we resolve the CID on click before
- // writing the strongRef. Cert + project searches already carry
- // the CID inline from the indexer.
- const cid = result.cid || (await resolveRecordCid(result.uri))
- if (!cid) throw new Error("Couldn't resolve record CID")
- await onAdd({ uri: result.uri, cid })
- } catch (err) {
- console.error("Failed to add item:", err)
- setError(err instanceof Error ? err.message : "Failed to add item")
- } finally {
- setAddingUri(null)
- }
- }
-
- return (
-
-
-
-
-
- setQuery(e.target.value)}
- placeholder={SEARCH_PLACEHOLDERS[type]}
- />
-
- {error ? (
-
- {error}
-
- ) : null}
- {searching ? (
-
-
-
- ) : query.trim() && results.length === 0 ? (
- No matches.
- ) : results.length > 0 ? (
-
- {results.map((r) => {
- const isIn = alreadyIn.has(r.uri)
- return (
-
-
-
- {r.imageUrl ? (
- /* eslint-disable-next-line @next/next/no-img-element */
-
- ) : type === LIST_ACCOUNTS_TYPE ? (
-
- ) : (
-
- )}
-
-
- {r.title}
- {r.subtitle ? (
- {r.subtitle}
- ) : null}
-
-
handleAdd(r)}
- loading={addingUri === r.uri}
- disabled={isIn || (!!addingUri && addingUri !== r.uri)}
- >
- {isIn ? "Added" : "Add"}
-
-
-
- )
- })}
-
- ) : null}
-
-
- )
-}
-
-const SEARCH_PLACEHOLDERS: Record = {
- "list:certs": "Search activities by title",
- "list:projects": "Search projects by title",
- "list:accounts": "Search accounts by handle or name",
-}
-
-// ----------------------------- Search drivers -----------------------------
-
-async function runSearch(
- type: TypedListType,
- query: string,
- signal: AbortSignal,
-): Promise {
- if (type === LIST_ACCOUNTS_TYPE) return searchAccounts(query, signal)
- if (type === LIST_CERTS_TYPE) return searchCerts(query, signal)
- return searchProjects(query, signal)
-}
-
-async function searchAccounts(query: string, signal: AbortSignal): Promise {
- // Account-list membership must NOT require a Certified profile, so search
- // Certified + Bluesky merged (see `searchMergedActors`). Each result
- // strong-refs the profile record it actually has (`profileNsid`) — rkey
- // is the literal "self" — so the URI always targets an existing record
- // and `resolveRecordCid` succeeds on add.
- const merged = await searchMergedActors(query, signal)
- return merged.map((a) => ({
- uri: `at://${a.did}/${a.profileNsid}/self`,
- cid: "",
- title: a.displayName || (a.handle ? `@${a.handle}` : a.did),
- subtitle: a.handle ? `@${a.handle}` : null,
- avatarUrl: a.avatarUrl,
- initials: getInitials(a.displayName, a.handle ?? a.did),
- }))
-}
-
-async function searchCerts(query: string, signal: AbortSignal): Promise {
- const result = await fetchIndexerActivities({
- first: 10,
- search: query,
- signal,
- })
- const out: SearchResult[] = []
- for (const rec of result.records) {
- const parsed = parseAtUri(rec.uri)
- const imageUrl =
- rec.value.image && parsed
- ? resolveActivityImageUrl(rec.value.image, parsed.did)
- : null
- out.push({
- uri: rec.uri,
- cid: rec.cid,
- title:
- typeof rec.value.title === "string" && rec.value.title.length > 0
- ? rec.value.title
- : "Untitled activity",
- subtitle: null,
- imageUrl,
- })
- }
- return out
-}
-
-interface ProjectsResponse {
- data?: {
- orgHypercertsCollection?: {
- edges: {
- node: {
- uri: string
- cid: string
- title: string | null
- shortDescription: string | null
- } | null
- }[]
- } | null
- } | null
-}
-
-async function searchProjects(query: string, signal: AbortSignal): Promise {
- const res = await fetch(INDEXER_PROXY_URL, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- operationName: "Projects",
- variables: { first: 10, after: null, authors: null, search: query },
- }),
- signal,
- })
- if (!res.ok) throw new Error(`Project search failed: ${res.status}`)
- const json = (await res.json()) as ProjectsResponse
- const out: SearchResult[] = []
- for (const edge of json.data?.orgHypercertsCollection?.edges ?? []) {
- if (!edge.node) continue
- out.push({
- uri: edge.node.uri,
- cid: edge.node.cid,
- title: edge.node.title?.trim() || "Untitled project",
- subtitle: edge.node.shortDescription || null,
- })
- }
- return out
-}
-
diff --git a/src/components/profile/profile-overview.tsx b/src/components/profile/profile-overview.tsx
index 277a9be8..9a78b5a9 100644
--- a/src/components/profile/profile-overview.tsx
+++ b/src/components/profile/profile-overview.tsx
@@ -758,6 +758,7 @@ function LocationPickerColumn({
useEffect(() => {
if (lastSourceRef.current === "map") {
lastSourceRef.current = null
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- debounced geocode keyed on the typed name: the map-originated skip branch and the below-2-chars branch clear now-stale suggestions; both bail out when suggestions are already empty
setSuggestions([])
return
}
diff --git a/src/components/profile/profile-projects.tsx b/src/components/profile/profile-projects.tsx
index 4620dd12..d445f654 100644
--- a/src/components/profile/profile-projects.tsx
+++ b/src/components/profile/profile-projects.tsx
@@ -19,9 +19,14 @@ import { useManagedProjects } from "@/hooks/use-managed-projects"
import { useProjectItems, type ProjectItemResolution } from "@/hooks/use-project-items"
import { resolveActivityImageUrl } from "@/lib/atproto/activity"
import { activityDetailHref, parseAtUri } from "@/lib/atproto/activity-uri"
-import { formatShortDate } from "@/lib/utils/format-date"
+import { formatShortDate, formatTimePeriod } from "@/lib/utils/format-date"
import OwnerByline from "@/components/ui/owner-byline"
-import type { CollectionRecord } from "@/lib/atproto/collection"
+import {
+ asString,
+ projectImage,
+ projectTitle,
+ type CollectionRecord,
+} from "@/lib/atproto/collection"
import type { OwnerTag } from "@/lib/atproto/owner-tag"
interface ProfileProjectsProps {
@@ -248,25 +253,19 @@ function ProjectBox({ project, owner }: ProjectBoxProps) {
? recordUrl(parsed.did, "project", parsed.rkey)
: null
- const title =
- asString(value.title) || asString(value.name) || "Untitled project"
+ const title = projectTitle(value)
const shortDesc = asString(value.shortDescription)
const createdAt = asString(value.createdAt)
const createdLabel = createdAt ? formatShortDate(createdAt) : null
const { resolutions, isLoading } = useProjectItems(value.items)
- // Banner falls back to legacy `image`. Rendered much larger than
- // the previous compact thumbnail so the project reads as the
+ // Hero slot — banner-first (`projectImage` banner slot). Rendered
+ // much larger than a compact thumbnail so the project reads as the
// primary unit on the page.
- const rawImage = (value as Record).banner ?? value.image
+ const rawImage = projectImage(value, "banner")
const imageUrl =
- rawImage && projectDid
- ? resolveActivityImageUrl(
- rawImage as Parameters[0],
- projectDid,
- )
- : null
+ rawImage && projectDid ? resolveActivityImageUrl(rawImage, projectDid) : null
const [imageFailed, setImageFailed] = useState(false)
const showImage = !!imageUrl && !imageFailed
@@ -438,14 +437,6 @@ function CertThumb({ url }: { url: string | null }) {
)
}
-function asString(v: unknown): string | null {
- return typeof v === "string" && v.length > 0 ? v : null
-}
-
-function projectTitle(p: CollectionRecord): string {
- return asString(p.value.title) || asString(p.value.name) || "Untitled project"
-}
-
function filterAndSort(
projects: CollectionRecord[],
query: string,
@@ -454,7 +445,7 @@ function filterAndSort(
const q = query.trim().toLowerCase()
const matches = q
? projects.filter((p) => {
- const t = projectTitle(p).toLowerCase()
+ const t = projectTitle(p.value).toLowerCase()
const d = (asString(p.value.shortDescription) ?? "").toLowerCase()
return t.includes(q) || d.includes(q)
})
@@ -468,9 +459,9 @@ function filterAndSort(
case "created-asc":
return compareDate(asString(a.value.createdAt) ?? "", asString(b.value.createdAt) ?? "")
case "alpha-asc":
- return projectTitle(a).localeCompare(projectTitle(b))
+ return projectTitle(a.value).localeCompare(projectTitle(b.value))
case "alpha-desc":
- return projectTitle(b).localeCompare(projectTitle(a))
+ return projectTitle(b.value).localeCompare(projectTitle(a.value))
}
})
return sorted
@@ -480,25 +471,6 @@ function compareDate(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
-/**
- * Render the cert's time period exactly the way the cert detail page
- * formats it — so the row reads the same whether the user sees it
- * here or on the cert itself.
- *
- * - both set → "Jan 1, 2026 – Mar 15, 2026"
- * - only start → "Jan 1, 2026 (ongoing)"
- * - only end → "Until Mar 15, 2026"
- * - neither → null (caller skips the row)
- */
-function formatTimePeriod(start: string | null, end: string | null): string | null {
- if (!start && !end) return null
- const s = start ? formatShortDate(start) : null
- const e = end ? formatShortDate(end) : null
- if (s && e) return `${s} – ${e}`
- if (s) return `${s} (ongoing)`
- if (e) return `Until ${e}`
- return null
-}
function countActivityItems(items: unknown): number {
if (!Array.isArray(items)) return 0
diff --git a/src/components/project/project-detail.tsx b/src/components/project/project-detail.tsx
index 86bd1968..b43bcae1 100644
--- a/src/components/project/project-detail.tsx
+++ b/src/components/project/project-detail.tsx
@@ -55,12 +55,20 @@ import {
import { putProjectRecord } from "@/lib/atproto/project"
import { InvalidSwapError } from "@/lib/atproto/repo-write"
import { saveWithSwap } from "@/lib/atproto/save-with-swap"
-import { saveDraft } from "@/lib/utils/swap-drafts"
+import {
+ contributorKey,
+ contributionRoleText,
+} from "@/lib/atproto/contributor-display"
import { uploadBlob, type UploadedBlob } from "@/lib/atproto/profile"
import { asLinearDocument, isEmptyLongDescription } from "@/lib/leaflet/guards"
-import { formatShortDate } from "@/lib/utils/format-date"
+import { formatShortDate, formatTimePeriod } from "@/lib/utils/format-date"
import type { LinearDocument } from "@/lib/leaflet/types"
-import type { CollectionValue } from "@/lib/atproto/collection"
+import {
+ asString,
+ projectImage,
+ projectTitle,
+ type CollectionValue,
+} from "@/lib/atproto/collection"
import type { HypercertsLargeImage } from "@/lib/atproto/types"
import type {
ActivityContributor as ActivityContributorType,
@@ -88,10 +96,6 @@ interface ProjectDetailProps {
handle: string | null
}
-function asString(v: unknown): string | null {
- return typeof v === "string" && v.length > 0 ? v : null
-}
-
/** True when the existing description is present but in a shape the
* leaflet editor can't open in-place — a `com.atproto.repo.strongRef`
* union member, a `org.hypercerts.defs#descriptionString`, or any
@@ -115,27 +119,6 @@ function shouldPreserveDescription(
return true
}
-function contributorKey(
- c: ActivityContributorType,
- index: number,
-): string {
- const id = c.contributorIdentity as unknown
- if (id && typeof id === "object") {
- const obj = id as Record
- if (typeof obj.uri === "string") return `${obj.uri}#${index}`
- if (typeof obj.identity === "string") return `${obj.identity}#${index}`
- }
- if (typeof id === "string") return `${id}#${index}`
- return `contributor-${index}`
-}
-
-function contributionRoleText(details: unknown): string | null {
- if (typeof details === "string") return details
- if (!details || typeof details !== "object") return null
- const obj = details as Record
- return typeof obj.role === "string" ? obj.role : null
-}
-
/**
* Detail view for a single `org.hypercerts.collection` project record.
*
@@ -265,9 +248,7 @@ export default function ProjectDetail({
// no hand-rolled document listeners are needed here.
const title =
- asString(effectiveValue.title) ||
- asString(effectiveValue.name) ||
- "Untitled project"
+ projectTitle(effectiveValue)
const shortDesc = asString(effectiveValue.shortDescription)
const showFullDescription = isRenderableDescription(effectiveValue.description)
@@ -361,16 +342,9 @@ export default function ProjectDetail({
// 2. Post-save local mirror.
// 3. Re-resolved from the local mirror's record.
// 4. Original server value.
- const rawImage =
- (effectiveValue as Record).banner ??
- (effectiveValue as Record).image
+ const rawImage = projectImage(effectiveValue, "banner")
const serverImageUrl =
- rawImage && !imageRemoved
- ? resolveActivityImageUrl(
- rawImage as Parameters[0],
- did,
- )
- : null
+ rawImage && !imageRemoved ? resolveActivityImageUrl(rawImage, did) : null
const effectiveImageUrl =
pendingImagePreviewUrl ?? localImageUrl ?? serverImageUrl
@@ -448,14 +422,7 @@ export default function ProjectDetail({
}, [resolutions])
// Time period rendering — same rules as the cert detail.
- let timePeriodLabel: string | null = null
- if (startDate && endDate) {
- timePeriodLabel = `${formatShortDate(startDate)} – ${formatShortDate(endDate)}`
- } else if (startDate) {
- timePeriodLabel = `${formatShortDate(startDate)} (ongoing)`
- } else if (endDate) {
- timePeriodLabel = `Until ${formatShortDate(endDate)}`
- }
+ const timePeriodLabel = formatTimePeriod(startDate, endDate)
const certCount = resolutions.length
// Phones show the activities preview as full-width list rows (the
@@ -821,36 +788,21 @@ export default function ProjectDetail({
})
if (!result.ok) {
- // Conflict or livelock — persist drafts to localStorage so
- // the user can recover after refresh, and surface a clear
- // error in the EditBanner. Don't throw; the save handler's
- // catch below is for unexpected errors.
- saveDraft(sessionDid, "org.hypercerts.collection", rkey, {
- title: trimmedTitle,
- shortDescription: trimmedShort,
- description: drafts.description,
- items: draftItems,
- })
+ // Conflict or livelock — surface a clear error in the
+ // EditBanner. Don't throw; the save handler's catch below
+ // is for unexpected errors.
if (result.reason === "conflict") {
setSaveError(
- `Someone else saved while you were editing — conflicts on ${result.conflictingFields.join(", ")}. Your draft is saved locally; refresh to see the latest version and re-apply.`,
+ `Someone else saved while you were editing — conflicts on ${result.conflictingFields.join(", ")}. Refresh to see the latest version and re-apply your changes.`,
)
} else {
setSaveError(
- "Couldn't auto-merge after several retries — your draft is saved locally; refresh to see the latest version.",
+ "Couldn't auto-merge after several retries — refresh to see the latest version and try again.",
)
}
return
}
- // Success — clear any prior conflict draft.
- try {
- const { clearDraft } = await import("@/lib/utils/swap-drafts")
- clearDraft(sessionDid, "org.hypercerts.collection", rkey)
- } catch {
- // Non-fatal — module load shouldn't fail; if it does,
- // a stale draft just sticks around until next conflict.
- }
if (nextSaved) setLocalValue(nextSaved)
if (pendingImagePreviewUrl) {
// Revoke any prior local mirror before promoting the
diff --git a/src/components/project/project-edit-route.tsx b/src/components/project/project-edit-route.tsx
index afbcdff1..a040e5ae 100644
--- a/src/components/project/project-edit-route.tsx
+++ b/src/components/project/project-edit-route.tsx
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
-import { recordUrl } from "@/lib/urls"
+import { parseAtUri, recordUrl, rkeyFromUri } from "@/lib/urls"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { MapPin, Plus, X, FolderGit2 } from "lucide-react"
@@ -28,7 +28,11 @@ import { useProjectItems } from "@/hooks/use-project-items"
import { putProjectRecord } from "@/lib/atproto/project"
import { InvalidSwapError } from "@/lib/atproto/repo-write"
import { saveWithSwap } from "@/lib/atproto/save-with-swap"
-import type { CollectionValue } from "@/lib/atproto/collection"
+import {
+ asString,
+ projectImage,
+ type CollectionValue,
+} from "@/lib/atproto/collection"
import { asLinearDocument, isEmptyLongDescription } from "@/lib/leaflet/guards"
import { splitLocationName } from "@/lib/atproto/location"
import { resolveActivityImageUrl } from "@/lib/atproto/activity"
@@ -55,18 +59,6 @@ interface SelectedCert {
title: string
}
-const AT_URI_RE = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/
-
-function parseAtUri(uri: string): { did: string; collection: string; rkey: string } | null {
- const m = AT_URI_RE.exec(uri)
- if (!m) return null
- return { did: m[1], collection: m[2], rkey: m[3] }
-}
-
-function asString(v: unknown): string {
- return typeof v === "string" ? v : ""
-}
-
/**
* `/{actor}/project/{rkey}/edit` — full-page project editor. `actor` is
* resolved to a DID by the parent route; this component takes the resolved
@@ -154,8 +146,9 @@ export default function ProjectEditRoute({
if (seededRef.current) return
if (!project) return
const v = project.value
- setTitle(asString(v.title))
- setShortDescription(asString(v.shortDescription))
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot ref-guarded (seededRef) seeding of editable form state + swap baseline from the loaded project record; key-remount refactor tracked separately
+ setTitle(asString(v.title) ?? "")
+ setShortDescription(asString(v.shortDescription) ?? "")
setDescription(
asLinearDocument(v.description) ??
(typeof v.description === "string" && v.description.trim().length > 0
@@ -196,28 +189,38 @@ export default function ProjectEditRoute({
title:
typeof r.record!.value.title === "string" && r.record!.value.title.trim()
? r.record!.value.title.trim()
- : r.record!.uri.split("/").pop() ?? "(untitled activity)",
+ : rkeyFromUri(r.record!.uri) || "(untitled activity)",
}))
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot ref-guarded (itemsSeededRef) hydration of the editable items list once useProjectItems resolutions settle; re-running would stomp user reordering/removal
setItems(hydrated)
itemsSeededRef.current = true
}, [itemResolutions, itemsResolving, rawItems.length])
// Hydrate the location (single strongRef → AddedLocation) so the
// edit form shows the existing place name + lets the user clear
- // or replace it.
- useEffect(() => {
- if (!project) return
- const locRef = project.value.location as { uri?: string; cid?: string } | undefined
+ // or replace it. The synchronous outcomes (no ref, unparsable URI)
+ // are adjusted during render when the record identity changes (keyed
+ // on uri|cid, not object identity, which isn't render-stable); only
+ // the getRecord name lookup lives in the effect.
+ const projectKey = project ? `${project.uri}|${project.cid}` : null
+ const [prevProjectKey, setPrevProjectKey] = useState(projectKey)
+ if (prevProjectKey !== projectKey) {
+ setPrevProjectKey(projectKey)
+ const locRef = project?.value.location as { uri?: string; cid?: string } | undefined
if (!locRef?.uri || !locRef.cid) {
setLocation(null)
- return
- }
- let aborted = false
- const parsed = parseAtUri(locRef.uri)
- if (!parsed) {
+ } else if (!parseAtUri(locRef.uri)) {
setLocation({ ref: { uri: locRef.uri, cid: locRef.cid }, name: locRef.uri })
- return
}
+ }
+
+ useEffect(() => {
+ if (!project) return
+ const locRef = project.value.location as { uri?: string; cid?: string } | undefined
+ if (!locRef?.uri || !locRef.cid) return
+ const parsed = parseAtUri(locRef.uri)
+ if (!parsed) return
+ let aborted = false
const qs = new URLSearchParams({
repo: parsed.did,
collection: parsed.collection,
@@ -229,7 +232,7 @@ export default function ProjectEditRoute({
if (!res.ok) {
setLocation({
ref: { uri: locRef.uri!, cid: locRef.cid! },
- name: locRef.uri!.split("/").pop() ?? locRef.uri!,
+ name: rkeyFromUri(locRef.uri!) || locRef.uri!,
})
return
}
@@ -237,14 +240,14 @@ export default function ProjectEditRoute({
const raw = data.value?.name?.trim() ?? ""
const split = splitLocationName(raw)
const name =
- split.name || raw || locRef.uri!.split("/").pop() || "Location"
+ split.name || raw || rkeyFromUri(locRef.uri!) || "Location"
setLocation({ ref: { uri: locRef.uri!, cid: locRef.cid! }, name })
})
.catch(() => {
if (aborted) return
setLocation({
ref: { uri: locRef.uri!, cid: locRef.cid! },
- name: locRef.uri!.split("/").pop() ?? locRef.uri!,
+ name: rkeyFromUri(locRef.uri!) || locRef.uri!,
})
})
return () => {
@@ -266,6 +269,7 @@ export default function ProjectEditRoute({
const SHORT_DESC_MAX = 300
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- deliberate cross-field watcher clearing the save error on any edit; setError(null) bails out (no render) whenever error is already null
setError(null)
}, [
title,
@@ -323,15 +327,10 @@ export default function ProjectEditRoute({
// shape (BlobRef under `.image`); the same resolver project-detail
// uses works for both. `banner` is the new field; legacy projects
// sometimes only carry `image` as a square hero. Prefer banner.
- const rawBannerOrImage =
- (project?.value as Record | undefined)?.banner ??
- (project?.value as Record | undefined)?.image
+ const rawBannerOrImage = project ? projectImage(project.value, "banner") : null
const existingBannerUrl =
!bannerRemoved && rawBannerOrImage && did
- ? resolveActivityImageUrl(
- rawBannerOrImage as Parameters[0],
- did,
- )
+ ? resolveActivityImageUrl(rawBannerOrImage, did)
: null
const displayBannerUrl = pendingBannerPreviewUrl ?? existingBannerUrl
@@ -474,8 +473,8 @@ export default function ProjectEditRoute({
description: description,
}
const userMountSnapshot: UserShape = {
- title: asString(mountSnapshot.value.title),
- shortDescription: asString(mountSnapshot.value.shortDescription),
+ title: asString(mountSnapshot.value.title) ?? "",
+ shortDescription: asString(mountSnapshot.value.shortDescription) ?? "",
description: asLinearDocument(mountSnapshot.value.description) ?? null,
}
const result = await saveWithSwap({
@@ -509,8 +508,8 @@ export default function ProjectEditRoute({
return {
cid: data.cid,
value: {
- title: asString(data.value.title),
- shortDescription: asString(data.value.shortDescription),
+ title: asString(data.value.title) ?? "",
+ shortDescription: asString(data.value.shortDescription) ?? "",
description:
asLinearDocument(data.value.description) ?? null,
},
diff --git a/src/components/right-rail/news-section.tsx b/src/components/right-rail/news-section.tsx
index 7d9b4051..9ab722aa 100644
--- a/src/components/right-rail/news-section.tsx
+++ b/src/components/right-rail/news-section.tsx
@@ -3,6 +3,7 @@
import React, { useId } from "react"
import { useBskyPosts, type BskyPost } from "@/hooks/use-bsky-posts"
import { formatRelativeTime } from "@/lib/atproto/activity"
+import { rkeyFromUri } from "@/lib/urls"
import RichText from "./rich-text"
const DEFAULT_ACTOR = "certified.app"
@@ -72,7 +73,7 @@ export default function NewsSection({
function NewsPost({ post }: { post: BskyPost }) {
// The AT-URI ends in the rkey: at:///app.bsky.feed.post/.
// Bluesky's public web link is /profile//post/.
- const rkey = post.uri.split("/").pop() ?? ""
+ const rkey = rkeyFromUri(post.uri)
const permalink = rkey
? `https://bsky.app/profile/${encodeURIComponent(post.author.handle)}/post/${encodeURIComponent(rkey)}`
: `https://bsky.app/profile/${encodeURIComponent(post.author.handle)}`
diff --git a/src/components/settings/settings-panel.tsx b/src/components/settings/settings-panel.tsx
index b013d1b9..afa5cd15 100644
--- a/src/components/settings/settings-panel.tsx
+++ b/src/components/settings/settings-panel.tsx
@@ -14,7 +14,6 @@ import {
import { useAuth } from "@/lib/auth/auth-context"
import { useSession } from "@/hooks/use-session"
import { useOrg } from "@/lib/groups/org-context"
-import OrgSettings from "@/components/groups/org-settings"
import SyncSocialGraphSection from "@/components/settings/sync-social-graph-section"
import ImportAsGroupSection from "@/components/settings/import-as-group-section"
import AppPasswordsSection from "@/components/settings/app-passwords-section"
@@ -31,6 +30,12 @@ const EmailSection = dynamic(
const PasswordSection = dynamic(
() => import("@/components/account/password-section"),
)
+// Group-admin subtree (member management, group password reset) —
+// only group accounts render it, so keep it out of the personal
+// /settings first-load chunk.
+const OrgSettings = dynamic(
+ () => import("@/components/groups/org-settings"),
+)
type PageKey =
| "account"
diff --git a/src/components/settings/sync-social-graph-section.tsx b/src/components/settings/sync-social-graph-section.tsx
index ebaa0f3c..3b034b67 100644
--- a/src/components/settings/sync-social-graph-section.tsx
+++ b/src/components/settings/sync-social-graph-section.tsx
@@ -19,7 +19,7 @@ import {
useSocialGraphSync,
type SocialGraphSyncResult,
} from "@/hooks/use-social-graph-sync"
-import { getInitials } from "@/lib/utils/initials"
+import { deriveIdentity } from "@/lib/utils/identity"
interface SyncSocialGraphSectionProps {
/**
@@ -614,8 +614,7 @@ interface CandidateRowProps {
function CandidateRow({ did, checked, onToggle, disabled }: CandidateRowProps) {
const { info, isLoading } = useAuthorInfo(did)
- const name = info?.displayName || info?.handle || did
- const handle = info?.handle && info.handle !== info.did ? info.handle : null
+ const { displayName: name, handle, initials } = deriveIdentity(info, did)
return (
@@ -632,7 +631,7 @@ function CandidateRow({ did, checked, onToggle, disabled }: CandidateRowProps) {
)}
diff --git a/src/components/ui/app-dialog.tsx b/src/components/ui/app-dialog.tsx
index 77e42f69..448bec57 100644
--- a/src/components/ui/app-dialog.tsx
+++ b/src/components/ui/app-dialog.tsx
@@ -213,14 +213,18 @@ export default function AppDialog({
// would re-call `showModal()` on an already-open dialog and throw
// `InvalidStateError`, unmounting the modal mid-task).
const onCloseRef = useRef(onClose)
- onCloseRef.current = onClose
// Stash the auto-focus props in refs so the mount-once effect below
// reads their latest values without taking them as deps (which would
// re-run the effect and re-call `showModal()` → InvalidStateError).
const autoFocusFirstRef = useRef(autoFocusFirst)
- autoFocusFirstRef.current = autoFocusFirst
const initialFocusRef_ = useRef(initialFocusRef)
- initialFocusRef_.current = initialFocusRef
+ // Latest-ref sync: in an effect (not render) per react-hooks/refs.
+ // Declared before the mount effect so it runs first in each commit.
+ useEffect(() => {
+ onCloseRef.current = onClose
+ autoFocusFirstRef.current = autoFocusFirst
+ initialFocusRef_.current = initialFocusRef
+ })
useEffect(() => {
const dialog = dialogRef.current
@@ -328,7 +332,6 @@ export default function AppDialog({
}
// Mount-once: no dep on `onClose`. The listener reads the latest
// value via the ref above.
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleBackdropClick = useCallback(
@@ -373,7 +376,7 @@ export default function AppDialog({
// UA's top-layer placement: centered in the VIEWPORT, wherever the
// page is scrolled.
const baseChrome =
- "fixed inset-0 m-auto flex w-[90vw] max-w-[420px] max-h-[calc(100vh-40px)] flex-col items-stretch overflow-x-hidden overflow-y-auto rounded-[var(--radius)] border border-[var(--border-default)] bg-[var(--color-off-white)] p-0 text-[var(--fg-primary)] font-normal normal-case tracking-normal shadow-[0_24px_64px_var(--navy-overlay-30)] backdrop:bg-[var(--modal-backdrop)] motion-safe:animate-[modalSlideUp_300ms_cubic-bezier(0.16,1,0.3,1)] max-[799px]:w-full max-[799px]:max-w-none"
+ "fixed inset-0 m-auto flex w-[90vw] max-w-[420px] max-h-[calc(100vh-40px)] flex-col items-stretch overflow-x-hidden overflow-y-auto rounded-[var(--radius)] border border-[var(--border-default)] bg-[var(--color-off-white)] p-0 text-[var(--fg-primary)] font-normal normal-case tracking-normal shadow-[shadow:var(--shadow-modal)] backdrop:bg-[var(--modal-backdrop)] motion-safe:animate-[modalSlideUp_300ms_cubic-bezier(0.16,1,0.3,1)] max-[799px]:w-full max-[799px]:max-w-none"
const composedClassName = className
? `${baseChrome} ${className}`
diff --git a/src/components/ui/bottom-sheet.tsx b/src/components/ui/bottom-sheet.tsx
index 70cb28bb..712abcf4 100644
--- a/src/components/ui/bottom-sheet.tsx
+++ b/src/components/ui/bottom-sheet.tsx
@@ -90,7 +90,7 @@ export default function BottomSheet({
const sheetBase =
// `bottom-sheet` is kept as a JS hook (navbar.tsx click-outside guard uses
// closest('.bottom-sheet, .bottom-sheet__backdrop')); styling is the Tailwind below.
- "bottom-sheet hidden max-[799px]:flex max-[799px]:flex-col max-[799px]:fixed max-[799px]:bottom-0 max-[799px]:left-0 max-[799px]:right-0 max-[799px]:max-h-[70vh] max-[799px]:bg-[var(--bg-elevated)] max-[799px]:rounded-t-[var(--radius)] max-[799px]:z-[71] max-[799px]:overflow-hidden max-[799px]:animate-[bottomSheetSlideUp_0.3s_ease-out] max-[799px]:transition-[max-height] max-[799px]:duration-300 max-[799px]:ease-out"
+ "bottom-sheet hidden max-[799px]:flex max-[799px]:flex-col max-[799px]:fixed max-[799px]:bottom-0 max-[799px]:left-0 max-[799px]:right-0 max-[799px]:max-h-[70vh] max-[799px]:bg-[var(--bg-elevated)] max-[799px]:rounded-t-[var(--radius)] max-[799px]:z-[var(--z-portal-sheet)] max-[799px]:overflow-hidden max-[799px]:animate-[bottomSheetSlideUp_0.3s_ease-out] max-[799px]:transition-[max-height] max-[799px]:duration-300 max-[799px]:ease-out"
// `.bottom-sheet--expanded` raises the cap to 92vh.
const sheetExpandedClass = sheetExpanded ? " max-[799px]:max-h-[92vh]" : ""
const sheetClassName = `${sheetBase}${sheetExpandedClass}${
@@ -120,7 +120,9 @@ export default function BottomSheet({
return createPortal(
<>
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
index 5bfb992c..5a4bc160 100644
--- a/src/components/ui/checkbox.tsx
+++ b/src/components/ui/checkbox.tsx
@@ -72,7 +72,7 @@ const Checkbox = React.forwardRef(
// The native input is the real control: it stays in the a11y tree
// and stacks above the box so clicks/keyboard hit it. `peer` lets
// the box react to its :checked / :focus-visible / :disabled state.
- className="peer absolute inset-0 z-[1] m-0 h-4 w-4 cursor-pointer appearance-none rounded opacity-0 disabled:cursor-not-allowed"
+ className="peer absolute inset-0 z-[var(--z-local-raise)] m-0 h-4 w-4 cursor-pointer appearance-none rounded opacity-0 disabled:cursor-not-allowed"
{...props}
/>
{
- setMounted(true);
- }, []);
+ // next-themes only knows the real theme on the client; render the
+ // undefined (system-default) state until hydration completes.
+ const mounted = useMounted();
const current = mounted ? (theme as ThemeValue | undefined) : undefined;
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index 08122728..1a36f924 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -8,6 +8,7 @@ import React, {
useLayoutEffect,
useRef,
useState,
+ useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { useMounted } from "@/hooks/use-mounted";
@@ -63,22 +64,26 @@ interface Coords {
const GAP = 8;
const PADDING = 8;
+const HOVER_QUERY = "(hover: hover) and (pointer: fine)";
+
+function subscribeHoverCapable(onChange: () => void): () => void {
+ const mq = globalThis.matchMedia?.(HOVER_QUERY);
+ if (!mq) return () => {};
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+}
+
/**
* True once we know the primary pointer can actually hover (mouse /
- * trackpad). Starts false — matching the SSR markup — and flips in an
- * effect, so touch devices never mount the hover listeners at all.
+ * trackpad). The server snapshot is false — matching the SSR markup —
+ * so touch devices never mount the hover listeners at all.
*/
function useHoverCapable(): boolean {
- const [capable, setCapable] = useState(false);
- useEffect(() => {
- const mq = globalThis.matchMedia?.("(hover: hover) and (pointer: fine)");
- if (!mq) return;
- setCapable(mq.matches);
- const onChange = (e: MediaQueryListEvent) => setCapable(e.matches);
- mq.addEventListener("change", onChange);
- return () => mq.removeEventListener("change", onChange);
- }, []);
- return capable;
+ return useSyncExternalStore(
+ subscribeHoverCapable,
+ () => globalThis.matchMedia?.(HOVER_QUERY)?.matches ?? false,
+ () => false,
+ );
}
function computeCoords(
@@ -137,9 +142,12 @@ export default function Tooltip({
}, []);
const show = useCallback(() => setOpen(true), []);
+ // hide() is the only path that sets open=false, so clearing the stale
+ // coords here keeps the bubble hidden-until-measured on the next open.
const hide = useCallback(() => {
clearTimer();
setOpen(false);
+ setCoords(null);
}, [clearTimer]);
const onPointerEnter = useCallback(() => {
@@ -151,10 +159,7 @@ export default function Tooltip({
// Position when open; reposition on scroll/resize; hide on Esc.
useLayoutEffect(() => {
- if (!open) {
- setCoords(null);
- return;
- }
+ if (!open) return;
const reposition = () => {
const trigger = wrapRef.current;
const bubble = bubbleRef.current;
diff --git a/src/components/visualization/endorsement-graph.tsx b/src/components/visualization/endorsement-graph.tsx
index 41a68900..3b41ff8e 100644
--- a/src/components/visualization/endorsement-graph.tsx
+++ b/src/components/visualization/endorsement-graph.tsx
@@ -270,6 +270,11 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
// Background is read during render (a prop), so it lives in state rather
// than the colours ref; updated alongside the ref on theme changes.
const [bgColor, setBgColor] = useState(undefined)
+ // Bumped when a repaint is needed for reasons React can't see: an avatar
+ // image finishing loading, or a theme flip updating colorsRef. Carried in
+ // the painter callbacks' deps — force-graph repaints once per new painter
+ // identity, so the canvas stays paused when idle (autoPauseRedraw).
+ const [repaintEpoch, setRepaintEpoch] = useState(0)
const [onlyMutual, setOnlyMutual] = useState(false)
// Badge-kind checkboxes. Both on by default; the UI disables the last
// checked one so at least one kind is always shown.
@@ -298,6 +303,9 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
const c = readThemeColors()
colorsRef.current = c
setBgColor(c.bg)
+ // The painters read colorsRef (a ref), so a theme flip needs an
+ // explicit repaint or nodes/links keep the stale theme's colours.
+ setRepaintEpoch((e) => e + 1)
}
apply()
const obs = new MutationObserver(apply)
@@ -321,21 +329,45 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
}, [])
// --- preload avatars ---------------------------------------------------
- // The canvas runs with autoPauseRedraw disabled (see the ForceGraph2D
- // props below), so it keeps repainting every frame and avatars appear as
- // their images finish loading — no manual repaint needed. We still
- // preload into a stable map so the paint loop never mints a new Image per
- // frame.
+ // Coalesce repaint bumps to one per animation frame: image load events
+ // fire as separate tasks (React can't batch them), so a graph with
+ // hundreds of avatar nodes would otherwise re-render this component
+ // once per image in a burst after mount. The pending rAF is cancelled
+ // on unmount so a mid-burst unmount can't fire a stray callback.
+ const repaintRafRef = useRef(null)
+ const scheduleRepaint = useCallback(() => {
+ if (repaintRafRef.current !== null) return
+ repaintRafRef.current = requestAnimationFrame(() => {
+ repaintRafRef.current = null
+ setRepaintEpoch((e) => e + 1)
+ })
+ }, [])
+ useEffect(
+ () => () => {
+ if (repaintRafRef.current !== null) {
+ cancelAnimationFrame(repaintRafRef.current)
+ }
+ },
+ [],
+ )
+
+ // Preload into a stable map so the paint loop never mints a new Image per
+ // frame. Each image that finishes (or fails) schedules a coalesced
+ // repaintEpoch bump so the paused canvas repaints and the avatar appears
+ // — the ref has no public refresh(), and load events fire asynchronously
+ // even for cached images, so hooking them here misses nothing.
useEffect(() => {
const map = imagesRef.current
for (const n of nodes) {
if (!n.avatarUrl || map.has(n.id)) continue
const img = new Image()
img.decoding = "async"
+ img.onload = scheduleRepaint
+ img.onerror = scheduleRepaint
img.src = n.avatarUrl
map.set(n.id, img)
}
- }, [nodes])
+ }, [nodes, scheduleRepaint])
// --- filtered working data (kind checkboxes + scope + mutual) ----------
const data = useMemo(() => {
@@ -445,12 +477,14 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
// Configure the simulation per layout mode. Re-runs when the mode or the
// working data changes (react-force-graph rebuilds its default charge/link/
// center forces on a graphData swap, so we re-apply our additions after) and
- // once `size.w` flips positive — that's the render where the graph mounts
- // and `fgRef.current` becomes available. Collision is on in every mode so
- // avatars don't overlap.
+ // once `graphMounted` flips true — that's the render where the graph mounts
+ // and `fgRef.current` becomes available. A boolean dep (not `size.w`) so
+ // later container resizes never re-apply forces or reheat the simulation.
+ // Collision is on in every mode so avatars don't overlap.
+ const graphMounted = size.w > 0
useEffect(() => {
const fg = fgRef.current
- if (!fg || size.w === 0) return
+ if (!fg || !graphMounted) return
const charge = fg.d3Force("charge")
const link = fg.d3Force("link")
const count = data.nodes.length
@@ -487,7 +521,7 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
fg.d3Force("gravity", makeGravityForce(0.09))
}
fg.d3ReheatSimulation?.()
- }, [layout, data, size.w])
+ }, [layout, data, graphMounted])
// --- adjacency for hover/selection highlight ---------------------------
// Built from the FILTERED links so hover dimming, focus zoom and the
@@ -638,7 +672,8 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
}
ctx.restore()
},
- [activeId, highlightNodes],
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- repaintEpoch forces one repaint when refs the painter reads change (avatar loads, theme flip)
+ [activeId, highlightNodes, repaintEpoch],
)
const paintPointerArea = useCallback(
@@ -665,7 +700,8 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
if (link.kind === "award") return c.award
return link.mutual ? c.accent : c.link
},
- [highlightNodes],
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- repaintEpoch forces one repaint when colorsRef changes on a theme flip
+ [highlightNodes, repaintEpoch],
)
const selectedNode = selectedId ? nodeById.get(selectedId) ?? null : null
@@ -755,16 +791,13 @@ export default function EndorsementGraph({ nodes, links, focusReq, truncated = f
return (
- {size.w > 0 && (
+ {graphMounted && (
""}
cooldownTicks={120}
diff --git a/src/components/workspace/workspace.tsx b/src/components/workspace/workspace.tsx
index db18a884..3775f825 100644
--- a/src/components/workspace/workspace.tsx
+++ b/src/components/workspace/workspace.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useCallback, useEffect, useState } from "react"
+import { useCallback, useEffect, useRef } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import LoadingSpinner from "@/components/ui/loading-spinner"
import { Tabs, TabList, Tab, TabPanel } from "@/components/ui/tabs"
@@ -83,17 +83,18 @@ export default function Workspace() {
// Help with first-time-empty: if there's no `actor` param yet but
// we have actors loaded, pick the first one as a default scope so
- // the comparison surfaces start populated.
- const [defaulted, setDefaulted] = useState(false)
+ // the comparison surfaces start populated. The latch is only read
+ // inside this effect, so a ref avoids a render.
+ const defaultedRef = useRef(false)
useEffect(() => {
- if (defaulted) return
+ if (defaultedRef.current) return
if (scope.kind !== "network") return
if (!actors.length) return
- setDefaulted(true)
+ defaultedRef.current = true
const params = new URLSearchParams(searchParams?.toString() ?? "")
params.set("actor", actors[0].did)
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
- }, [actors, defaulted, scope.kind, searchParams, pathname, router])
+ }, [actors, scope.kind, searchParams, pathname, router])
const setUrl = useCallback(
(next: Partial<{ layout: LayoutKey; actor: string | null; lexicon: WorkspaceLexicon | null }>) => {
diff --git a/src/hooks/__tests__/create-cached-did-resource.test.tsx b/src/hooks/__tests__/create-cached-did-resource.test.tsx
new file mode 100644
index 00000000..d3b71f23
--- /dev/null
+++ b/src/hooks/__tests__/create-cached-did-resource.test.tsx
@@ -0,0 +1,221 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { renderHook, act, waitFor, cleanup } from "@testing-library/react"
+import { createCachedDidResource } from "../create-cached-did-resource"
+
+// Each test builds its own resource (the factory owns per-resource
+// cache + inflight maps), so there is no cross-test module state.
+
+const STALE_MS = 5 * 60 * 1000
+
+beforeEach(() => {
+ cleanup()
+})
+
+describe("createCachedDidResource — singleflight", () => {
+ it("shares one fetch across simultaneous mounts for the same DID", async () => {
+ let resolveFetch: (v: string[]) => void = () => {}
+ const fetcher = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve
+ }),
+ )
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: fetcher,
+ onError: "reset",
+ errorFallback: "failed",
+ })
+
+ const a = renderHook(() => useResource("did:plc:one"))
+ const b = renderHook(() => useResource("did:plc:one"))
+
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1))
+
+ await act(async () => {
+ resolveFetch(["x"])
+ })
+
+ await waitFor(() => {
+ expect(a.result.current.data).toEqual(["x"])
+ expect(b.result.current.data).toEqual(["x"])
+ })
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+
+ it("one consumer unmounting does not fail its sibling", async () => {
+ let resolveFetch: (v: number) => void = () => {}
+ const fetcher = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve
+ }),
+ )
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: fetcher,
+ onError: "reset",
+ errorFallback: "failed",
+ })
+
+ const a = renderHook(() => useResource("did:plc:two"))
+ const b = renderHook(() => useResource("did:plc:two"))
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1))
+
+ // The shared promise must not be bound to the first caller's
+ // AbortSignal — its unmount only suppresses its own setState.
+ a.unmount()
+ await act(async () => {
+ resolveFetch(7)
+ })
+
+ await waitFor(() => expect(b.result.current.data).toBe(7))
+ expect(b.result.current.error).toBeNull()
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+
+ it("serves the fresh cache to re-mounts without refetching", async () => {
+ const fetcher = vi.fn(async () => "value")
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: fetcher,
+ onError: "reset",
+ errorFallback: "failed",
+ })
+
+ const first = renderHook(() => useResource("did:plc:three"))
+ await waitFor(() => expect(first.result.current.data).toBe("value"))
+ first.unmount()
+
+ const second = renderHook(() => useResource("did:plc:three"))
+ await waitFor(() => expect(second.result.current.data).toBe("value"))
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+
+ it("refetch bypasses the cache AND a pending in-flight fetch", async () => {
+ const resolvers: ((v: string) => void)[] = []
+ const fetcher = vi.fn(
+ (_did: string, _opts: { force: boolean }) =>
+ new Promise((resolve) => {
+ resolvers.push(resolve)
+ }),
+ )
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: fetcher,
+ onError: "reset",
+ errorFallback: "failed",
+ })
+
+ const hook = renderHook(() => useResource("did:plc:four"))
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1))
+
+ // Refetch while the mount fetch is still pending: it must start a
+ // brand-new forced fetch rather than joining the stale one.
+ let refetchPromise: Promise = Promise.resolve()
+ act(() => {
+ refetchPromise = hook.result.current.refetch()
+ })
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2))
+ expect(fetcher.mock.calls[1][1]).toEqual({ force: true })
+
+ await act(async () => {
+ resolvers[1]("fresh")
+ await refetchPromise
+ })
+ expect(hook.result.current.data).toBe("fresh")
+
+ // The superseded pre-refetch fetch settling late must NOT clobber
+ // the cache: a new mount reads "fresh" without another fetch.
+ await act(async () => {
+ resolvers[0]("stale")
+ })
+ const second = renderHook(() => useResource("did:plc:four"))
+ await waitFor(() => expect(second.result.current.data).toBe("fresh"))
+ expect(fetcher).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe("createCachedDidResource — error policy", () => {
+ function failingSecondFetch(): ReturnType {
+ let calls = 0
+ return vi.fn(async () => {
+ calls++
+ if (calls === 1) return "ok"
+ throw new Error("boom")
+ })
+ }
+
+ it('"reset" drops the previous value on failure', async () => {
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: failingSecondFetch() as (did: string, opts: { force: boolean }) => Promise,
+ onError: "reset",
+ errorFallback: "failed",
+ })
+ const hook = renderHook(() => useResource("did:plc:reset"))
+ await waitFor(() => expect(hook.result.current.data).toBe("ok"))
+
+ await act(async () => {
+ await hook.result.current.refetch()
+ })
+ expect(hook.result.current.error).toBe("boom")
+ expect(hook.result.current.data).toBeNull()
+ })
+
+ it('"retain" keeps the previous value next to the error', async () => {
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: failingSecondFetch() as (did: string, opts: { force: boolean }) => Promise,
+ onError: "retain",
+ errorFallback: "failed",
+ })
+ const hook = renderHook(() => useResource("did:plc:retain"))
+ await waitFor(() => expect(hook.result.current.data).toBe("ok"))
+
+ await act(async () => {
+ await hook.result.current.refetch()
+ })
+ expect(hook.result.current.error).toBe("boom")
+ expect(hook.result.current.data).toBe("ok")
+ })
+})
+
+describe("createCachedDidResource — mutate", () => {
+ it("writes through to the module cache so re-mounts see the mutation", async () => {
+ const fetcher = vi.fn(async () => ["a"])
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: fetcher,
+ onError: "reset",
+ errorFallback: "failed",
+ })
+
+ const first = renderHook(() => useResource("did:plc:mutate"))
+ await waitFor(() => expect(first.result.current.data).toEqual(["a"]))
+
+ act(() => {
+ first.result.current.mutate((prev) => ["b", ...(prev ?? [])])
+ })
+ expect(first.result.current.data).toEqual(["b", "a"])
+ first.unmount()
+
+ const second = renderHook(() => useResource("did:plc:mutate"))
+ await waitFor(() => expect(second.result.current.data).toEqual(["b", "a"]))
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+
+ it("returns the empty state for a null DID without fetching", async () => {
+ const fetcher = vi.fn(async () => "never")
+ const useResource = createCachedDidResource({
+ staleMs: STALE_MS,
+ fetch: fetcher,
+ onError: "reset",
+ errorFallback: "failed",
+ })
+ const hook = renderHook(() => useResource(null))
+ await waitFor(() => expect(hook.result.current.isLoading).toBe(false))
+ expect(hook.result.current.data).toBeNull()
+ expect(fetcher).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/__tests__/use-activity-malformed-value.test.tsx b/src/hooks/__tests__/use-activity-malformed-value.test.tsx
new file mode 100644
index 00000000..222b1096
--- /dev/null
+++ b/src/hooks/__tests__/use-activity-malformed-value.test.tsx
@@ -0,0 +1,104 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { renderHook, waitFor, cleanup } from "@testing-library/react"
+
+/**
+ * Tests the coerceClaimActivityValue guard on `useActivity`'s live-PDS
+ * path. ATProto is open, so a foreign repo can hold a claim.activity whose
+ * string-declared fields are objects; unguarded, those crash React render
+ * sites ("Objects are not valid as a React child"). The guard must blank
+ * the render fields while preserving everything else — the loadActivity
+ * cache seeds the edit route's form.
+ *
+ * Mirrors the mock setup of use-activity-indexer-fallback.test.tsx; each
+ * test uses a unique did/rkey so the module-level cache doesn't bleed.
+ */
+
+const authFetch = vi.fn()
+vi.mock("@/lib/auth/fetch", () => ({
+ authFetch: (...a: unknown[]) => authFetch(...a),
+}))
+
+const fetchIndexerActivitiesByUris = vi.fn()
+vi.mock("@/lib/atproto/indexer", () => ({
+ fetchIndexerActivitiesByUris: (...a: unknown[]) =>
+ fetchIndexerActivitiesByUris(...a),
+}))
+
+import { useActivity } from "../use-activity"
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ })
+}
+
+beforeEach(() => {
+ cleanup()
+ authFetch.mockReset()
+ fetchIndexerActivitiesByUris.mockReset()
+})
+
+describe("useActivity — malformed PDS value coercion", () => {
+ it("blanks non-string render fields and keeps the rest of the record", async () => {
+ const did = "did:plc:malformedcase0000000a"
+ const rkey = "rkeyMal"
+ const uri = `at://${did}/org.hypercerts.claim.activity/${rkey}`
+
+ authFetch.mockResolvedValue(
+ jsonResponse({
+ uri,
+ cid: "cid-mal",
+ value: {
+ title: { evil: true },
+ shortDescription: ["not", "a", "string"],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ image: { mimeType: "image/png" },
+ workScope: "biodiversity",
+ },
+ }),
+ )
+
+ const { result } = renderHook(() => useActivity(did, rkey))
+
+ await waitFor(() => expect(result.current.activity).not.toBeNull())
+ expect(result.current.error).toBeNull()
+ expect(result.current.activity?.value.title).toBe("")
+ expect(result.current.activity?.value.shortDescription).toBe("")
+ expect(result.current.activity?.value.createdAt).toBe(
+ "2026-01-01T00:00:00.000Z",
+ )
+ // Non-render fields survive the coercion untouched.
+ expect(result.current.activity?.value.image).toEqual({
+ mimeType: "image/png",
+ })
+ expect(result.current.activity?.value.workScope).toBe("biodiversity")
+ expect(fetchIndexerActivitiesByUris).not.toHaveBeenCalled()
+ })
+
+ it("leaves a well-formed PDS value intact", async () => {
+ const did = "did:plc:wellformedcase000000a"
+ const rkey = "rkeyOk"
+ const uri = `at://${did}/org.hypercerts.claim.activity/${rkey}`
+
+ authFetch.mockResolvedValue(
+ jsonResponse({
+ uri,
+ cid: "cid-ok",
+ value: {
+ title: "Live Activity",
+ shortDescription: "All strings",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ startDate: "2025-11-01",
+ },
+ }),
+ )
+
+ const { result } = renderHook(() => useActivity(did, rkey))
+
+ await waitFor(() => expect(result.current.activity).not.toBeNull())
+ expect(result.current.activity?.value.title).toBe("Live Activity")
+ expect(result.current.activity?.value.shortDescription).toBe("All strings")
+ expect(result.current.activity?.value.startDate).toBe("2025-11-01")
+ })
+})
diff --git a/src/hooks/__tests__/use-context-updates.test.tsx b/src/hooks/__tests__/use-context-updates.test.tsx
new file mode 100644
index 00000000..3db85096
--- /dev/null
+++ b/src/hooks/__tests__/use-context-updates.test.tsx
@@ -0,0 +1,183 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { renderHook, act, waitFor, cleanup } from "@testing-library/react"
+
+// Controllable mock of fetchContextUpdates. Each call records its subject
+// URI and returns a deferred promise we settle by hand, so a test can
+// hold a fetch in-flight while a second instance mounts.
+interface PendingFetch {
+ subjectUri: string
+ resolve: (records: unknown[]) => void
+ reject: (err: unknown) => void
+}
+
+const calls: PendingFetch[] = []
+
+vi.mock("@/lib/atproto/context-attachment", () => ({
+ fetchContextUpdates: vi.fn(
+ (_did: string, subjectUri: string) =>
+ new Promise((resolve, reject) => {
+ calls.push({ subjectUri, resolve, reject })
+ }),
+ ),
+}))
+
+import {
+ useContextUpdates,
+ invalidateContextUpdates,
+} from "../use-context-updates"
+
+/** Minimal update record — only the fields the hook touches. */
+function record(uri: string, createdAt: string) {
+ return {
+ uri,
+ cid: "bafyupdate",
+ value: { contentType: "update", title: "t", createdAt },
+ }
+}
+
+const callsFor = (uri: string) => calls.filter((c) => c.subjectUri === uri)
+
+/** Settle the newest pending fetch for `uri` inside act(). */
+async function resolveLatest(uri: string, records: unknown[]) {
+ await act(async () => {
+ const pending = callsFor(uri)
+ pending[pending.length - 1].resolve(records)
+ })
+}
+
+beforeEach(() => {
+ cleanup()
+ calls.length = 0
+})
+
+// Unique subject URIs per test — the cache under test is module-level
+// and persists across tests in this file.
+const subject = (n: string) =>
+ `at://did:plc:ctx${n}/org.hypercerts.claim.activity/rkey${n}`
+
+describe("useContextUpdates — shared cache + in-flight coalescing", () => {
+ it("two mounted instances share one fetch and both receive the sorted list", async () => {
+ const uri = subject("share")
+ const a = renderHook(() => useContextUpdates(uri))
+ const b = renderHook(() => useContextUpdates(uri))
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(1))
+
+ await resolveLatest(uri, [
+ record(`${uri}#u-old`, "2026-01-01T00:00:00Z"),
+ record(`${uri}#u-new`, "2026-02-01T00:00:00Z"),
+ ])
+
+ // One network call total; both instances see createdAt-DESC order.
+ expect(callsFor(uri)).toHaveLength(1)
+ const uris = (h: typeof a) => h.result.current.updates.map((u) => u.uri)
+ expect(uris(a)).toEqual([`${uri}#u-new`, `${uri}#u-old`])
+ expect(uris(b)).toEqual(uris(a))
+ })
+
+ it("removeUpdate patches every mounted instance and tombstones the URI against a stale refetch", async () => {
+ const uri = subject("remove")
+ const a = renderHook(() => useContextUpdates(uri))
+ const b = renderHook(() => useContextUpdates(uri))
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(1))
+ await resolveLatest(uri, [
+ record(`${uri}#u1`, "2026-01-01T00:00:00Z"),
+ record(`${uri}#u2`, "2026-02-01T00:00:00Z"),
+ ])
+
+ // Delete via instance A — instance B (e.g. the navbar count) must
+ // converge without its own refetch.
+ act(() => {
+ a.result.current.removeUpdate(`${uri}#u2`)
+ })
+ expect(a.result.current.updates.map((u) => u.uri)).toEqual([`${uri}#u1`])
+ expect(b.result.current.updates.map((u) => u.uri)).toEqual([`${uri}#u1`])
+
+ // The reconcile refetch still returns the deleted record (indexer
+ // lag) — the tombstone keeps it from resurrecting.
+ act(() => {
+ b.result.current.refetch()
+ })
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(2))
+ await resolveLatest(uri, [
+ record(`${uri}#u1`, "2026-01-01T00:00:00Z"),
+ record(`${uri}#u2`, "2026-02-01T00:00:00Z"),
+ ])
+ expect(a.result.current.updates.map((u) => u.uri)).toEqual([`${uri}#u1`])
+ expect(b.result.current.updates.map((u) => u.uri)).toEqual([`${uri}#u1`])
+ })
+
+ it("a remount inside the freshness window serves the cache without a new fetch", async () => {
+ const uri = subject("remount")
+ const a = renderHook(() => useContextUpdates(uri))
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(1))
+ await resolveLatest(uri, [record(`${uri}#u1`, "2026-01-01T00:00:00Z")])
+ a.unmount()
+
+ // Tab-switch remount: data is served synchronously from the cache.
+ const b = renderHook(() => useContextUpdates(uri))
+ expect(b.result.current.updates.map((u) => u.uri)).toEqual([`${uri}#u1`])
+ expect(b.result.current.isLoading).toBe(false)
+ expect(callsFor(uri)).toHaveLength(1)
+ })
+
+ it("invalidateContextUpdates forces the next mount to re-fetch (create/edit save path)", async () => {
+ const uri = subject("invalidate")
+ const a = renderHook(() => useContextUpdates(uri))
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(1))
+ await resolveLatest(uri, [record(`${uri}#u1`, "2026-01-01T00:00:00Z")])
+ a.unmount()
+
+ // The update form saved on its own route and invalidated the subject.
+ invalidateContextUpdates(uri)
+
+ const b = renderHook(() => useContextUpdates(uri))
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(2))
+ await resolveLatest(uri, [
+ record(`${uri}#u1`, "2026-01-01T00:00:00Z"),
+ record(`${uri}#u-created`, "2026-03-01T00:00:00Z"),
+ ])
+ expect(b.result.current.updates.map((u) => u.uri)).toEqual([
+ `${uri}#u-created`,
+ `${uri}#u1`,
+ ])
+ })
+
+ it("null and unparseable subjects never fetch", async () => {
+ const a = renderHook(() => useContextUpdates(null))
+ expect(a.result.current.updates).toEqual([])
+ expect(a.result.current.isLoading).toBe(false)
+ expect(a.result.current.error).toBeNull()
+
+ const b = renderHook(() => useContextUpdates("not-an-at-uri"))
+ await waitFor(() =>
+ expect(b.result.current.isLoading).toBe(false),
+ )
+ expect(b.result.current.updates).toEqual([])
+ expect(b.result.current.error).toBeNull()
+ expect(calls).toHaveLength(0)
+ })
+
+ it("a failed fetch surfaces the error and is not cached — the next mount retries", async () => {
+ const uri = subject("error")
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {})
+ try {
+ const a = renderHook(() => useContextUpdates(uri))
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(1))
+ await act(async () => {
+ callsFor(uri)[0].reject(new Error("indexer down"))
+ })
+ expect(a.result.current.error).toBe("indexer down")
+ a.unmount()
+
+ const b = renderHook(() => useContextUpdates(uri))
+ await waitFor(() => expect(callsFor(uri)).toHaveLength(2))
+ await resolveLatest(uri, [record(`${uri}#u1`, "2026-01-01T00:00:00Z")])
+ expect(b.result.current.error).toBeNull()
+ expect(b.result.current.updates).toHaveLength(1)
+ } finally {
+ consoleError.mockRestore()
+ }
+ })
+})
diff --git a/src/hooks/__tests__/use-endorsement-graph.test.tsx b/src/hooks/__tests__/use-endorsement-graph.test.tsx
new file mode 100644
index 00000000..eeb67ae0
--- /dev/null
+++ b/src/hooks/__tests__/use-endorsement-graph.test.tsx
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { renderHook, waitFor, cleanup } from "@testing-library/react"
+
+/**
+ * The graph loader (a) pages the AllEndorsements indexer op via GET with
+ * the operation variables as query params, and (b) resolves participant
+ * profiles in PARALLEL 100-DID chunks — a multi-hundred-participant
+ * network must not pay one sequential round-trip per chunk.
+ *
+ * The hook keeps a module-level cache, so each test re-imports a fresh
+ * module via vi.resetModules().
+ */
+
+interface ActorLite {
+ did: string
+ displayName: string | null
+ avatarUrl: string | null
+}
+
+/** Pending fetchNetworkActorsByDids calls, resolved manually per test. */
+let actorCalls: { dids: string[]; resolve: (actors: ActorLite[]) => void }[] = []
+
+vi.mock("@/lib/atproto/workspace", () => ({
+ fetchNetworkActorsByDids: (dids: string[]) =>
+ new Promise((resolve) => {
+ actorCalls.push({ dids, resolve })
+ }),
+}))
+
+vi.mock("@/lib/atproto/resolve-did-batch", () => ({
+ loadResolvedProfile: async () => null,
+}))
+
+// 120 endorsements between disjoint pairs -> 240 participant DIDs -> two
+// scan pages (PAGE_SIZE 100) and three profile chunks (PROFILE_CHUNK 100).
+const EDGE_COUNT = 120
+
+function edgeNode(n: number) {
+ return {
+ uri: `at://did:plc:i${n}/app.certified.badge.award/e${n}`,
+ did: `did:plc:i${n}`,
+ subject: { did: `did:plc:s${n}` },
+ }
+}
+
+let fetchCalls: { url: string; init: RequestInit | undefined }[] = []
+
+function stubIndexerFetch() {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input)
+ fetchCalls.push({ url, init })
+ const u = new URL(url, "http://localhost")
+ const badgeType = u.searchParams.get("badgeType")
+ const after = u.searchParams.get("after")
+ let edges: { node: ReturnType }[] = []
+ let pageInfo: { hasNextPage: boolean; endCursor: string | null } = {
+ hasNextPage: false,
+ endCursor: null,
+ }
+ if (badgeType === "endorsement") {
+ if (!after) {
+ edges = Array.from({ length: 100 }, (_, n) => ({ node: edgeNode(n) }))
+ pageInfo = { hasNextPage: true, endCursor: "cursor-1" }
+ } else if (after === "cursor-1") {
+ edges = Array.from({ length: EDGE_COUNT - 100 }, (_, n) => ({
+ node: edgeNode(100 + n),
+ }))
+ }
+ }
+ return {
+ ok: true,
+ json: async () => ({ data: { appCertifiedBadgeAward: { edges, pageInfo } } }),
+ }
+ }),
+ )
+}
+
+async function freshHook() {
+ vi.resetModules()
+ return (await import("../use-endorsement-graph")).useEndorsementGraph
+}
+
+beforeEach(() => {
+ actorCalls = []
+ fetchCalls = []
+ stubIndexerFetch()
+})
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe("useEndorsementGraph — indexer GET contract", () => {
+ it("pages AllEndorsements via GET with op/badgeType/first/after params", async () => {
+ const useEndorsementGraph = await freshHook()
+ renderHook(() => useEndorsementGraph())
+
+ // Both scans page to completion: endorsement page 1 + page 2, award page 1.
+ await waitFor(() => expect(fetchCalls).toHaveLength(3))
+
+ const parsed = fetchCalls.map((c) => ({
+ u: new URL(c.url, "http://localhost"),
+ init: c.init,
+ }))
+ for (const { u, init } of parsed) {
+ expect(u.pathname).toBe("/api/indexer")
+ expect(u.searchParams.get("op")).toBe("AllEndorsements")
+ expect(u.searchParams.get("first")).toBe("100")
+ // GET with no body — the POST form is gone.
+ expect(init?.method).toBeUndefined()
+ expect(init?.body).toBeUndefined()
+ }
+
+ const endorsement = parsed.filter((p) => p.u.searchParams.get("badgeType") === "endorsement")
+ const award = parsed.filter((p) => p.u.searchParams.get("badgeType") === "award")
+ expect(endorsement).toHaveLength(2)
+ expect(award).toHaveLength(1)
+ // First page carries no cursor; the second carries the returned one.
+ expect(endorsement.map((p) => p.u.searchParams.get("after")).sort()).toEqual([
+ "cursor-1",
+ null,
+ ].sort())
+ expect(award[0].u.searchParams.get("after")).toBeNull()
+ })
+})
+
+describe("useEndorsementGraph — profile chunk resolution", () => {
+ it("fetches all 100-DID chunks in parallel and merges them by DID", async () => {
+ const useEndorsementGraph = await freshHook()
+ const { result } = renderHook(() => useEndorsementGraph())
+
+ // All three chunk calls must be in flight BEFORE any resolves — the
+ // old serial loop would sit at one pending call and time out here.
+ await waitFor(() => expect(actorCalls).toHaveLength(3))
+ expect(actorCalls.map((c) => c.dids.length)).toEqual([100, 100, 40])
+
+ for (const call of actorCalls) {
+ call.resolve(
+ call.dids.map((did) => ({ did, displayName: `Name ${did}`, avatarUrl: null })),
+ )
+ }
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
+ const graph = result.current.graph
+ expect(graph?.nodes).toHaveLength(EDGE_COUNT * 2)
+ expect(graph?.links).toHaveLength(EDGE_COUNT)
+ // Chunk results landed in the profile map regardless of chunk order.
+ expect(graph?.nodes.find((n) => n.id === "did:plc:i0")?.displayName).toBe(
+ "Name did:plc:i0",
+ )
+ expect(graph?.nodes.find((n) => n.id === "did:plc:s119")?.displayName).toBe(
+ "Name did:plc:s119",
+ )
+ })
+})
diff --git a/src/hooks/__tests__/use-explore-loaders.test.ts b/src/hooks/__tests__/use-explore-loaders.test.ts
new file mode 100644
index 00000000..e82673e1
--- /dev/null
+++ b/src/hooks/__tests__/use-explore-loaders.test.ts
@@ -0,0 +1,258 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+
+// Pins the LoadArgs.pageSize contract: the server-paginated branches
+// (default accounts / projects / certs listings + funding) thread the
+// caller's page size into `first:`, while the client-side-filtered
+// follows branch keeps its fixed 100-actor window — trimming that one
+// would empty the client-side intersect (All-view skeptic carve-out).
+
+vi.mock("@/lib/atproto/indexer", () => ({
+ fetchEndorsementClosure: vi.fn(),
+ fetchFundingReceipts: vi.fn(async () => ({
+ records: [],
+ endCursor: null,
+ hasMore: false,
+ })),
+ fetchIndexerActivities: vi.fn(async () => ({
+ records: [],
+ dids: new Map(),
+ endCursor: null,
+ hasMore: false,
+ })),
+ fetchIndexerActivitiesByUris: vi.fn(),
+ fetchIndexerProjectsByUris: vi.fn(),
+ fetchProjects: vi.fn(async () => ({
+ records: [],
+ endCursor: null,
+ hasMore: false,
+ })),
+ fetchUserIndexerActivities: vi.fn(),
+ EndorsementClosureError: class EndorsementClosureError extends Error {},
+}))
+
+vi.mock("@/lib/atproto/workspace", () => ({
+ fetchNetworkActors: vi.fn(async () => ({
+ actors: [],
+ endCursor: null,
+ hasMore: false,
+ })),
+ fetchNetworkActorsByDids: vi.fn(),
+ fetchDidsByKindInSet: vi.fn(),
+}))
+
+vi.mock("@/lib/atproto/badges", () => ({
+ fetchGivenEndorsementDids: vi.fn(),
+}))
+
+vi.mock("@/lib/utils/recently-viewed", () => ({
+ getRecentlyViewed: vi.fn(() => []),
+ removeRecentlyViewed: vi.fn(),
+}))
+
+vi.mock("@/lib/atproto/records-by-uri", () => ({
+ fetchActivitiesByUris: vi.fn(),
+ fetchProjectsByUris: vi.fn(),
+}))
+
+vi.mock("@/lib/auth/fetch", () => ({
+ authFetch: vi.fn(),
+}))
+
+import { loadPage, type LoadArgs } from "@/hooks/use-explore-loaders"
+import {
+ fetchFundingReceipts,
+ fetchIndexerActivities,
+ fetchIndexerProjectsByUris,
+ fetchProjects,
+} from "@/lib/atproto/indexer"
+import { fetchNetworkActors } from "@/lib/atproto/workspace"
+import { fetchProjectsByUris } from "@/lib/atproto/records-by-uri"
+import { MA_EARTH_FILTER } from "@/lib/atproto/featured"
+import { authFetch } from "@/lib/auth/fetch"
+import type { CollectionRecord } from "@/lib/atproto/collection"
+
+function args(overrides: Partial): LoadArgs {
+ return {
+ kind: "activities",
+ filter: "all",
+ sub: "all",
+ search: "",
+ viewerDid: null,
+ followedDids: new Set(),
+ myGroupDids: new Set(),
+ myGroups: [],
+ managedAuthorDids: [],
+ cursor: null,
+ signal: null,
+ pageSize: 10,
+ degree: 1,
+ noEndorsementRings: false,
+ excludeCertLabels: null,
+ includeCertLabels: null,
+ excludeOrgLabels: null,
+ includeOrgLabels: null,
+ confirmedBy: null,
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe("loadPage pageSize threading", () => {
+ it("threads pageSize into the default activities listing", async () => {
+ await loadPage(args({ kind: "activities" }))
+ expect(fetchIndexerActivities).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(fetchIndexerActivities).mock.calls[0][0]).toMatchObject({
+ first: 10,
+ })
+ })
+
+ it("threads pageSize into the default projects listing", async () => {
+ await loadPage(args({ kind: "projects" }))
+ expect(fetchProjects).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(fetchProjects).mock.calls[0][0]).toMatchObject({
+ first: 10,
+ })
+ })
+
+ it("threads pageSize into the default accounts listing", async () => {
+ await loadPage(args({ kind: "accounts" }))
+ expect(fetchNetworkActors).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(fetchNetworkActors).mock.calls[0][0]).toMatchObject({
+ first: 10,
+ })
+ })
+
+ it("threads pageSize into the funding listing", async () => {
+ await loadPage(args({ kind: "funding" }))
+ expect(fetchFundingReceipts).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(fetchFundingReceipts).mock.calls[0][0]).toMatchObject({
+ first: 10,
+ })
+ })
+
+ it("keeps the fixed 100-actor window on the client-filtered follows branch", async () => {
+ await loadPage(
+ args({
+ kind: "accounts",
+ filter: "follows",
+ viewerDid: "did:plc:viewer",
+ followedDids: new Set(["did:plc:a"]),
+ }),
+ )
+ expect(fetchNetworkActors).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(fetchNetworkActors).mock.calls[0][0]).toMatchObject({
+ first: 100,
+ })
+ })
+})
+
+// Pins the Ma Earth featured Projects contract: the curated URIs go
+// through the indexer batch (`fetchIndexerProjectsByUris`) first, with
+// the curator's item order restored, and the per-URI PDS path
+// (`fetchProjectsByUris`) engages ONLY when the batch fails — HTTP /
+// GraphQL failure (ok: false), an all-empty result for a non-empty
+// curated set, or a rejected call. The deployed indexer may not
+// support `uri: { in }` on `orgHypercertsCollection` yet, so the PDS
+// fallback is what keeps this branch shippable either way.
+describe("loadPage Ma Earth projects batch → PDS fallback", () => {
+ const P1 = "at://did:plc:curated/org.hypercerts.collection/proj-1"
+ const P2 = "at://did:plc:curated/org.hypercerts.collection/proj-2"
+
+ function rec(uri: string): CollectionRecord {
+ return { uri, cid: `cid-${uri.slice(-1)}`, value: { type: "project" } }
+ }
+
+ beforeEach(() => {
+ // Featured source collections resolve via authFetch getRecord; all
+ // three Ma Earth project collections return the same two items
+ // (deduped to [P1, P2]). Cached module-wide after the first test —
+ // re-mocked here so each test also passes in isolation.
+ vi.mocked(authFetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ value: {
+ items: [
+ { itemIdentifier: { uri: P1, cid: "c1" } },
+ { itemIdentifier: { uri: P2, cid: "c2" } },
+ ],
+ },
+ }),
+ } as unknown as Response)
+ })
+
+ it("serves the batch result in curator order without touching the PDS path", async () => {
+ // Indexer returns indexed order (P2 first) — the loader re-sorts
+ // to the curated item order the PDS path preserves implicitly.
+ vi.mocked(fetchIndexerProjectsByUris).mockResolvedValue({
+ ok: true,
+ records: [rec(P2), rec(P1)],
+ })
+
+ const page = await loadPage(
+ args({ kind: "projects", filter: MA_EARTH_FILTER }),
+ )
+
+ expect(fetchIndexerProjectsByUris).toHaveBeenCalledWith(
+ [P1, P2],
+ undefined,
+ )
+ expect(fetchProjectsByUris).not.toHaveBeenCalled()
+ expect(page.projects.map((p) => p.uri)).toEqual([P1, P2])
+ })
+
+ it("falls back to the per-URI PDS path when the batch reports failure", async () => {
+ vi.mocked(fetchIndexerProjectsByUris).mockResolvedValue({
+ ok: false,
+ records: [],
+ })
+ vi.mocked(fetchProjectsByUris).mockResolvedValue({
+ records: [rec(P1), rec(P2)],
+ missing: [],
+ })
+
+ const page = await loadPage(
+ args({ kind: "projects", filter: MA_EARTH_FILTER }),
+ )
+
+ expect(fetchProjectsByUris).toHaveBeenCalledWith([P1, P2], undefined)
+ expect(page.projects.map((p) => p.uri)).toEqual([P1, P2])
+ })
+
+ it("falls back when the batch resolves empty for a non-empty curated set", async () => {
+ vi.mocked(fetchIndexerProjectsByUris).mockResolvedValue({
+ ok: true,
+ records: [],
+ })
+ vi.mocked(fetchProjectsByUris).mockResolvedValue({
+ records: [rec(P1)],
+ missing: [P2],
+ })
+
+ const page = await loadPage(
+ args({ kind: "projects", filter: MA_EARTH_FILTER }),
+ )
+
+ expect(fetchProjectsByUris).toHaveBeenCalledTimes(1)
+ expect(page.projects.map((p) => p.uri)).toEqual([P1])
+ })
+
+ it("falls back when the batch call rejects", async () => {
+ vi.mocked(fetchIndexerProjectsByUris).mockRejectedValue(
+ new Error("indexer unreachable"),
+ )
+ vi.mocked(fetchProjectsByUris).mockResolvedValue({
+ records: [rec(P2)],
+ missing: [P1],
+ })
+
+ const page = await loadPage(
+ args({ kind: "projects", filter: MA_EARTH_FILTER }),
+ )
+
+ expect(fetchProjectsByUris).toHaveBeenCalledWith([P1, P2], undefined)
+ expect(page.projects.map((p) => p.uri)).toEqual([P2])
+ })
+})
diff --git a/src/hooks/__tests__/use-followers-singleflight.test.tsx b/src/hooks/__tests__/use-followers-singleflight.test.tsx
new file mode 100644
index 00000000..62e1f818
--- /dev/null
+++ b/src/hooks/__tests__/use-followers-singleflight.test.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { renderHook, act, waitFor, cleanup } from "@testing-library/react"
+
+// useFollowers is mounted by the profile header, sidebar and follow
+// button in the same commit. The cached-DID-resource factory must
+// collapse those cold-cache mounts into one paginated indexer walk,
+// and the walk's dedupe-by-follower must run inside the shared fetch
+// so every waiter receives the deduped list.
+
+const postIndexerMock = vi.fn()
+vi.mock("@/lib/atproto/indexer", () => ({
+ postIndexer: (...args: unknown[]) => postIndexerMock(...args),
+}))
+
+import { useFollowers } from "../use-followers"
+
+function followPage(nodes: unknown[]): unknown {
+ return {
+ ok: true,
+ status: 200,
+ errors: [],
+ data: {
+ appCertifiedGraphFollow: {
+ totalCount: nodes.length,
+ edges: nodes.map((node) => ({ node })),
+ pageInfo: { hasNextPage: false, endCursor: null },
+ },
+ },
+ }
+}
+
+beforeEach(() => {
+ cleanup()
+ postIndexerMock.mockReset()
+})
+
+describe("useFollowers — shared walk + dedupe", () => {
+ it("two simultaneous consumers share one walk; duplicate follow records collapse", async () => {
+ let resolvePage: (v: unknown) => void = () => {}
+ postIndexerMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolvePage = resolve
+ }),
+ )
+
+ // Unique DID — the module cache persists across tests in this file.
+ const did = "did:plc:followers-target"
+ const a = renderHook(() => useFollowers(did))
+ const b = renderHook(() => useFollowers(did))
+
+ await waitFor(() => expect(postIndexerMock).toHaveBeenCalledTimes(1))
+ expect(postIndexerMock.mock.calls[0][0]).toBe("Followers")
+
+ await act(async () => {
+ resolvePage(
+ followPage([
+ // alice followed twice (re-follow left the old record) — the
+ // list must keep only her NEWEST record.
+ { uri: "at://a/app.certified.graph.follow/1", cid: "c1", did: "did:plc:alice", createdAt: "2026-01-01T00:00:00.000Z" },
+ { uri: "at://a/app.certified.graph.follow/2", cid: "c2", did: "did:plc:alice", createdAt: "2026-02-01T00:00:00.000Z" },
+ { uri: "at://b/app.certified.graph.follow/3", cid: "c3", did: "did:plc:bob", createdAt: "2026-01-15T00:00:00.000Z" },
+ ]),
+ )
+ })
+
+ await waitFor(() => {
+ expect(a.result.current.count).toBe(2)
+ expect(b.result.current.count).toBe(2)
+ })
+ expect(postIndexerMock).toHaveBeenCalledTimes(1)
+
+ // Newest-first, alice's kept entry is her most recent record.
+ expect(a.result.current.entries.map((e) => e.cid)).toEqual(["c2", "c3"])
+ expect(b.result.current.entries).toEqual(a.result.current.entries)
+ })
+
+ it("optimistic add/remove write through to the cache for re-mounts", async () => {
+ postIndexerMock.mockImplementation(async () => followPage([]))
+
+ const did = "did:plc:followers-optimistic"
+ const first = renderHook(() => useFollowers(did))
+ await waitFor(() => expect(first.result.current.count).toBe(0))
+
+ act(() => {
+ first.result.current.addFollower(
+ "did:plc:carol",
+ "at://c/app.certified.graph.follow/9",
+ "c9",
+ )
+ })
+ expect(first.result.current.count).toBe(1)
+ first.unmount()
+
+ // Re-mount inside the stale window: the optimistic entry survives
+ // without a new fetch.
+ const second = renderHook(() => useFollowers(did))
+ await waitFor(() => expect(second.result.current.count).toBe(1))
+ expect(postIndexerMock).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ second.result.current.removeFollower("did:plc:carol")
+ })
+ expect(second.result.current.count).toBe(0)
+ })
+})
diff --git a/src/hooks/__tests__/use-given-endorsements-singleflight.test.tsx b/src/hooks/__tests__/use-given-endorsements-singleflight.test.tsx
new file mode 100644
index 00000000..f2b5fbd8
--- /dev/null
+++ b/src/hooks/__tests__/use-given-endorsements-singleflight.test.tsx
@@ -0,0 +1,87 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { renderHook, act, waitFor, cleanup } from "@testing-library/react"
+import type { BadgeAwardRecord } from "@/lib/atproto/badges"
+
+// useGivenEndorsements deliberately has NO TTL cache (fresh on every
+// mount), but concurrent mounts for the same DID — the Given panel and
+// its manage modal — must share ONE in-flight two-call PDS load, and a
+// forced refetch must bypass that shared load with noCache.
+
+const listAwardsMock = vi.fn()
+vi.mock("@/lib/atproto/badges", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ listDefinitions: vi.fn(async () => []),
+ listAwards: (...args: unknown[]) => listAwardsMock(...args),
+ endorsementDefUriSet: vi.fn(async () => new Set(["at://issuer/badge/x"])),
+ }
+})
+
+import { useGivenEndorsements } from "../use-endorsements"
+
+function award(rkey: string, subjectDid: string): BadgeAwardRecord {
+ return {
+ uri: `at://me/app.certified.badge.award/${rkey}`,
+ cid: `cid-${rkey}`,
+ rkey,
+ value: {
+ badge: { uri: "at://issuer/badge/x", cid: "cid-badge" },
+ subject: { did: subjectDid },
+ createdAt: "2026-01-01T00:00:00.000Z",
+ } as BadgeAwardRecord["value"],
+ }
+}
+
+beforeEach(() => {
+ cleanup()
+ listAwardsMock.mockReset()
+})
+
+describe("useGivenEndorsements — in-flight coalescing (no TTL cache)", () => {
+ it("two simultaneous mounts share one PDS load", async () => {
+ let resolveAwards: (v: BadgeAwardRecord[]) => void = () => {}
+ listAwardsMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveAwards = resolve
+ }),
+ )
+
+ const did = "did:plc:given-singleflight"
+ const a = renderHook(() => useGivenEndorsements(did))
+ const b = renderHook(() => useGivenEndorsements(did))
+
+ await waitFor(() => expect(listAwardsMock).toHaveBeenCalledTimes(1))
+
+ await act(async () => {
+ resolveAwards([award("a1", "did:plc:subject")])
+ })
+
+ await waitFor(() => {
+ expect(a.result.current.isLoading).toBe(false)
+ expect(b.result.current.isLoading).toBe(false)
+ })
+ expect(listAwardsMock).toHaveBeenCalledTimes(1)
+ expect(a.result.current.endorsements).toHaveLength(1)
+ expect(b.result.current.endorsements).toEqual(a.result.current.endorsements)
+ })
+
+ it("refetch bypasses the shared load and passes noCache", async () => {
+ listAwardsMock.mockImplementation(async () => [])
+
+ const did = "did:plc:given-refetch"
+ const hook = renderHook(() => useGivenEndorsements(did))
+ await waitFor(() => expect(hook.result.current.isLoading).toBe(false))
+ expect(listAwardsMock).toHaveBeenCalledTimes(1)
+ expect(listAwardsMock.mock.calls[0][2]).toBeUndefined()
+
+ await act(async () => {
+ await hook.result.current.refetch()
+ })
+ expect(listAwardsMock).toHaveBeenCalledTimes(2)
+ // Post-write freshness: the forced load must beat the proxy's 5s
+ // listRecords cache.
+ expect(listAwardsMock.mock.calls[1][2]).toEqual({ noCache: true })
+ })
+})
diff --git a/src/hooks/__tests__/use-pending-awards-count-logged-out.test.tsx b/src/hooks/__tests__/use-pending-awards-count-logged-out.test.tsx
deleted file mode 100644
index 3b8506b1..00000000
--- a/src/hooks/__tests__/use-pending-awards-count-logged-out.test.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest"
-import { renderHook, cleanup } from "@testing-library/react"
-
-// Logged-out state: the hook short-circuits before touching the
-// responses fetch or the scan cache, so those collaborators only
-// need inert stubs. useAuth drives the branch under test.
-const authState = { did: null as string | null, isAuthenticated: false }
-
-vi.mock("@/lib/auth/auth-context", () => ({
- useAuth: () => authState,
-}))
-
-vi.mock("@/hooks/use-profile-responses", () => ({
- useProfileResponses: () => ({ responses: [], isLoading: false }),
-}))
-
-vi.mock("@/hooks/use-received-endorsements", () => ({
- peekCachedReceivedEndorsements: () => null,
-}))
-
-import { usePendingAwardsCount } from "../use-pending-awards-count"
-
-beforeEach(() => {
- cleanup()
- authState.did = null
- authState.isAuthenticated = false
-})
-
-describe("usePendingAwardsCount — logged-out contract", () => {
- it("returns null (not 0) when logged out, matching the JSDoc", () => {
- const { result } = renderHook(() => usePendingAwardsCount())
- expect(result.current).toBeNull()
- })
-
- it("returns null when authenticated but did is missing", () => {
- authState.isAuthenticated = true
- authState.did = null
- const { result } = renderHook(() => usePendingAwardsCount())
- expect(result.current).toBeNull()
- })
-})
diff --git a/src/hooks/__tests__/use-project-items.test.tsx b/src/hooks/__tests__/use-project-items.test.tsx
index ef834902..c35f051c 100644
--- a/src/hooks/__tests__/use-project-items.test.tsx
+++ b/src/hooks/__tests__/use-project-items.test.tsx
@@ -202,6 +202,40 @@ describe("useProjectItems — batched indexer resolution", () => {
expect(result.current.resolutions[1].error).toBe("Activity not found")
})
+ it("coerces malformed string fields on the PDS fallback path", async () => {
+ const did = "did:plc:projitemsffffffffffff1"
+ const uri = uriFor(did, "malformed")
+
+ // Indexer misses (its path normalizes server-side); the PDS fallback
+ // returns a foreign record whose string-declared fields are objects.
+ fetchIndexerActivitiesByUris.mockResolvedValue(indexerResult([]))
+ authFetch.mockResolvedValue(
+ jsonResponse({
+ uri,
+ cid: "cid-mal",
+ value: {
+ title: { evil: true },
+ shortDescription: ["not a string"],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ contributors: [{ contributorIdentity: { identity: "did:plc:x" } }],
+ },
+ }),
+ )
+
+ const { result } = renderHook(() => useProjectItems(itemsFor(uri)))
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
+
+ const record = result.current.resolutions[0].record
+ expect(result.current.resolutions[0].error).toBeNull()
+ expect(record?.value.title).toBe("")
+ expect(record?.value.shortDescription).toBe("")
+ // Non-render fields pass through untouched.
+ expect(record?.value.contributors).toEqual([
+ { contributorIdentity: { identity: "did:plc:x" } },
+ ])
+ })
+
it("ignores non-activity item URIs (only cert cards resolve)", async () => {
const did = "did:plc:projitemseeeeeeeeeeee1"
const activityUri = uriFor(did, "cert")
diff --git a/src/hooks/__tests__/use-received-endorsements-singleflight.test.tsx b/src/hooks/__tests__/use-received-endorsements-singleflight.test.tsx
new file mode 100644
index 00000000..61ef54ec
--- /dev/null
+++ b/src/hooks/__tests__/use-received-endorsements-singleflight.test.tsx
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { renderHook, act, waitFor, cleanup } from "@testing-library/react"
+
+// On a cold profile visit the header, sidebar and overview all mount
+// useReceivedEndorsements for the same DID in one commit. The module
+// cache only stores settled scans, so without in-flight dedupe each
+// instance used to launch its own full indexer page-walk (3x every
+// POST /api/indexer page). The singleflight map must collapse them
+// into ONE scan shared by every waiter.
+
+const postIndexerMock = vi.fn()
+vi.mock("@/lib/atproto/indexer", () => ({
+ postIndexer: (...args: unknown[]) => postIndexerMock(...args),
+}))
+
+import { useReceivedEndorsements } from "../use-received-endorsements"
+
+function awardPage(nodes: unknown[]): unknown {
+ return {
+ ok: true,
+ status: 200,
+ errors: [],
+ data: {
+ appCertifiedBadgeAward: {
+ edges: nodes.map((node) => ({ node })),
+ pageInfo: { hasNextPage: false, endCursor: null },
+ },
+ },
+ }
+}
+
+beforeEach(() => {
+ cleanup()
+ postIndexerMock.mockReset()
+})
+
+describe("useReceivedEndorsements — in-flight singleflight", () => {
+ it("three simultaneous mounts share one indexer scan", async () => {
+ let resolvePage: (v: unknown) => void = () => {}
+ postIndexerMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolvePage = resolve
+ }),
+ )
+
+ // Unique DID — the module cache persists across tests in this file.
+ const did = "did:plc:singleflight-recv"
+ const h1 = renderHook(() => useReceivedEndorsements(did))
+ const h2 = renderHook(() => useReceivedEndorsements(did))
+ const h3 = renderHook(() => useReceivedEndorsements(did))
+
+ // All three mounted against a cold cache, but only ONE scan runs.
+ await waitFor(() => expect(postIndexerMock).toHaveBeenCalledTimes(1))
+ expect(postIndexerMock.mock.calls[0][0]).toBe("ReceivedEndorsements")
+
+ await act(async () => {
+ resolvePage(
+ awardPage([
+ {
+ uri: "at://did:plc:iss/app.certified.badge.award/a1",
+ cid: "cid-a1",
+ did: "did:plc:iss",
+ createdAt: "2026-06-01T00:00:00.000Z",
+ badge: null,
+ },
+ ]),
+ )
+ })
+
+ await waitFor(() => {
+ expect(h1.result.current.isLoading).toBe(false)
+ expect(h2.result.current.isLoading).toBe(false)
+ expect(h3.result.current.isLoading).toBe(false)
+ })
+ expect(postIndexerMock).toHaveBeenCalledTimes(1)
+
+ const uris = (r: typeof h1) =>
+ r.result.current.endorsements.map((e) => e.uri)
+ expect(uris(h1)).toEqual(["at://did:plc:iss/app.certified.badge.award/a1"])
+ expect(uris(h2)).toEqual(uris(h1))
+ expect(uris(h3)).toEqual(uris(h1))
+ })
+
+ it("one consumer unmounting mid-scan does not fail its siblings", async () => {
+ let resolvePage: (v: unknown) => void = () => {}
+ postIndexerMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolvePage = resolve
+ }),
+ )
+
+ const did = "did:plc:singleflight-unmount"
+ const h1 = renderHook(() => useReceivedEndorsements(did))
+ const h2 = renderHook(() => useReceivedEndorsements(did))
+ await waitFor(() => expect(postIndexerMock).toHaveBeenCalledTimes(1))
+
+ // The shared scan is not bound to the unmounting caller's signal.
+ h1.unmount()
+ await act(async () => {
+ resolvePage(awardPage([]))
+ })
+
+ await waitFor(() => expect(h2.result.current.isLoading).toBe(false))
+ expect(h2.result.current.error).toBeNull()
+ expect(h2.result.current.endorsements).toEqual([])
+ expect(postIndexerMock).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/hooks/__tests__/use-user-activities.test.tsx b/src/hooks/__tests__/use-user-activities.test.tsx
deleted file mode 100644
index 1adcafc1..00000000
--- a/src/hooks/__tests__/use-user-activities.test.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest"
-import { renderHook, act, waitFor, cleanup } from "@testing-library/react"
-import type {
- ActivityRecord,
- ListActivitiesResponse,
-} from "@/lib/atproto/activity-types"
-
-// Controllable mock of fetchActivities. Each call records its args and
-// returns a deferred promise we resolve by hand so a test can interleave
-// a profile switch between a fetch starting and resolving.
-interface PendingCall {
- did: string
- cursor: string | undefined
- resolve: (value: ListActivitiesResponse) => void
-}
-
-const calls: PendingCall[] = []
-
-vi.mock("@/lib/atproto/activity", () => ({
- fetchActivities: vi.fn(
- (did: string, cursor?: string) =>
- new Promise((resolve) => {
- calls.push({ did, cursor, resolve })
- }),
- ),
-}))
-
-import { useUserActivities } from "../use-user-activities"
-
-function rec(uri: string): ActivityRecord {
- return {
- uri,
- cid: `cid-${uri}`,
- value: {
- title: uri,
- shortDescription: "",
- createdAt: "2026-01-01T00:00:00.000Z",
- },
- }
-}
-
-/** Resolve the oldest still-pending fetch matching the predicate. */
-function resolveCall(
- match: (c: PendingCall) => boolean,
- value: ListActivitiesResponse,
-) {
- const idx = calls.findIndex(match)
- if (idx === -1) throw new Error("no matching pending fetchActivities call")
- const [call] = calls.splice(idx, 1)
- call.resolve(value)
-}
-
-beforeEach(() => {
- cleanup()
- calls.length = 0
-})
-
-describe("useUserActivities — loadMore generation guard", () => {
- it("ignores an in-flight loadMore for the previous DID after a profile switch", async () => {
- const { result, rerender } = renderHook(({ did }) => useUserActivities(did), {
- initialProps: { did: "did:a" },
- })
-
- // Page 1 for DID A.
- await act(async () => {
- resolveCall((c) => c.did === "did:a" && c.cursor === undefined, {
- records: [rec("at://a/1"), rec("at://a/2")],
- cursor: "cursor-a-1",
- })
- })
- await waitFor(() => expect(result.current.activities).toHaveLength(2))
-
- // Kick off loadMore for A — fetch starts but does NOT resolve yet.
- act(() => {
- result.current.loadMore()
- })
- await waitFor(() =>
- expect(calls.some((c) => c.did === "did:a" && c.cursor === "cursor-a-1")).toBe(true),
- )
-
- // Switch to DID B and let its page-1 land, resetting the list to B's rows.
- rerender({ did: "did:b" })
- await act(async () => {
- resolveCall((c) => c.did === "did:b" && c.cursor === undefined, {
- records: [rec("at://b/1")],
- cursor: "cursor-b-1",
- })
- })
- await waitFor(() =>
- expect(result.current.activities.map((r) => r.uri)).toEqual(["at://b/1"]),
- )
-
- // Now the stale loadMore for A resolves late. Its records must NOT be
- // appended to B's reset list.
- await act(async () => {
- resolveCall((c) => c.did === "did:a" && c.cursor === "cursor-a-1", {
- records: [rec("at://a/3"), rec("at://a/4")],
- cursor: "cursor-a-2",
- })
- })
-
- const uris = result.current.activities.map((r) => r.uri)
- expect(uris).toEqual(["at://b/1"])
- expect(uris).not.toContain("at://a/3")
- expect(uris).not.toContain("at://a/4")
- })
-
- it("dedups appended records by uri on loadMore", async () => {
- const { result } = renderHook(() => useUserActivities("did:a"))
-
- await act(async () => {
- resolveCall((c) => c.did === "did:a" && c.cursor === undefined, {
- records: [rec("at://a/1"), rec("at://a/2")],
- cursor: "cursor-a-1",
- })
- })
- await waitFor(() => expect(result.current.activities).toHaveLength(2))
-
- act(() => {
- result.current.loadMore()
- })
- await waitFor(() =>
- expect(calls.some((c) => c.did === "did:a" && c.cursor === "cursor-a-1")).toBe(true),
- )
-
- // Page 2 overlaps page 1 (at://a/2 repeats across the cursor boundary).
- await act(async () => {
- resolveCall((c) => c.did === "did:a" && c.cursor === "cursor-a-1", {
- records: [rec("at://a/2"), rec("at://a/3")],
- cursor: null as unknown as undefined,
- })
- })
-
- const uris = result.current.activities.map((r) => r.uri)
- expect(uris).toEqual(["at://a/1", "at://a/2", "at://a/3"])
- })
-})
diff --git a/src/hooks/create-cached-did-resource.ts b/src/hooks/create-cached-did-resource.ts
new file mode 100644
index 00000000..8146483c
--- /dev/null
+++ b/src/hooks/create-cached-did-resource.ts
@@ -0,0 +1,197 @@
+"use client"
+
+import { useCallback, useEffect, useRef, useState } from "react"
+
+/**
+ * Factory for the module-cache + stale-window + singleflight skeleton
+ * shared by the per-DID fetch hooks. `useFollowers` and `useFollowing`
+ * used to re-implement the same ~100-line shape (module cache Map,
+ * doFetch with null-reset / stale-window read / abort-guarded
+ * setState, mount effect, cache-busting refetch, optimistic
+ * write-through mutators); this centralises the wiring while each
+ * hook keeps its own data mapping and policy knobs.
+ *
+ * Deliberately NOT adopted by the other cache-shaped hooks:
+ * - `useReceivedEndorsements` — layers a cross-instance optimistic
+ * overlay + window-focus revalidation on top of the skeleton.
+ * - `useEndorsementLists` — cache entries are versioned against the
+ * endorsement-lists invalidation bus.
+ * - `useProfileResponses` — external-store variant (module state +
+ * useSyncExternalStore) with its own singleflight.
+ * - `useGivenEndorsements` — fresh-on-every-mount by design; only
+ * in-flight coalescing, no TTL cache.
+ * Forcing those through the factory would cost more in indirection
+ * than the duplication it removes.
+ */
+
+export interface CachedDidResourceConfig {
+ /** How long a cached snapshot serves new mounts without refetching. */
+ staleMs: number
+ /**
+ * Shared fetcher for a DID. Runs OUTSIDE any single caller's
+ * AbortSignal: the promise is shared across every hook instance
+ * mounted for the same DID, so one consumer unmounting must not
+ * fail its siblings (same contract as useTypedLists' shared fetch).
+ * Any post-fetch shaping (dedupe, sort) belongs in here so every
+ * waiter receives the shaped value. `force` is true for
+ * user-invoked refetches — fetchers that sit behind an HTTP cache
+ * use it to pass `noCache`.
+ */
+ fetch: (did: string, opts: { force: boolean }) => Promise
+ /**
+ * What happens to the last-known data when a fetch fails:
+ * - "reset": drop it — consumers' derived "loaded" state returns
+ * to the initial null (e.g. a follower count must
+ * show "loading" again, not a stale number).
+ * - "retain": keep rendering the previous snapshot next to the
+ * error message.
+ * Failures are never written to the module cache either way, so a
+ * transient hiccup can't lock the UI in until the stale window
+ * expires — the next mount / refetch retries.
+ */
+ onError: "reset" | "retain"
+ /** Message used when a failure isn't an Error instance. */
+ errorFallback: string
+}
+
+export interface CachedDidResourceState {
+ /** Last fetched (or optimistically mutated) value; null before the
+ * first successful load for the current DID. */
+ data: T | null
+ isLoading: boolean
+ error: string | null
+ /** Bypass the module cache AND any in-flight fetch; call after a
+ * write so the caller sees post-write state. */
+ refetch: () => Promise
+ /**
+ * Optimistic write-through: update this instance's state and the
+ * module cache in one step. Returning the previous value from the
+ * updater is a no-op (no cache write). Sibling instances pick the
+ * new value up from the cache on their next fetch — same contract
+ * the follower/following mutators had before the factory.
+ */
+ mutate: (updater: (prev: T | null) => T | null) => void
+}
+
+interface CacheEntry {
+ data: T
+ fetchedAt: number
+}
+
+/**
+ * Build a hook that reads a per-DID resource through a module-level
+ * stale-while-cached Map with in-flight deduplication. Each factory
+ * call owns its own cache + inflight maps, so distinct resources
+ * never collide on DID keys.
+ */
+export function createCachedDidResource(
+ config: CachedDidResourceConfig,
+): (did: string | null) => CachedDidResourceState {
+ const { staleMs, fetch: fetchResource, onError, errorFallback } = config
+
+ const cache = new Map>()
+ // Singleflight: N instances mounting together (header, sidebar, tab
+ // panel) share ONE fetch instead of racing N identical walks against
+ // a cold cache.
+ const inflight = new Map>()
+
+ function startFetch(did: string, force: boolean): Promise {
+ const promise = fetchResource(did, { force }).then((value) => {
+ // Skip the cache write if a forced refetch superseded this fetch
+ // while it was in flight — its result is pre-write data.
+ if (inflight.get(did) === promise) {
+ cache.set(did, { data: value, fetchedAt: Date.now() })
+ }
+ return value
+ })
+ inflight.set(did, promise)
+ promise
+ .catch(() => {
+ // Swallowed here only — each awaiting instance surfaces its
+ // own error from its `await`.
+ })
+ .finally(() => {
+ // Only clear if still ours (a forced refetch may have replaced
+ // the slot during the await window) — useTypedLists' guard.
+ if (inflight.get(did) === promise) inflight.delete(did)
+ })
+ return promise
+ }
+
+ return function useCachedDidResource(
+ did: string | null,
+ ): CachedDidResourceState {
+ const [data, setData] = useState(null)
+ const [isLoading, setIsLoading] = useState(!!did)
+ const [error, setError] = useState(null)
+ const didRef = useRef(did)
+ didRef.current = did
+
+ const doFetch = useCallback(
+ async (targetDid: string | null, signal?: AbortSignal, force = false) => {
+ if (!targetDid) {
+ setData(null)
+ setIsLoading(false)
+ setError(null)
+ return
+ }
+ if (!force) {
+ const cached = cache.get(targetDid)
+ if (cached && Date.now() - cached.fetchedAt < staleMs) {
+ setData(cached.data)
+ setIsLoading(false)
+ return
+ }
+ }
+ setIsLoading(true)
+ setError(null)
+ try {
+ // Force bypasses the in-flight map too: the caller just
+ // wrote, and a pending pre-write fetch would hand back stale
+ // data. The replacement promise takes over the map slot so
+ // later joiners share the fresh fetch.
+ const promise =
+ (force ? undefined : inflight.get(targetDid)) ??
+ startFetch(targetDid, force)
+ const value = await promise
+ if (signal?.aborted) return
+ setData(value)
+ } catch (err) {
+ if (signal?.aborted) return
+ if (onError === "reset") setData(null)
+ setError(err instanceof Error ? err.message : errorFallback)
+ } finally {
+ if (!signal?.aborted) setIsLoading(false)
+ }
+ },
+ [],
+ )
+
+ useEffect(() => {
+ const controller = new AbortController()
+ doFetch(did, controller.signal)
+ return () => controller.abort()
+ }, [did, doFetch])
+
+ const refetch = useCallback(async () => {
+ const targetDid = didRef.current
+ if (!targetDid) return
+ cache.delete(targetDid)
+ await doFetch(targetDid, undefined, true)
+ }, [doFetch])
+
+ const mutate = useCallback((updater: (prev: T | null) => T | null) => {
+ const targetDid = didRef.current
+ if (!targetDid) return
+ setData((prev) => {
+ const next = updater(prev)
+ if (next !== prev && next !== null) {
+ cache.set(targetDid, { data: next, fetchedAt: Date.now() })
+ }
+ return next
+ })
+ }, [])
+
+ return { data, isLoading, error, refetch, mutate }
+ }
+}
diff --git a/src/hooks/use-activity-funding.ts b/src/hooks/use-activity-funding.ts
index 9fa6a1af..318ca0e0 100644
--- a/src/hooks/use-activity-funding.ts
+++ b/src/hooks/use-activity-funding.ts
@@ -24,7 +24,7 @@ export function useActivityFunding(
const { first = 100 } = options
const [receipts, setReceipts] = useState([])
const [totalCount, setTotalCount] = useState(null)
- const [isLoading, setIsLoading] = useState(false)
+ const [isLoading, setIsLoading] = useState(!!did && !!rkey)
const [error, setError] = useState(null)
// Bumped by `refetch()` to re-run the fetch effect — e.g. the "refresh"
// affordance shown after recording, since the indexer is eventually
@@ -32,22 +32,32 @@ export function useActivityFunding(
const [refreshNonce, setRefreshNonce] = useState(0)
const refetch = useCallback(() => setRefreshNonce((n) => n + 1), [])
- useEffect(() => {
- if (!did || !rkey) {
+ // Adjust state during render when the fetch identity changes (the nonce
+ // is part of the key so refetch() still flips isLoading). Stale receipts
+ // are kept while a same-target refetch is in flight, matching the old
+ // effect's behavior.
+ const fetchKey = `${did}|${rkey}|${first}|${refreshNonce}`
+ const [prevFetchKey, setPrevFetchKey] = useState(fetchKey)
+ if (prevFetchKey !== fetchKey) {
+ setPrevFetchKey(fetchKey)
+ if (did && rkey) {
+ setIsLoading(true)
+ setError(null)
+ } else {
setReceipts([])
setTotalCount(null)
setIsLoading(false)
setError(null)
- return
}
+ }
+
+ useEffect(() => {
+ if (!did || !rkey) return
const forUri = `at://${did}/org.hypercerts.claim.activity/${rkey}`
const controller = new AbortController()
const { signal } = controller
- setIsLoading(true)
- setError(null)
-
fetchFundingReceiptsForActivity(forUri, { first, signal })
.then((result) => {
if (signal.aborted) return
diff --git a/src/hooks/use-activity.ts b/src/hooks/use-activity.ts
index 0116aff4..17d8e949 100644
--- a/src/hooks/use-activity.ts
+++ b/src/hooks/use-activity.ts
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
import { authFetch } from "@/lib/auth/fetch"
import { createBoundedCache } from "@/lib/utils/bounded-cache"
import { fetchIndexerActivitiesByUris } from "@/lib/atproto/indexer"
+import { coerceClaimActivityValue } from "@/lib/atproto/coerce-claim-activity"
import type { ClaimActivity } from "@/lib/atproto/activity-types"
const COLLECTION = "org.hypercerts.claim.activity"
@@ -119,14 +120,16 @@ function loadActivity(did: string, rkey: string): Promise {
const data = (await res.json()) as {
uri: string
cid: string
- value: ClaimActivity
+ value: unknown
}
+ // A foreign PDS record can carry any shape — coerce the
+ // string-declared render fields before anything renders them.
const activity: SingleActivity = {
uri: data.uri,
cid: data.cid,
did,
rkey,
- value: data.value,
+ value: coerceClaimActivityValue(data.value),
}
cache.set(key, activity)
return activity
diff --git a/src/hooks/use-author-info.ts b/src/hooks/use-author-info.ts
index 798c5099..7bd3078e 100644
--- a/src/hooks/use-author-info.ts
+++ b/src/hooks/use-author-info.ts
@@ -40,17 +40,21 @@ export function useAuthorInfo(did: string | null): {
const [isLoading, setIsLoading] = useState(!!did)
const [error, setError] = useState(null)
+ // Reset to the initializer state during render when the DID changes
+ // (React's adjust-state-during-render pattern), so the effect only
+ // contains the fetch lifecycle.
+ const [prevDid, setPrevDid] = useState(did)
+ if (prevDid !== did) {
+ setPrevDid(did)
+ setInfo(null)
+ setIsLoading(!!did)
+ setError(null)
+ }
+
useEffect(() => {
- if (!did) {
- setInfo(null)
- setIsLoading(false)
- setError(null)
- return
- }
+ if (!did) return
let cancelled = false
- setIsLoading(true)
- setError(null)
fetchAuthor(did)
.then((data) => {
diff --git a/src/hooks/use-bottom-sheet-drag.ts b/src/hooks/use-bottom-sheet-drag.ts
index 77bb0015..18c1536e 100644
--- a/src/hooks/use-bottom-sheet-drag.ts
+++ b/src/hooks/use-bottom-sheet-drag.ts
@@ -42,10 +42,11 @@ export function useBottomSheetDrag({
}
}, [])
- // Reset sheet expanded state when closed
- useEffect(() => {
- if (!isOpen) setSheetExpanded(false)
- }, [isOpen])
+ // Reset sheet expanded state when closed — adjusted during render so
+ // the collapse doesn't wait for an effect pass.
+ if (!isOpen && sheetExpanded) {
+ setSheetExpanded(false)
+ }
// Auto-expand sheet when input is focused on mobile (keyboard opens)
useEffect(() => {
diff --git a/src/hooks/use-cert-projects.ts b/src/hooks/use-cert-projects.ts
index 48c44f36..03f43663 100644
--- a/src/hooks/use-cert-projects.ts
+++ b/src/hooks/use-cert-projects.ts
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import type { CollectionRecord, CollectionValue } from "@/lib/atproto/collection"
-import { INDEXER_PROXY_URL } from "@/lib/atproto/indexer"
+import { postIndexer } from "@/lib/atproto/indexer"
/**
* Given a cert (did + rkey), find every `org.hypercerts.collection`
@@ -16,61 +16,55 @@ import { INDEXER_PROXY_URL } from "@/lib/atproto/indexer"
* below replaces that stopgap and picks up cross-DID curation for
* free.
*/
-interface ProjectsContainingCertResponse {
- data?: {
- orgHypercertsCollection?: {
- edges: {
- node:
- | (Pick & {
- did: string
- createdAt: string | null
- title: string | null
- shortDescription: string | null
- items: { itemIdentifier?: { uri?: string; cid?: string } }[] | null
- banner: unknown | null
- })
- | null
- }[]
- } | null
+interface ProjectsContainingCertData {
+ orgHypercertsCollection?: {
+ edges: {
+ node:
+ | (Pick & {
+ did: string
+ createdAt: string | null
+ title: string | null
+ shortDescription: string | null
+ items: { itemIdentifier?: { uri?: string; cid?: string } }[] | null
+ banner: unknown | null
+ })
+ | null
+ }[]
} | null
- errors?: { message: string }[]
}
export function useCertProjects(did: string | null, rkey: string | null) {
const [projects, setProjects] = useState([])
- const [isLoading, setIsLoading] = useState(false)
+ const [isLoading, setIsLoading] = useState(!!did && !!rkey)
const [error, setError] = useState(null)
+ // Adjust state during render when the cert identity changes, so the
+ // effect holds only the fetch lifecycle.
+ const certKey = `${did}|${rkey}`
+ const [prevCertKey, setPrevCertKey] = useState(certKey)
+ if (prevCertKey !== certKey) {
+ setPrevCertKey(certKey)
+ setProjects([])
+ setIsLoading(!!did && !!rkey)
+ setError(null)
+ }
+
useEffect(() => {
- if (!did || !rkey) {
- setProjects([])
- setIsLoading(false)
- setError(null)
- return
- }
+ if (!did || !rkey) return
const certUri = `at://${did}/org.hypercerts.claim.activity/${rkey}`
const controller = new AbortController()
const signal = controller.signal
- setIsLoading(true)
- setError(null)
-
- fetch(INDEXER_PROXY_URL, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- operationName: "ProjectsContainingCert",
- variables: { certUri, first: 50 },
- }),
- signal,
- })
- .then(async (res) => {
+ postIndexer(
+ "ProjectsContainingCert",
+ { certUri, first: 50 },
+ { signal },
+ )
+ .then((res) => {
if (signal.aborted) return
if (!res.ok) throw new Error(`Indexer returned ${res.status}`)
- const json = (await res.json()) as ProjectsContainingCertResponse
- if (signal.aborted) return
- const edges = json.data?.orgHypercertsCollection?.edges ?? []
+ const edges = res.data?.orgHypercertsCollection?.edges ?? []
const out: CollectionRecord[] = []
for (const edge of edges) {
if (!edge.node) continue
diff --git a/src/hooks/use-cgs-memberships.ts b/src/hooks/use-cgs-memberships.ts
index cd2ceb40..c0a2032a 100644
--- a/src/hooks/use-cgs-memberships.ts
+++ b/src/hooks/use-cgs-memberships.ts
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useState } from "react"
-import { authFetch } from "@/lib/auth/fetch"
+import { loadResolvedProfile } from "@/lib/atproto/resolve-did-batch"
import { fetchRemoteMemberships } from "@/lib/groups/api"
export interface UserGroup {
@@ -14,14 +14,6 @@ export interface UserGroup {
joinedAt?: string
}
-interface ResolvedDid {
- did: string
- handle: string
- displayName?: string
- description?: string
- avatar?: string
-}
-
/**
* Returns the groups the signed-in viewer belongs to, sourced
* directly from the Certified Group Service (CGS) — the single
@@ -63,32 +55,19 @@ export function useCgsMemberships(did: string | null): {
const remote = await fetchRemoteMemberships(signal)
if (signal.aborted) return
+ // Resolve group profiles through the batched coalescer — one
+ // coalesced POST /api/resolve-dids for all K groups, plus
+ // session caching across refreshes. The previous per-row
+ // GET /api/resolve-did pattern is exactly what blew that
+ // route's 60/min rate limit (see resolve-did-batch.ts).
+ // loadResolvedProfile never rejects (null on failure) and
+ // takes no signal; a fetch completing after unmount only
+ // warms the shared cache — the aborted guard below still
+ // gates setState.
const hydrated = await Promise.all(
remote.map(async (m): Promise => {
- try {
- const res = await authFetch(
- `/api/resolve-did?did=${encodeURIComponent(m.groupDid)}`,
- { signal },
- )
- if (!res.ok) {
- return {
- groupDid: m.groupDid,
- handle: m.groupDid,
- role: m.role,
- joinedAt: m.joinedAt,
- }
- }
- const data = (await res.json()) as ResolvedDid
- return {
- groupDid: m.groupDid,
- handle: data.handle || m.groupDid,
- displayName: data.displayName,
- description: data.description,
- avatarUrl: data.avatar,
- role: m.role,
- joinedAt: m.joinedAt,
- }
- } catch {
+ const profile = await loadResolvedProfile(m.groupDid)
+ if (!profile) {
return {
groupDid: m.groupDid,
handle: m.groupDid,
@@ -96,6 +75,15 @@ export function useCgsMemberships(did: string | null): {
joinedAt: m.joinedAt,
}
}
+ return {
+ groupDid: m.groupDid,
+ handle: profile.handle || m.groupDid,
+ displayName: profile.displayName,
+ description: profile.description,
+ avatarUrl: profile.avatar ?? undefined,
+ role: m.role,
+ joinedAt: m.joinedAt,
+ }
}),
)
if (signal.aborted) return
diff --git a/src/hooks/use-context-updates.ts b/src/hooks/use-context-updates.ts
index df1fc9e6..4e67e5e1 100644
--- a/src/hooks/use-context-updates.ts
+++ b/src/hooks/use-context-updates.ts
@@ -1,11 +1,191 @@
"use client"
-import { useCallback, useEffect, useRef, useState } from "react"
+import { useCallback, useEffect, useSyncExternalStore } from "react"
import {
fetchContextUpdates,
type ContextAttachmentRecord,
} from "@/lib/atproto/context-attachment"
import { parseAtUri } from "@/lib/atproto/activity-uri"
+import { createBoundedCache } from "@/lib/utils/bounded-cache"
+
+interface UpdatesEntry {
+ updates: ContextAttachmentRecord[]
+ isLoading: boolean
+ error: string | null
+ /** Epoch ms of the last successful resolve; 0 = never resolved (or
+ * explicitly invalidated), which forces the next reader to fetch. */
+ fetchedAt: number
+}
+
+const EMPTY_ENTRY: UpdatesEntry = Object.freeze({
+ updates: [],
+ isLoading: false,
+ error: null,
+ fetchedAt: 0,
+})
+
+/** How long a resolved list stays fresh. The detail pages mount two
+ * reader instances per subject (navbar count + the
+ * child) and remount the child on every tab switch — those hit the
+ * cache. Writes bypass the window via `invalidateContextUpdates`. */
+const REVALIDATE_MS = 30_000
+
+// Module-level cache + in-flight coalescer keyed by subjectUri,
+// mirroring use-activity.ts. Without sharing, every detail-page mount
+// issued two identical fetches (navbar-count instance + child list)
+// and each tab switch refetched; worse, a delete patched only one
+// instance's state so the other's count went stale. Entries are
+// replaced immutably and subscribers are notified so every mounted
+// instance converges on the same list.
+const cache = createBoundedCache(200)
+const inFlight = new Map