From 042de27276943107a43d0792cd39b801b7c44fdb Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 29 Jul 2026 14:50:40 +0600 Subject: [PATCH 1/5] feed: integrate Certified Feed Service transport Add the typed public XRPC client, service-backed home-feed hook and rendering path, guarded indexer rollback mode, configuration docs, and regression coverage for pagination and source isolation. Co-Authored-By: OpenAI Codex --- .env.local.example | 10 + AGENTS.md | 47 +- README.md | 2 + .../implementation-review-round-1.md | 43 + .../implementation-review-round-2.md | 37 + .../implementation-review-round-3.md | 27 + .../implementation-review-round-4.md | 36 + .../interactive-plan.html | 1779 +++++++++++++++++ docs/feed-service-integration/plan.md | 303 +++ .../review-round-1.md | 51 + .../review-round-2.md | 67 + src/components/dev/mock-fetch-provider.tsx | 8 + .../cert-preview-location-icon.test.tsx | 29 +- .../__tests__/endorsement-group-row.test.tsx | 29 +- .../home-feed-empty-pagination.test.tsx | 57 + ...ome-feed-indexer-source-isolation.test.tsx | 85 + .../home-feed-service-render.test.tsx | 421 ++++ .../home-feed-source-isolation.test.tsx | 75 + src/components/home/home-feed-rows.tsx | 481 +++-- src/components/home/home-feed.tsx | 459 ++--- .../use-home-feed-invalid-cursor.test.tsx | 4 +- .../use-home-feed-loadmore-abort.test.tsx | 25 +- .../__tests__/use-home-feed-service.test.tsx | 318 +++ src/hooks/use-home-feed.ts | 1033 ++++++---- .../atproto/__tests__/certified-feed.test.ts | 429 ++++ src/lib/atproto/certified-feed.ts | 635 ++++++ src/lib/dev/fixtures/feed.ts | 58 +- src/lib/utils/__tests__/group-feed.test.ts | 265 +-- src/lib/utils/group-feed.ts | 40 +- 29 files changed, 5671 insertions(+), 1182 deletions(-) create mode 100644 docs/feed-service-integration/implementation-review-round-1.md create mode 100644 docs/feed-service-integration/implementation-review-round-2.md create mode 100644 docs/feed-service-integration/implementation-review-round-3.md create mode 100644 docs/feed-service-integration/implementation-review-round-4.md create mode 100644 docs/feed-service-integration/interactive-plan.html create mode 100644 docs/feed-service-integration/plan.md create mode 100644 docs/feed-service-integration/review-round-1.md create mode 100644 docs/feed-service-integration/review-round-2.md create mode 100644 src/components/home/__tests__/home-feed-empty-pagination.test.tsx create mode 100644 src/components/home/__tests__/home-feed-indexer-source-isolation.test.tsx create mode 100644 src/components/home/__tests__/home-feed-service-render.test.tsx create mode 100644 src/components/home/__tests__/home-feed-source-isolation.test.tsx create mode 100644 src/hooks/__tests__/use-home-feed-service.test.tsx create mode 100644 src/lib/atproto/__tests__/certified-feed.test.ts create mode 100644 src/lib/atproto/certified-feed.ts diff --git a/.env.local.example b/.env.local.example index 6372fd9b..a9051fbc 100644 --- a/.env.local.example +++ b/.env.local.example @@ -48,6 +48,16 @@ INDEXER_URL=https://magic-indexer-prod.up.railway.app/graphql # INDEXER_URL above and leave this unset on new deploys. # NEXT_PUBLIC_INDEXER_URL= +# Home-feed transport cutover. Defaults to `indexer` while the hydrated +# Certified Feed Service completes staging/production observation. Set to +# `service` to send one direct, credentialless XRPC request per feed page. +# NEXT_PUBLIC_HOME_FEED_SOURCE=indexer +# +# Required when NEXT_PUBLIC_HOME_FEED_SOURCE=service. Configure the exact HTTPS +# origin only (no path/query/fragment); the app appends the getFeed XRPC path. +# Local development also permits an http://localhost/127.0.0.1/[::1] origin. +# NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL=https://feed.example.com + # Optional indexer fast-path for /api/resolve-did (default false). When # "true", resolve-did reads identity (handle + the Bluesky profile block) # from the indexer's actorProfile(did) query instead of fanning out to diff --git a/AGENTS.md b/AGENTS.md index b54e2f32..bafa8e64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,8 @@ Source: `.env.local.example` and `src/lib/utils/config.ts`. | Variable | Required | Purpose | |---|---|---| | `NEXT_PUBLIC_PDS_URL` | yes | PDS / handle resolver URL. Defaults to `https://certified.one`. | +| `NEXT_PUBLIC_HOME_FEED_SOURCE` | optional | Home-feed transport: `indexer` (default and rollback path) or `service` (direct hydrated XRPC). Invalid values fail with an actionable configuration error. | +| `NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL` | service mode | Exact Certified Feed Service origin. Production/staging require HTTPS; non-production permits HTTP only for localhost/loopback. Do not include a path, query, fragment, or credentials. | | `PUBLIC_URL` | recommended in production | Canonical app origin. Wins when deriving OAuth `client_id` and `redirect_uris`; exact same-origin CSRF requests from it are trusted. Falls back to `VERCEL_BRANCH_URL`, then `VERCEL_URL`, then `http://localhost:3000` outside production. **For local atproto OAuth sign-in to actually complete, set this to `http://127.0.0.1:3000`** — see [§22 Common Pitfalls](#22-common-pitfalls) #3. | | `VERCEL_BRANCH_URL` | Vercel-provided fallback | Hostname-only stable branch URL. Becomes the canonical OAuth origin when `PUBLIC_URL` is absent and is accepted for same-origin CSRF requests. Do not add a scheme or `NEXT_PUBLIC_` alias. | | `VERCEL_URL` | Vercel-provided fallback | Hostname-only commit deployment URL. Final canonical OAuth fallback and accepted for same-origin CSRF requests. Do not add a scheme or `NEXT_PUBLIC_` alias. | @@ -176,6 +178,7 @@ Key principles: 2. **All XRPC calls go through `/api/xrpc/[...method]`.** Never call the PDS from the browser directly with credentials — there are none. Use `authFetch()` from `src/lib/auth/fetch.ts`. It detects 401 and triggers the global `onUnauthorized` handler registered by `AuthProvider`, which clears auth state and asks the user to sign in again. 3. **Group operations** use a parallel set of routes under `/api/groups/**` because they require the AtpAgent's `withProxy("certified_group", groupDid)` pattern + custom NSID lexicons (`app.certified.group.*`). They do not share the `/api/xrpc/[...method]` handler. 4. **DID resolution is direct.** `resolvePdsUrl` and `resolveHandle` (in `src/lib/atproto/did.ts`) hit `plc.directory` or the `did:web` host with a 5s timeout; results are not cached server-side. +5. **The home-feed service path is the sole credentialless direct XRPC exception.** When `NEXT_PUBLIC_HOME_FEED_SOURCE=service`, `src/lib/atproto/certified-feed.ts` calls the public read-only Certified Feed Service with `credentials: "omit"` and `cache: "no-store"`. It never sends the app session or uses `authFetch`. The build-time `indexer` source retains the old browser/indexer path for rollback until the observation-window cleanup is separately approved. ## 6. Provider Tree & Layout System @@ -1052,15 +1055,18 @@ stop when the next pass would be nit-picking. ### Branching (project-specific override) -Work happens **directly on `staging`**, not on per-feature -branches. When `staging` is in good shape, open a Draft PR -from `staging` into `main`. The operator merges; agents never -merge. +Work happens on **task-specific feature branches**, not +directly on `staging`. Continue an existing task on its current +feature branch; create one before editing when the current +branch is `main`, `staging`, or another default/integration +branch. Keep planning, implementation, review, and commits on +the feature branch. -This overrides the global "feature-branch into staging" -default in `~/.claude/CLAUDE.md`. This repo's review cadence -is dense enough that `staging` is the natural integration -point. +After the feature branch passes review and verification, merge +it into `staging` with operator approval. When `staging` is in +good shape, open a Draft PR from `staging` into `main`. The +operator merges the `staging` → `main` PR; agents never merge +that PR. ### Order of operations @@ -1095,9 +1101,10 @@ point. item. Update the plan in place. Run further rounds only if the previous round surfaced substantive items. -4. **Implement.** Commit directly to `staging`. Atomic commits - with a clear scope tag. Match the existing commit-message - convention (`Co-Authored-By:` trailer per Safety Rule 6). +4. **Implement.** Commit on the task's feature branch. Use + atomic commits with a clear scope tag. Match the existing + commit-message convention (`Co-Authored-By:` trailer per + Safety Rule 6). 5. **Local verification.** Run all four quality gates plus anything that exercises the new surface: @@ -1116,20 +1123,26 @@ point. follow-up round only if round 1 surfaced enough substantive items to justify one. -7. **Draft PR `staging → main`.** Body must link to the plan +7. **Integrate into `staging`.** After operator approval, merge + the verified feature branch into `staging`. Push only with + explicit approval. + +8. **Draft PR `staging → main`.** Body must link to the plan and review-decision docs, list breaking changes, state out-of-scope items, and include a test plan checklist. -8. **Make CI green.** Fix root causes. Never `--no-verify`. +9. **Make CI green.** Fix root causes. Never `--no-verify`. Never skip hooks. Loop until all checks pass. -9. **Stop.** The operator merges. Notify with the PR URL and - a short summary of what shipped. +10. **Stop.** The operator merges the `staging` → `main` PR. + Notify with the PR URL and a short summary of what shipped. ### Hard rules -- **Never merge.** Stopping at "PR Draft, CI green" is the - contract. +- **Never merge `staging` into `main` or merge the corresponding + PR.** Feature-branch integration into `staging` requires + operator approval. Stopping at "PR Draft, CI green" is the + release contract. - **Never `--force` push to `main`.** Avoid history rewrites on `staging` once you've pushed; it's the shared working branch. diff --git a/README.md b/README.md index 9bc2cb68..0bab1f0f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Edit `.env.local` with your values: | Variable | Required | Description | |----------|----------|-------------| | `NEXT_PUBLIC_PDS_URL` | Yes | PDS / handle resolver URL (default: `https://certified.one`) | +| `NEXT_PUBLIC_HOME_FEED_SOURCE` | No | Home feed transport: `indexer` (default/rollback) or `service` | +| `NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL` | Service mode | Exact HTTPS origin of the Certified Feed Service; local loopback HTTP is allowed in development | | `PUBLIC_URL` | Recommended in production | Canonical app origin used for OAuth metadata and callbacks | | `VERCEL_BRANCH_URL` | Vercel-provided fallback | Stable branch hostname used when `PUBLIC_URL` is absent | | `VERCEL_URL` | Vercel-provided fallback | Commit deployment hostname used when the first two values are absent | diff --git a/docs/feed-service-integration/implementation-review-round-1.md b/docs/feed-service-integration/implementation-review-round-1.md new file mode 100644 index 00000000..b9a1669c --- /dev/null +++ b/docs/feed-service-integration/implementation-review-round-1.md @@ -0,0 +1,43 @@ +# Implementation review round 1 + +Target: current uncommitted implementation of [`plan.md`](./plan.md). + +## Accepted findings + +### Failed invalid-cursor recovery can reuse the invalid cursor + +Accepted. Recovery must clear cursor ownership before requesting page one. If that recovery fails, the continuation retry action must retry page one while preserving visible items rather than reissuing the invalid cursor. + +### Explicit actor DID can disagree with source ownership + +Accepted. A present event actor DID must equal the validated source AT-URI authority. Missing actor DID may still derive from that authority. Tests must pin mismatch rejection and blob ownership. + +### Datetime and `Retry-After` parsing are too broad + +Accepted. Feed datetimes must match an RFC3339 shape before parsing. `Retry-After` accepts only non-negative delta-seconds or a strict HTTP-date shape, not arbitrary JavaScript-parsable dates. + +### Same-tick `loadMore` calls can duplicate one page request + +Accepted. Add a synchronous in-flight admission ref set before React state updates and cleared only when that continuation/recovery settles or is superseded. + +### Request-key transitions can expose or paginate stale state + +Accepted. State must carry/verify request ownership. A viewer/filter transition renders loading state immediately, rejects continuation against old ownership, aborts both request classes, and cannot combine a new request body with an old cursor. + +### Legacy identity precedence regressed + +Accepted. For incomplete rollback-path summaries, `useAuthorInfo` remains authoritative over indexer hints for handle, display name, and avatar. Add regression coverage. + +### Lifecycle/source-isolation coverage is incomplete + +Accepted. Add focused coverage for same-tick continuation admission, initial abort, filter change during continuation, stale request ownership, empty-page continuation, multi-step cursor cycles, failed invalid-cursor recovery and retry, cooldown expiry, grouped hydrated endorsements, and service-source isolation from legacy/indexer hooks. + +## Rejected or classified separately + +### Remove the AGENTS branching-policy change + +Rejected as an implementation action. That change existed before this task began and belongs to the user's pre-existing dirty worktree. The feed implementation only added the environment rows and direct-XRPC architecture note. The pre-existing policy edit must remain untouched. + +### Treat one reviewer full-suite failure as a confirmed feed regression + +Not confirmed. The implementation worker's full run passed 963 tests. The reviewer saw four Explore failures that all passed in isolation, indicating suite-level flakiness or resource sensitivity. Final acceptance still requires a fresh full-suite pass after fixes; repeated failure would become actionable evidence. diff --git a/docs/feed-service-integration/implementation-review-round-2.md b/docs/feed-service-integration/implementation-review-round-2.md new file mode 100644 index 00000000..e3ac945b --- /dev/null +++ b/docs/feed-service-integration/implementation-review-round-2.md @@ -0,0 +1,37 @@ +# Implementation review round 2 + +Target: post-round-1 uncommitted implementation. + +## Accepted findings + +### Calendar-valid RFC3339 validation + +Accepted. A shape regex plus `Date.parse` still permits normalized rollover values. The adapter must reject invalid calendar days, invalid hour 24, and other normalized-but-invalid timestamps while preserving valid fractional seconds and timezone offsets. + +### Bound `Retry-After` timestamp arithmetic + +Accepted. Delta-seconds must produce a safe finite millisecond timestamp within the browser timer/date range. Oversized values degrade to no readable cooldown rather than overflowing. + +### Enforce handle, URI, and CID formats + +Accepted. Runtime validation must reject malformed actor handles, generic URI image values, source/target CIDs, and blob-ref CIDs. This remains dependency-free and should follow the Lexicon-compatible syntax already enforced by the service. + +### Empty-page pagination needs a manual escape after the auto budget + +Accepted. When no events are visible but a cursor remains, render a manual load-more control. It must remain usable after 25 automatic attempts without introducing a second unbounded IntersectionObserver loop. Add component coverage for budget exhaustion/accessibility. + +### Recognized 5xx messages must remain generic + +Accepted. Even a recognized `InternalError` body cannot surface its message when HTTP status is 500 or greater. Preserve service messages only for recognized sub-500 XRPC errors; test recognized 5xx sanitization. + +### Add rollback-source isolation coverage + +Accepted. Add an indexer-mode component test proving the service hook does not mount and the legacy follow/evaluator hook graph remains selected. + +### Run fresh ship gates after fixes + +Accepted. Final review cannot rely on the previous worker run because this round changes code and tests. + +## Result + +Apply these fixes in one writer pass, then run a final fresh review round focused on regressions rather than optional hardening. diff --git a/docs/feed-service-integration/implementation-review-round-3.md b/docs/feed-service-integration/implementation-review-round-3.md new file mode 100644 index 00000000..20ccad86 --- /dev/null +++ b/docs/feed-service-integration/implementation-review-round-3.md @@ -0,0 +1,27 @@ +# Implementation review round 3 + +Target: post-round-2 implementation. + +## Accepted findings + +### Add normalization/render coverage for every event kind + +Accepted. Runtime adapter tests parse all seven views, but app-level coverage should also prove the normalized home-feed union and row renderer handle all eight known event kinds plus the unknown fallback without crashing or reintroducing actor lookups for complete summaries. + +### Run final gates after the coverage change + +Accepted. The round-2 worker reported a fresh passing run of both typechecks, 994 tests, lint, build, and diff check. Adding the final coverage requires another run before completion. + +## Rejected findings + +### Add Draft to the organization-quality UI + +Rejected as an unapproved product expansion. The approved [`interactive-plan.html`](./interactive-plan.html) request builder exposes High quality, Standard, Likely test, and Not labeled yet. The app's authoritative `ORGLABEL_TIERS` and Orglabeler documentation likewise define only `high-quality`, `standard`, and `likely-test`. The feed-service wire type accepts `draft` for forward compatibility, so the adapter keeps that value in its public type, but the current UI intentionally does not offer or send it. + +### Prior quality gates are unavailable + +Corrected. The round-2 fix worker ran and reported passing post-fix typecheck, test typecheck, 994-test full suite, lint with the unchanged warning baseline, production build, and `git diff --check`. Final gates will still run again after the added matrix coverage. + +## Result + +Add the focused event matrix coverage, then perform one final clean review and independent local validation. External rollout checks remain approval-gated. diff --git a/docs/feed-service-integration/implementation-review-round-4.md b/docs/feed-service-integration/implementation-review-round-4.md new file mode 100644 index 00000000..1dda0bf0 --- /dev/null +++ b/docs/feed-service-integration/implementation-review-round-4.md @@ -0,0 +1,36 @@ +# Implementation review round 4 — final + +Two fresh reviewers inspected the complete uncommitted diff after rounds 1–3. + +## Result + +**CLEAN.** No blocker or medium-priority local fix remains. + +Review confirmed: + +- Credentialless, no-store direct XRPC transport +- Runtime validation for source/actor ownership, kind/view pairing, timestamps, handles, URIs, CIDs, cursor bounds, contextual image variants, and safe errors +- Request ownership, abort/generation checks, single continuation admission, cursor-cycle handling, invalid-cursor page-one recovery, and retry behavior +- Service/indexer hook-graph isolation and redeploy rollback path +- Complete service actors and grouped endorsement subjects render without fallback identity requests +- App-level coverage for all eight event kinds plus unknown fallback +- Environment and rollback documentation consistency + +## Independent final validation + +Run serially after the final test-only change: + +```text +npm run typecheck passed +npm run typecheck:test passed +npm test 114 files, 995 tests passed +npm run lint 0 errors, 64 pre-existing warnings +npm run build passed +git diff --check passed +``` + +Build retained the pre-existing workspace-root and missing-`INDEXER_URL` fallback warnings. + +## Not performed + +No commit, push, deployment, environment change, live CORS request, browser smoke test, rollback smoke test, or production observation was performed. Those remain approval-gated rollout work. diff --git a/docs/feed-service-integration/interactive-plan.html b/docs/feed-service-integration/interactive-plan.html new file mode 100644 index 00000000..9bf8fdcb --- /dev/null +++ b/docs/feed-service-integration/interactive-plan.html @@ -0,0 +1,1779 @@ + + + + + + + + Certified Feed Integration Map + + + + + +
+
+
+ Certified + Systems note 01 +
+

Feed service integration

+
+ +
+
+ +
+
+
+ Implementation map · 28 July 2026 +

Feed, without the fan-out.

+

+ Replace Certified’s browser-coordinated Hyperindex timeline with one hydrated XRPC. Scope, pairing, validation and actor summaries move behind a single contract. The app returns to rendering. +

+
+ +
+ +
+
+ 01 / Flow +

Collapse the pipeline

+

Toggle the system boundary. The target keeps target-title and blob reads where they belong, but removes feed scope and hydration work from the browser.

+
+ +
+
+ + +
+

One browser request per selected page.

+
+ +
+ + +
+
+ Client + HomeFeed + Supplies viewer and current filter selections. +
+
+
+ One XRPC + getFeed + Typed request, cursor, stable errors. +
+
+
+ Feed service + Scope → select → validate → hydrate + One source statement, one identity batch. +
+
+
+ UI + View-native cards + Render seven validated view variants. +
+
+
+ +
+
+ Page transport +
1 XRPC
+

Selection and hydration share one service call.

+
+
+ Scope owner +
Service
+

Viewer follows and evaluator expansion resolve near the database.

+
+
+ Actor summary +
Included
+

Actor and endorsement subject summaries arrive hydrated.

+
+
+
+ +
+
+ 02 / Contract +

Build the request

+

Change the inputs and inspect the deployed wire contract. Activity-quality filtering and durable preference storage are intentionally absent from this cutover.

+
+ +
+
+

Viewer controls

+

The service owns graph expansion. The browser sends current filter selections, not an author list or saved preference record.

+ +
+ + +
+ +
+ Trusted evaluators + + +
+ +
+ Organization quality + + + + +
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+
+ +
+
+ 03 / Work +

Five controlled cuts

+

Each active phase has one owner and one acceptance boundary. The old path remains available until staging proves the new one; preference persistence is a later follow-up.

+
+ +
+ +
+
+

Deferred follow-up: this cutover does not add a preference Lexicon, personal-PDS writes, a local preference cache or cross-device synchronization. Evaluator and organization-quality selections remain request inputs only.

+
+ +
+
+ 04 / Files +

Keep the seam narrow

+

The home feed changes shape. Explore, generic profile queries and the rest of `/api/indexer` remain untouched.

+
+ +
+ + + + +
+
+
+ +
+
+ 05 / Cutover +

Prove the boundary

+

Service commit 02f2a78 is deployed with feedTimestamp and configurable exact-origin CORS. Verify the approved browser origins and app cutover behavior before changing the default.

+
+ +
+
+
+ + Deployed service baseline + 02f2a78 +
+
+ + Feed-procedure CORS + exact origin · POST +
+
+ + Cache policy + no-store +
+
+ + Health endpoint + 200 · ok +
+
+ + Readiness endpoint + 200 · ready +
+
+ + Hydrated first page + items + cursor +
+
+ + Cursor continuation + second page +
+
+ + Invalid cursor + 400 · InvalidCursor +
+
+ +
+

Remaining release gates

+

Service deployment and CORS implementation are complete. Remaining check state is stored locally in this document.

+
+ +

0 of 5 gates complete

+
+
+
+
+ +
+

Certified Feed Service integration · deployed baseline 02f2a78

+

Back to flow

+
+
+ + + + diff --git a/docs/feed-service-integration/plan.md b/docs/feed-service-integration/plan.md new file mode 100644 index 00000000..9b416f9f --- /dev/null +++ b/docs/feed-service-integration/plan.md @@ -0,0 +1,303 @@ +# Certified Feed Service integration plan + +## Larger goal + +Replace the home feed's browser-owned scope expansion and two-step Hyperindex hydration with the deployed Certified Feed Service's hydrated XRPC while keeping the old indexer path available for a redeploy-based rollback during the observation window. + +The home page should supply only the acting viewer DID and current evaluator/organization-quality selections. The service owns follow expansion, evaluator expansion, organization filtering, event selection, source validation, actor hydration, and cursor generation. The app owns rendering, target-title reads, and blob delivery through its existing XRPC proxy. + +Authoritative visual map: [`interactive-plan.html`](./interactive-plan.html). + +## Alternatives considered + +### 1. Build-time source flag with both implementations retained — chosen + +```ts +type HomeFeedSource = "indexer" | "service" + +const source = parseHomeFeedSource( + process.env.NEXT_PUBLIC_HOME_FEED_SOURCE, +) +``` + +- Default: `indexer` until staging verification completes. +- `service` activates the direct hydrated XRPC path. +- Invalid configured values fail with an actionable configuration error. +- Because this is a `NEXT_PUBLIC_*` build-time value, switching it requires a deployment. Rollback is one environment-variable change followed by a redeploy, not an instantaneous runtime toggle. +- Both paths and their dependencies remain bundled until the observation window ends. + +Rationale: preserves the plan's direct-browser boundary and narrow scope without adding a new server proxy solely to own a runtime flag. + +### 2. Immediate direct cutover + +Rejected because it removes the verified rollback path before staging and production observation. + +### 3. Same-origin BFF/runtime switch + +Rejected for this cutover because the feed contains public indexed data, `viewerDid` is a public scope input rather than an authorization identity, and a BFF would add another transport and operational boundary. CORS is browser-origin policy, not authentication or abuse prevention; gateway rate limiting remains the abuse-control boundary. + +## Trust boundary + +The direct feed procedure is intentionally unauthenticated and returns only public indexed records and public actor/profile summaries. The request uses `viewerDid` to select that DID's public Certified follow graph. It does not assert the signed-in user's identity or authorize access to private data. + +The browser request must use: + +```ts +fetch(endpoint, { + method: "POST", + credentials: "omit", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + signal, +}) +``` + +No cookie, OAuth token, rate-limit bypass key, or other credential crosses the origin boundary. + +## Environment contract + +```text +NEXT_PUBLIC_HOME_FEED_SOURCE=indexer|service +NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL=https:// +``` + +- `NEXT_PUBLIC_HOME_FEED_SOURCE` defaults to `indexer` when absent. +- `NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL` is required only when the source is `service`. +- Production/staging service URLs must use HTTPS. +- Non-production permits HTTP only for `localhost`, `127.0.0.1`, or `[::1]`. +- The URL must be an exact origin: no credentials/userinfo, path beyond `/`, query, or fragment. The client appends `/xrpc/app.certified.feed.beta.getFeed`. +- Configuration tests cover HTTPS origins, loopback HTTP, userinfo, paths, queries, fragments, and unsupported protocols. +- Exact approved app origins are `https://certified.app`, `https://staging.certified.app`, and local loopback development origins. Vercel preview origins are not implicitly allowed; they must either use the indexer source or be added explicitly to the service deployment. +- The current CSP already permits direct HTTPS connections. Plain-HTTP local cross-origin feed service use is not added to production CSP; local verification can use the deployed HTTPS service or a same-host development setup. + +## Public and internal APIs + +```ts +type OrganizationQuality = + | "high-quality" + | "standard" + | "draft" + | "likely-test" + +type GetCertifiedFeedInput = { + viewerDid: string + trustedEvaluators: string[] + organizationQuality: { + allowed: OrganizationQuality[] + includeUnrated: boolean + } + limit: number + cursor?: string +} + +type CertifiedFeedActor = { + did: string + handle?: string + displayName?: string + avatar?: FeedImage +} + +type CertifiedFeedItem = { + id: string + kind: string // open union + subject: { uri: string; cid: string } + feedTimestamp: string + actor: CertifiedFeedActor + view: KnownFeedView | UnknownFeedView +} + +type CertifiedFeedPage = { + items: CertifiedFeedItem[] + cursor?: string +} + +class CertifiedFeedError extends Error { + status: number + code: string | null + retryAt: number | null +} + +async function fetchCertifiedFeed( + input: GetCertifiedFeedInput, + signal?: AbortSignal, +): Promise +``` + +The adapter remains dependency-free and runtime-checks the response boundary. + +## Runtime validation contract + +- Validate the response object, `items` array, optional cursor, required item fields, strong references, timestamps, actor summaries, and every known view shape. +- A response cursor must be a non-empty string no longer than 4096 characters. +- Validate DIDs at least as a syntactically bounded `did::` value. +- If `actor.did` is absent, derive it only from a valid source AT-URI whose authority is a valid DID. +- Enforce the service's kind-to-view matrix: `cert.create` → `activityView`; `collection.create` / `project.created_with_cert` → `collectionView`; every other known kind → its same-named view. A known kind paired with the wrong known view is a contract failure. +- A malformed known view is a contract failure for the page. The error says which item/view failed and that the service contract or deployment must be corrected. +- An unknown `view.$type` or unknown event kind becomes a renderable unknown event preserving actor, timestamp, raw kind, and subject URI. It must not crash or disappear silently. +- Image validation is contextual: actor/activity accept `uri | smallImage`; collection accepts `uri | smallImage | largeImage`; update accepts `uri | smallBlob`. A malformed known variant fails the contract; a genuinely unknown discriminator is ignored. +- Actor-avatar blob URLs use `actor.did`. View-image blob URLs use the DID authority from the validated source AT-URI. All blobs resolve through `/api/xrpc/com/atproto/sync/getBlob`; URI variants remain external image URLs. + +## View-native rendering seam + +Both sources normalize into one `HomeFeedEvent` union: + +```ts +type HomeFeedActor = { + did: string + handle: string | null + displayName: string | null + avatarUrl: string | null + complete: boolean // service summary is complete even when DID-only +} + +type HomeFeedEvent = + | { kind: "cert.create"; view: ActivityView; ...base } + | { kind: "collection.create" | "project.created_with_cert"; view: CollectionView; ...base } + | { kind: "endorsement.award" | "legacy.endorsement"; subject: HomeFeedActor; ...base } + | { kind: "evaluation.create" | "measurement.create" | "hyperboard.create" | "update.create"; view: SimpleView; ...base } + | { kind: "unknown"; rawKind: string; subjectUri: string; ...base } +``` + +Call stack: + +```text +HomeFeed(activeDid) + -> ephemeral evaluator + organization-quality controls + -> source switch between separate hook-owning children + -> ServiceHomeFeed + -> wait for useTrustedEvaluators() to resolve + -> useHomeFeed({ viewerDid, trustedEvaluators, organizationQuality }) + -> fetchCertifiedFeed() + -> normalize service views + -> LegacyHomeFeed + -> existing follows/evaluator/org expansion + -> useLegacyHomeFeed(effectiveFollows) + -> FollowerEvents + HydrateFeedPage + -> normalize legacy payloads + -> HomeFeedBody(HomeFeedEvent[]) + -> target-title hooks / blob proxy only where still required +``` + +The source split uses separate `ServiceHomeFeed` and `LegacyHomeFeed` child components so legacy hooks are never invoked in service mode and hooks are not called conditionally inside one component. Follow-specific loading/error/count states remain inside `LegacyHomeFeed`; the service body does not claim an empty page means the viewer follows nobody. + +Service actor and endorsement-subject summaries set `complete: true`. Source-safe renderer components consume complete summaries without invoking `useAuthorInfo`; legacy lookup renderers receive only incomplete summaries. Evaluator rows still resolve evaluator display data because the trusted-evaluator list contains DIDs only. Endorsement groups carry `HomeFeedActor[]`, not just DIDs, so grouped service endorsements preserve hydrated summaries. `legacy.endorsement` remains supported through the observation window with an incomplete subject actor. + +## Pagination and failure behavior + +The service hook returns explicit failure and retry state: + +```ts +type HomeFeedState = { + events: HomeFeedEvent[] + isLoading: boolean + isLoadingMore: boolean + hasMore: boolean + initialError: string | null + continuationError: string | null + retryAt: number | null + canAutoLoad: boolean + requestKey: string + retryInitial(): void + loadMore(): void // also the explicit continuation retry +} +``` + +- Initial and continuation requests receive abort signals. +- A dependency change aborts both request classes and increments a request generation; stale resolutions cannot commit state. +- `requestKey` resets/remounts the component-level auto-pagination budget when viewer or filters change. +- Append pages deduplicate by event ID. +- Missing cursor means end of feed. +- Empty pages with a cursor remain pageable; existing auto-pagination may request the next selected page. +- Track every cursor seen in the current request generation. Reject an empty, overlong, repeated, or cyclic cursor before another request can loop. +- `InvalidCursor` discards the cursor and requests page one while preserving visible items until replacement succeeds. +- Initial errors show a retry action. +- Continuation errors preserve visible items, set `canAutoLoad: false`, and show a manual retry action. +- HTTP 429 reads `Retry-After` as delta-seconds or HTTP-date only when cross-origin response headers expose it. A valid visible delay disables manual retry until expiry. Missing, malformed, or CORS-hidden headers still block automatic pagination but allow explicit manual retry; the app does not pretend a hidden header was honored. +- Preserve server messages only from a bounded, validated JSON XRPC error body carrying one of the recognized feed error codes. Malformed bodies, non-JSON gateway responses, and unknown error codes use a generic client message. + +## Filter behavior + +Keep: + +- Trusted evaluator selection, sourced from the existing curated list reader. +- Organization-quality selection, serialized directly as `allowed` and `includeUnrated`. +- Ephemeral in-memory state only. + +Drop from the service path: + +- PDS follow walk in the browser. +- Evaluator endorsement expansion in the browser. +- Organization-label DID resolution in the browser. +- Activity-quality request filtering and its controls. + +The hydrated service contract has no activity-quality field or labels in activity views. Service mode can therefore include draft/likely-test activities selected by the public feed service. The rollback indexer path keeps its existing default label exclusion internally, but the activity-quality control is removed from the shared UI during this cutover. + +## File ownership + +### Add + +- `src/lib/atproto/certified-feed.ts` +- `src/lib/atproto/__tests__/certified-feed.test.ts` +- Mandatory hook lifecycle and source-isolation contract tests + +### Change + +- `src/hooks/use-home-feed.ts` +- `src/components/home/home-feed.tsx` +- `src/lib/utils/group-feed.ts` +- `src/lib/utils/__tests__/group-feed.test.ts` +- `src/lib/dev/fixtures/feed.ts` +- `src/components/dev/mock-fetch-provider.tsx` +- `.env.local.example` +- `README.md` +- `AGENTS.md` only for the new public environment and architecture contract + +### Keep through observation + +- `src/lib/atproto/follower-events.ts` +- `src/hooks/use-evaluator-endorsements.ts` +- Home-feed GraphQL operations and tests +- `/api/indexer` for Explore, profiles, and the rollback feed path +- `useActivity` / `useProject` target-title reads + +### Remove only after a separately approved observation-window cleanup + +- Home-only `FollowerEvents`, `HydrateFeedPage`, `EvaluatorEndorsements`, and organization-label feed usage +- Legacy feed fixtures and rollback source branch + +No file deletion is part of this implementation. + +## Acceptance criteria + +1. Service mode sends one credentialless `getFeed` request per selected page. +2. Personal and active-group DIDs are sent as `viewerDid` without client-side scope expansion. +3. All eight event kinds render from the seven known view variants. +4. Unknown views/kinds degrade to the existing generic event treatment. +5. Feed actor and endorsement-subject cards do not call `useAuthorInfo` in service mode. +6. Activity/project target titles and blob fallbacks remain intact. +7. Filter changes and acting-context changes cannot append stale events. +8. Empty-page cursors, cursor cycles, invalid cursors, 429 responses, and continuation 500s terminate or recover without loops. +9. Configuration tests reject malformed/non-origin service URLs and invalid source values. +10. Lifecycle tests cover personal→group and filter changes during continuation, both request classes being aborted, stale resolutions, empty/repeated/cyclic cursors, retry blocking, evaluator-list readiness, grouped hydrated endorsements, and source isolation. +11. Service mode sends no home-feed requests to `/api/indexer`; indexer mode remains functional. +12. Explore and unrelated indexer operations are unchanged. +13. Before enabling service mode, the target app origin is present in the deployed exact CORS allowlist; production localhost allowance is explicitly reviewed; browser preflight and credentialless POST smoke tests pass for personal and active-group contexts; and indexer rollback mode is smoke-tested from the same build. +14. The rollout owner, rollback trigger, observation period, and exit metrics are recorded before production cutover. `Retry-After` cooldown is verified only if the gateway/service exposes that response header to browser JavaScript. +15. `npm run typecheck`, `npm run typecheck:test`, `npm run lint`, `npm test`, and `npm run build` pass. + +## Out of scope + +- Durable preference Lexicon or PDS writes +- localStorage preference cache +- Cross-device filter synchronization +- Feed-service authentication +- Target-record hydration inside the service +- Blob proxying inside the feed service +- Explore/profile indexer migration +- Legacy path deletion before observation completes +- Deployment, environment changes, CORS changes, or live external verification without separate explicit approval. These remain rollout gates, not app-code work. + +## Rollback + +Set `NEXT_PUBLIC_HOME_FEED_SOURCE=indexer` and redeploy the app. The legacy code, fixtures, and indexer operations remain present. No data migration, schema change, or user-state rollback is required. diff --git a/docs/feed-service-integration/review-round-1.md b/docs/feed-service-integration/review-round-1.md new file mode 100644 index 00000000..5a4d59a2 --- /dev/null +++ b/docs/feed-service-integration/review-round-1.md @@ -0,0 +1,51 @@ +# Plan review round 1 + +Target reviewed: [`interactive-plan.html`](./interactive-plan.html) plus the current home-feed implementation and Certified Feed Service commit `02f2a78`. + +## Accepted findings + +### Clarify the direct-browser trust boundary + +Accepted. The implementation plan now states that the endpoint returns public indexed data, `viewerDid` is a scope input rather than an authorization identity, and CORS is not authentication or abuse prevention. A same-origin authenticated BFF is intentionally not part of this cutover. + +### Define rollback flag ownership and semantics + +Accepted. The flag is `NEXT_PUBLIC_HOME_FEED_SOURCE=indexer|service`, defaults to `indexer`, fails on invalid configured values, and requires a redeploy because it is build-time public configuration. Both implementations remain bundled through observation. + +### Name and validate the service URL configuration + +Accepted. The URL variable is `NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL`. It is required only in service mode, must be HTTPS outside local development, and is documented alongside exact-origin behavior. Vercel preview origins are not implicitly supported. + +### Define open-union and malformed-known-view behavior + +Accepted. Unknown kinds/views become generic renderable events. Malformed known views fail the page with a contract error. Required fields, strong refs, DIDs, timestamps, and known image wrappers are runtime-checked. Missing actor DIDs are derived only from a valid DID-authority source AT-URI. + +### Define rate-limit and continuation failure behavior + +Accepted. The plan now distinguishes initial and continuation failures, parses both `Retry-After` formats, blocks automatic pagination after failure, preserves visible items, and requires explicit retry UI and focused tests. + +### Record the activity-quality behavior change + +Accepted. The shared activity-quality controls are removed. Service mode has no activity-quality filter and may include draft/likely-test activities. The rollback indexer path keeps its existing internal default exclusion. + +### Add a canonical Markdown implementation plan + +Accepted. `plan.md` is now the implementation-shaped contract; the interactive map remains the visual source. + +## Rejected or corrected findings + +### Request should include activity-quality input + +Rejected. The deployed `app.certified.feed.beta.getFeed` Lexicon has no activity-quality field, and the approved cutover explicitly defers it. Sending one would violate the request contract. + +### Every service actor must contain a DID + +Corrected. Commit `b256138` made `actorSummary.did` optional on the wire. The client therefore derives a missing actor DID from the validated source AT-URI. The normalized internal actor always has a DID. + +### Add a runtime server-controlled source switch + +Rejected for this cutover. It would require a new BFF/control-plane seam and undermine the chosen direct-browser architecture. Redeploy-based rollback is acceptable during the observation window. + +## Result + +No unresolved product or architecture decision blocks implementation. Live origin/service verification and any environment or deployment change remain separate approval-gated actions. diff --git a/docs/feed-service-integration/review-round-2.md b/docs/feed-service-integration/review-round-2.md new file mode 100644 index 00000000..80bf996f --- /dev/null +++ b/docs/feed-service-integration/review-round-2.md @@ -0,0 +1,67 @@ +# Plan review round 2 + +Target: [`plan.md`](./plan.md), reviewed against Certified Feed Service commit `02f2a78` and the current React implementation. + +## Accepted + +### `Retry-After` is not currently CORS-exposed + +Accepted. The browser cannot rely on reading it from the deployed cross-origin response. The client will parse and honor a visible valid header opportunistically, but a missing, malformed, or CORS-hidden header blocks automatic pagination and leaves explicit manual retry available. Exposing and verifying the header is a rollout gate, not an app-code or deployment change in this branch. + +### Enforce the kind-to-view matrix + +Accepted. Known mismatched pairs are contract failures. Unknown kinds or unknown view discriminators still degrade to the generic event renderer. + +### Make image validation contextual and pin blob ownership + +Accepted. Actor/activity, collection, and update views each accept only their Lexicon-defined image unions. Actor avatars use `actor.did`; view blobs use the source AT-URI authority. Tests must cover both. + +### Restrict trusted error messages + +Accepted. Only bounded JSON XRPC errors with recognized feed error codes may surface their service message. Unknown/non-JSON/malformed failures use a generic client message. + +### Strengthen cursor invariants + +Accepted. Response cursors must be non-empty and at most 4096 characters. The hook tracks every cursor in a request generation and stops any cycle before another request. + +### Preserve endorsement actor summaries through grouping + +Accepted. Grouping stores `HomeFeedActor[]`; service summaries render without `useAuthorInfo`. Grouped-endorsement coverage is required. + +### Split source implementations into child components + +Accepted. `ServiceHomeFeed` and `LegacyHomeFeed` own disjoint hook graphs. Legacy follow/evaluator/org expansion never runs in service mode. Follow-specific states remain legacy-only. + +### Preserve `legacy.endorsement` + +Accepted. The rollback path retains it with an incomplete subject actor. + +### Define hook retry state and timer ownership + +Accepted. The plan now names initial/continuation errors, retry timestamp, auto-load gate, request key, and retry actions. A valid visible 429 delay owns a timer that re-enables manual retry. + +### Reset auto-pagination on request-generation changes + +Accepted. The body receives a request key so viewer/filter changes reset its attempt budget. + +### Require lifecycle and source-isolation tests + +Accepted. These tests are mandatory, including acting-context/filter rerenders during continuation and evaluator-list readiness. + +### Tighten service URL parsing + +Accepted. The value is an exact origin, not a general base URL. Userinfo, path, query, fragment, invalid protocol, and non-loopback HTTP are rejected. + +### Add operational cutover gates + +Accepted. Exact CORS allowlist, localhost policy, browser preflight/POST smoke tests, same-build rollback smoke, rollout owner/trigger, observation period, and exit metrics are now explicit acceptance gates. They will not be executed without separate approval. + +## Rejected or deferred + +### Change the feed service or gateway in this app implementation + +Deferred. Adding `Access-Control-Expose-Headers: Retry-After`, changing deployed CORS origins, and gateway policy belong to the service/deployment rollout. This branch implements safe fallback behavior and records those external gates without changing external state. + +## Result + +The app implementation can proceed. Production cutover remains intentionally blocked on the approval-gated operational checks. diff --git a/src/components/dev/mock-fetch-provider.tsx b/src/components/dev/mock-fetch-provider.tsx index 1ebb1629..71630049 100644 --- a/src/components/dev/mock-fetch-provider.tsx +++ b/src/components/dev/mock-fetch-provider.tsx @@ -44,6 +44,7 @@ import { } from "@/lib/dev/fixtures/profile" import { resolveDidsResults } from "@/lib/dev/fixtures/authors" import { + certifiedFeedPage, followerEventsConnection, hydrateFeedPageData, activitiesConnection, @@ -60,6 +61,7 @@ import { groupsMembershipsResponse, } from "@/lib/dev/fixtures/groups" import { searchActorsResponse } from "@/lib/dev/fixtures/search" +import { CERTIFIED_FEED_PATH } from "@/lib/atproto/certified-feed" import { isManagedAuthorsRequest, managedProjectsConnection, @@ -343,6 +345,12 @@ function installMockFetch( const path = url.pathname + // Direct cross-origin feed-service XRPC. Match the path rather than the + // origin so preview builds can use any explicitly configured service URL. + if (path === CERTIFIED_FEED_PATH) { + return json(empty ? { items: [] } : certifiedFeedPage()) + } + // --- PLC directory (resolvePdsUrl / resolveHandle fetch it directly) --- if (url.hostname === "plc.directory") { // Managed scenario: serve a per-group DID document so each managed 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 2ee3d178..210c17e5 100644 --- a/src/components/home/__tests__/cert-preview-location-icon.test.tsx +++ b/src/components/home/__tests__/cert-preview-location-icon.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, afterEach } from "vitest" import { render, screen, cleanup, within } from "@testing-library/react" import { CertPreview } from "../home-feed-rows" -import type { ActivityRecord } from "@/lib/atproto/activity-types" // bug-010: the feed PreviewCard's MapPin was gated on // `i === 0 && withLocationIcon && i === meta.length - 1`, which is only @@ -20,24 +19,16 @@ afterEach(() => { describe("CertPreview location MapPin", () => { it("renders a MapPin next to the locations entry even when a period is also present", () => { - const record = { - uri: URI, - cid: "bafycid", - value: { - title: "Reforestation effort", - shortDescription: "Planting trees", - createdAt: "2025-01-01T00:00:00.000Z", - startDate: "2025-01-15T00:00:00.000Z", - endDate: "2025-03-20T00:00:00.000Z", - locations: [ - { uri: "at://did:plc:l/app.certified.location/a", cid: "bafa" }, - { uri: "at://did:plc:l/app.certified.location/b", cid: "bafb" }, - { uri: "at://did:plc:l/app.certified.location/c", cid: "bafc" }, - ], - }, - } as unknown as ActivityRecord - - render() + const view = { + title: "Reforestation effort", + shortDescription: "Planting trees", + imageUrl: null, + startDate: "2025-01-15T00:00:00.000Z", + endDate: "2025-03-20T00:00:00.000Z", + locationCount: 3, + } + + render() // The locations text still renders. const locationsItem = screen.getByText(/3 locations/).closest("span") diff --git a/src/components/home/__tests__/endorsement-group-row.test.tsx b/src/components/home/__tests__/endorsement-group-row.test.tsx index 7503337f..73d44536 100644 --- a/src/components/home/__tests__/endorsement-group-row.test.tsx +++ b/src/components/home/__tests__/endorsement-group-row.test.tsx @@ -3,7 +3,7 @@ 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" +import type { HomeFeedActor } from "@/hooks/use-home-feed" // useAuthorInfo goes through the batched DID resolver (network). Stub // it so rows render DID-only bylines synchronously; the spy doubles as @@ -17,13 +17,18 @@ 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 actor(did: string): HomeFeedActor { + return { + did, + handle: null, + displayName: null, + avatarUrl: null, + complete: false, + } } +const ACTOR_PROFILE = actor("did:plc:actor") + function makeGroup( count: number, overrides?: Partial, @@ -34,7 +39,9 @@ function makeGroup( actor: "did:plc:actor", actorProfile: ACTOR_PROFILE, createdAt: "2026-07-01T00:00:00.000Z", - subjectDids: Array.from({ length: count }, (_, i) => `did:plc:subject${i}`), + subjects: Array.from({ length: count }, (_, i) => + actor(`did:plc:subject${i}`), + ), ...overrides, } } @@ -90,15 +97,15 @@ describe("EndorsementGroupRow expanded-list windowing", () => { 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. + // subjects 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 + // Fresh group + fresh subject summaries, same values (actorProfile // ref is stable in production — it comes from the stable event). rerender() expect(useAuthorInfoMock.mock.calls.length).toBe(baseline) diff --git a/src/components/home/__tests__/home-feed-empty-pagination.test.tsx b/src/components/home/__tests__/home-feed-empty-pagination.test.tsx new file mode 100644 index 00000000..4ae7d54b --- /dev/null +++ b/src/components/home/__tests__/home-feed-empty-pagination.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { HomeFeedBody } from "../home-feed" + +const observerConstructed = vi.fn() + +class FakeIntersectionObserver { + constructor() { + observerConstructed() + } + observe() {} + disconnect() {} +} + +beforeEach(() => { + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver) + observerConstructed.mockClear() +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe("empty home-feed pagination", () => { + it("keeps an accessible manual control after the 25-attempt auto budget", async () => { + const loadMore = vi.fn() + const props = { + events: [], + isLoading: false, + isLoadingMore: false, + hasMore: true, + cursor: "cursor-a", + error: null, + continuationError: null, + retryAt: null, + canAutoLoad: true, + requestKey: "request-a", + retryInitial: vi.fn(), + loadMore, + } + const { rerender } = render() + await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(1)) + + for (let attempt = 1; attempt < 25; attempt++) { + rerender() + rerender() + await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(attempt + 1)) + } + + const manualButton = screen.getByRole("button", { name: "Load more" }) + expect(manualButton).toBeTruthy() + expect(observerConstructed).not.toHaveBeenCalled() + fireEvent.click(manualButton) + expect(loadMore).toHaveBeenCalledTimes(26) + }) +}) diff --git a/src/components/home/__tests__/home-feed-indexer-source-isolation.test.tsx b/src/components/home/__tests__/home-feed-indexer-source-isolation.test.tsx new file mode 100644 index 00000000..650000c3 --- /dev/null +++ b/src/components/home/__tests__/home-feed-indexer-source-isolation.test.tsx @@ -0,0 +1,85 @@ +import type { ComponentType } from "react" +import { cleanup, render, screen } from "@testing-library/react" +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + service: vi.fn(() => { + throw new Error("Service hook must not mount in indexer mode") + }), + legacy: vi.fn(() => ({ + events: [], + isLoading: false, + isLoadingMore: false, + hasMore: false, + cursor: null, + error: null, + continuationError: null, + retryAt: null, + canAutoLoad: true, + requestKey: "legacy-request", + retryInitial: vi.fn(), + loadMore: vi.fn(), + })), + following: vi.fn(() => ({ + subjects: new Set(["did:plc:zyxwvutsrqponmlkjihgfedc"]), + isLoading: false, + error: null, + })), + evaluatorExpansion: vi.fn(() => ({ + endorsedDids: new Set(), + isLoading: false, + })), + fetchOrgDidsByLabel: vi.fn(async () => new Set()), +})) + +vi.mock("@/hooks/use-home-feed", async () => { + const actual = await vi.importActual( + "@/hooks/use-home-feed", + ) + return { + ...actual, + useHomeFeed: mocks.service, + useLegacyHomeFeed: mocks.legacy, + } +}) +vi.mock("@/hooks/use-following", () => ({ useFollowing: mocks.following })) +vi.mock("@/hooks/use-evaluator-endorsements", () => ({ + useEvaluatorEndorsements: mocks.evaluatorExpansion, +})) +vi.mock("@/hooks/use-trusted-evaluators", () => ({ + useTrustedEvaluators: () => ({ evaluatorDids: [], isLoading: false }), +})) +vi.mock("@/lib/atproto/workspace", async () => { + const actual = await vi.importActual( + "@/lib/atproto/workspace", + ) + return { ...actual, fetchOrgDidsByLabel: mocks.fetchOrgDidsByLabel } +}) + +let HomeFeed: ComponentType<{ activeDid: string }> + +beforeAll(async () => { + vi.stubEnv("NEXT_PUBLIC_HOME_FEED_SOURCE", "indexer") + HomeFeed = (await import("../home-feed")).default +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +afterAll(() => { + vi.unstubAllEnvs() +}) + +describe("HomeFeed indexer source isolation", () => { + it("mounts the legacy graph and never mounts the service hook", () => { + render() + + expect(screen.getByText("No activity yet")).toBeTruthy() + expect(mocks.service).not.toHaveBeenCalled() + expect(mocks.legacy).toHaveBeenCalledOnce() + expect(mocks.following).toHaveBeenCalledOnce() + expect(mocks.evaluatorExpansion).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/home/__tests__/home-feed-service-render.test.tsx b/src/components/home/__tests__/home-feed-service-render.test.tsx new file mode 100644 index 00000000..4cf32d2a --- /dev/null +++ b/src/components/home/__tests__/home-feed-service-render.test.tsx @@ -0,0 +1,421 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + useHomeFeed, + type HomeFeedActor, + type HomeFeedEvent, +} from "@/hooks/use-home-feed" +import type { + CertifiedFeedItem, + CertifiedFeedView, +} from "@/lib/atproto/certified-feed" + +interface MockAuthorResult { + info: { + did: string + handle: string + displayName: string | null + avatarUrl: string | null + } | null + isLoading: boolean + error: string | null +} + +const { fetchCertifiedFeedMock, useAuthorInfoMock } = vi.hoisted(() => ({ + fetchCertifiedFeedMock: vi.fn(), + useAuthorInfoMock: vi.fn( + (_did: string | null): MockAuthorResult => ({ + info: null, + isLoading: false, + error: null, + }), + ), +})) + +vi.mock("@/lib/atproto/certified-feed", async () => { + const actual = await vi.importActual( + "@/lib/atproto/certified-feed", + ) + return { ...actual, fetchCertifiedFeed: fetchCertifiedFeedMock } +}) + +vi.mock("@/hooks/use-author-info", () => ({ + useAuthorInfo: useAuthorInfoMock, +})) + +import { EndorsementGroupRow, HomeFeedRow } from "../home-feed-rows" + +const issuer: HomeFeedActor = { + did: "did:plc:abcdefghijklmnopqrstuvwx", + handle: "issuer.example", + displayName: "Hydrated Issuer", + avatarUrl: null, + complete: true, +} +const subject: HomeFeedActor = { + did: "did:plc:zyxwvutsrqponmlkjihgfedc", + handle: "subject.example", + displayName: "Hydrated Subject", + avatarUrl: null, + complete: true, +} + +function base(uri: string) { + return { + uri, + actor: issuer.did, + actorProfile: issuer, + createdAt: "2026-07-21T10:00:00.000Z", + } +} + +const serviceActor = { + did: issuer.did, + handle: issuer.handle, + displayName: issuer.displayName, + avatar: null, +} +const serviceSubject = { + did: subject.did, + handle: subject.handle, + displayName: subject.displayName, + avatar: null, +} +const serviceCid = + "bafyreia3tbsfxe3cc75xrxyyn6qc42oupi73fxiox76prlyi5bpx7hr72u" +const serviceTimestamp = "2026-07-21T10:00:00.000Z" + +function serviceItem( + kind: string, + rkey: string, + view: CertifiedFeedView, + collection = "org.hypercerts.claim.activity", +): CertifiedFeedItem { + const uri = `at://${issuer.did}/${collection}/${rkey}` + return { + id: uri, + kind, + subject: { uri, cid: serviceCid }, + feedTimestamp: serviceTimestamp, + actor: serviceActor, + view, + } +} + +const serviceEventMatrix: CertifiedFeedItem[] = [ + serviceItem("cert.create", "activity", { + $type: "app.certified.feed.beta.defs#activityView", + title: "Matrix activity", + shortDescription: null, + image: null, + createdAt: null, + startDate: null, + endDate: null, + locationCount: 0, + }), + serviceItem( + "collection.create", + "collection", + { + $type: "app.certified.feed.beta.defs#collectionView", + collectionType: "list:accounts", + title: "Matrix collection", + shortDescription: null, + image: null, + createdAt: null, + itemCount: 2, + }, + "org.hypercerts.collection", + ), + serviceItem( + "project.created_with_cert", + "paired-project", + { + $type: "app.certified.feed.beta.defs#collectionView", + collectionType: "project", + title: "Matrix paired project", + shortDescription: null, + image: null, + createdAt: null, + itemCount: 1, + }, + "org.hypercerts.collection", + ), + serviceItem( + "endorsement.award", + "endorsement", + { + $type: "app.certified.feed.beta.defs#endorsementView", + subject: serviceSubject, + createdAt: null, + }, + "app.certified.badge.award", + ), + serviceItem("evaluation.create", "evaluation", { + $type: "app.certified.feed.beta.defs#evaluationView", + summary: "Matrix evaluation", + createdAt: null, + target: null, + }), + serviceItem("measurement.create", "measurement", { + $type: "app.certified.feed.beta.defs#measurementView", + metric: "Matrix measurement", + createdAt: null, + target: null, + }), + serviceItem("hyperboard.create", "hyperboard", { + $type: "app.certified.feed.beta.defs#hyperboardView", + createdAt: null, + }), + serviceItem("update.create", "update", { + $type: "app.certified.feed.beta.defs#updateView", + title: "Matrix update", + shortDescription: null, + image: null, + createdAt: null, + target: null, + }), + serviceItem("future.create", "future", { + $type: "app.certified.feed.beta.defs#futureView", + unknown: true, + }), +] + +function ServiceEventMatrix() { + const result = useHomeFeed(issuer.did, { + trustedEvaluators: [], + organizationQuality: { allowed: ["high-quality", "standard"], includeUnrated: true }, + }) + if (result.isLoading) return

Loading matrix

+ return ( + <> + + {result.events.map((event) => event.kind).join("|")} + + {result.events.map((event) => ( + + ))} + + ) +} + +beforeEach(() => { + fetchCertifiedFeedMock.mockReset() + useAuthorInfoMock.mockReset() + useAuthorInfoMock.mockReturnValue({ info: null, isLoading: false, error: null }) +}) + +afterEach(() => { + cleanup() +}) + +describe("service-native feed rendering", () => { + it("normalizes and renders all eight known kinds plus the unknown fallback", async () => { + fetchCertifiedFeedMock.mockResolvedValue({ + items: serviceEventMatrix, + cursor: null, + }) + + render() + + await waitFor(() => + expect(screen.getByTestId("normalized-kinds").textContent).toBe( + [ + "cert.create", + "collection.create", + "project.created_with_cert", + "endorsement.award", + "evaluation.create", + "measurement.create", + "hyperboard.create", + "update.create", + "unknown", + ].join("|"), + ), + ) + expect(screen.getByText("created an activity")).toBeTruthy() + expect(screen.getByText("created a list of accounts")).toBeTruthy() + expect(screen.getByText("created a project with an activity")).toBeTruthy() + expect(screen.getByText("Hydrated Subject")).toBeTruthy() + expect(screen.getByText("added an evaluation")).toBeTruthy() + expect(screen.getByText("added a measurement")).toBeTruthy() + expect(screen.getByText("created a hyperboard")).toBeTruthy() + expect(screen.getByText("posted an update")).toBeTruthy() + expect(screen.getByText("did something")).toBeTruthy() + expect(screen.getByText("Matrix activity")).toBeTruthy() + expect(screen.getByText("Matrix collection")).toBeTruthy() + expect(screen.getByText("Matrix paired project")).toBeTruthy() + expect(screen.getByText("Matrix update")).toBeTruthy() + expect(fetchCertifiedFeedMock).toHaveBeenCalledOnce() + expect(useAuthorInfoMock).not.toHaveBeenCalled() + }) + + it("renders a complete actor summary without mounting the fallback lookup", () => { + const event: HomeFeedEvent = { + ...base(`at://${issuer.did}/org.hypercerts.claim.activity/a`), + kind: "cert.create", + view: { + title: "Hydrated activity", + shortDescription: null, + imageUrl: null, + startDate: null, + endDate: null, + locationCount: 0, + }, + } + render() + expect(screen.getByText("Hydrated Issuer")).toBeTruthy() + expect(useAuthorInfoMock).not.toHaveBeenCalled() + }) + + it("renders complete endorsement issuer and subject summaries without fallback lookups", () => { + const event: HomeFeedEvent = { + ...base(`at://${issuer.did}/app.certified.badge.award/a`), + kind: "endorsement.award", + subject, + note: null, + } + render() + expect(screen.getByText("Hydrated Issuer")).toBeTruthy() + expect(screen.getByText("Hydrated Subject")).toBeTruthy() + expect(useAuthorInfoMock).not.toHaveBeenCalled() + }) + + it("preserves live lookup precedence over every incomplete legacy hint", () => { + useAuthorInfoMock.mockImplementation((did: string | null) => ({ + info: did === null + ? null + : did === issuer.did + ? { + did, + handle: "live-issuer.example", + displayName: "Live Issuer", + avatarUrl: "https://images.example/live-issuer.png", + } + : { + did, + handle: "live-subject.example", + displayName: "Live Subject", + avatarUrl: "https://images.example/live-subject.png", + }, + isLoading: false, + error: null, + })) + const event: HomeFeedEvent = { + ...base(`at://${issuer.did}/app.certified.badge.award/legacy`), + actorProfile: { + ...issuer, + handle: "stale-issuer.example", + displayName: "Stale Issuer", + avatarUrl: "https://images.example/stale-issuer.png", + complete: false, + }, + kind: "endorsement.award", + subject: { + ...subject, + handle: "stale-subject.example", + displayName: "Stale Subject", + avatarUrl: "https://images.example/stale-subject.png", + complete: false, + }, + note: null, + } + + const { container } = render() + + expect(screen.getByText("Live Issuer")).toBeTruthy() + expect(screen.getByText("Live Subject")).toBeTruthy() + expect(screen.queryByText("Stale Issuer")).toBeNull() + expect(screen.queryByText("Stale Subject")).toBeNull() + const imageSources = [...container.querySelectorAll("img")].map((image) => + image.getAttribute("src"), + ) + expect(imageSources).toContain("https://images.example/live-issuer.png") + expect(useAuthorInfoMock).toHaveBeenCalledWith(issuer.did) + expect(useAuthorInfoMock).toHaveBeenCalledWith(subject.did) + }) + + it("uses live lookup precedence in grouped legacy summary and expanded rows", () => { + const secondDid = "did:plc:qwertyuiopasdfghjklzxcvb" + useAuthorInfoMock.mockImplementation((did: string | null) => { + const identities = new Map([ + [issuer.did, ["live-issuer.example", "Live Issuer"]], + [subject.did, ["live-subject.example", "Live Subject"]], + [secondDid, ["live-second.example", "Live Second"]], + ]) + const identity = did ? identities.get(did) : undefined + return { + info: + did && identity + ? { + did, + handle: identity[0], + displayName: identity[1], + avatarUrl: null, + } + : null, + isLoading: false, + error: null, + } + }) + const incompleteIssuer = { + ...issuer, + displayName: "Stale Issuer", + complete: false, + } + render( + , + ) + + expect(screen.getByText("Live Issuer")).toBeTruthy() + expect(screen.getByText("Live Subject")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Show all" })) + expect(screen.getByText("Live Second")).toBeTruthy() + expect(screen.queryByText("Stale Second")).toBeNull() + }) + + it("renders a grouped hydrated endorsement without fallback lookups", () => { + render( + , + ) + + expect(screen.getByText("Hydrated Subject")).toBeTruthy() + fireEvent.click(screen.getByRole("button", { name: "Show all" })) + expect(screen.getByText("Second Subject")).toBeTruthy() + expect(useAuthorInfoMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/home/__tests__/home-feed-source-isolation.test.tsx b/src/components/home/__tests__/home-feed-source-isolation.test.tsx new file mode 100644 index 00000000..e86998b0 --- /dev/null +++ b/src/components/home/__tests__/home-feed-source-isolation.test.tsx @@ -0,0 +1,75 @@ +import type { ComponentType } from "react" +import { cleanup, render, screen } from "@testing-library/react" +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + service: vi.fn(() => ({ + events: [], + isLoading: false, + isLoadingMore: false, + hasMore: false, + cursor: null, + error: null, + continuationError: null, + retryAt: null, + canAutoLoad: true, + requestKey: "service-request", + retryInitial: vi.fn(), + loadMore: vi.fn(), + })), + legacy: vi.fn(() => { + throw new Error("Legacy feed hook must not mount in service mode") + }), + following: vi.fn(() => { + throw new Error("Follow expansion must not mount in service mode") + }), + evaluatorExpansion: vi.fn(() => { + throw new Error("Evaluator expansion must not mount in service mode") + }), +})) + +vi.mock("@/hooks/use-home-feed", async () => { + const actual = await vi.importActual( + "@/hooks/use-home-feed", + ) + return { + ...actual, + useHomeFeed: mocks.service, + useLegacyHomeFeed: mocks.legacy, + } +}) +vi.mock("@/hooks/use-following", () => ({ useFollowing: mocks.following })) +vi.mock("@/hooks/use-evaluator-endorsements", () => ({ + useEvaluatorEndorsements: mocks.evaluatorExpansion, +})) +vi.mock("@/hooks/use-trusted-evaluators", () => ({ + useTrustedEvaluators: () => ({ evaluatorDids: [], isLoading: false }), +})) + +let HomeFeed: ComponentType<{ activeDid: string }> + +beforeAll(async () => { + vi.stubEnv("NEXT_PUBLIC_HOME_FEED_SOURCE", "service") + HomeFeed = (await import("../home-feed")).default +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +afterAll(() => { + vi.unstubAllEnvs() +}) + +describe("HomeFeed service source isolation", () => { + it("mounts only the service hook graph", () => { + render() + + expect(screen.getByText("No activity yet")).toBeTruthy() + expect(mocks.service).toHaveBeenCalledOnce() + expect(mocks.legacy).not.toHaveBeenCalled() + expect(mocks.following).not.toHaveBeenCalled() + expect(mocks.evaluatorExpansion).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/home/home-feed-rows.tsx b/src/components/home/home-feed-rows.tsx index 50bc384e..19a953c9 100644 --- a/src/components/home/home-feed-rows.tsx +++ b/src/components/home/home-feed-rows.tsx @@ -6,36 +6,25 @@ 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 { + ActivityHomeFeedView, + CollectionHomeFeedView, + HomeFeedActor, + HomeFeedEvent, + SimpleHomeFeedView, +} 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 { formatRelativeTime } from "@/lib/atproto/activity" import { parseAtUri } from "@/lib/atproto/activity-uri" -import { formatTimePeriod } from "@/lib/utils/format-date" +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 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 @@ -50,34 +39,46 @@ import { TYPED_LIST_TYPES, type TypedListType } from "@/lib/atproto/typed-lists" * scope. Prefer its data; treat the indexer's actorProfile as a * first-paint hint when present. */ -function FeedCardHead({ - actor, - actorProfile, - action, - createdAt, -}: { +interface FeedCardHeadProps { actor: string - actorProfile: FeedActor + actorProfile: HomeFeedActor 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, +} + +function FeedCardHead(props: FeedCardHeadProps) { + return props.actorProfile.complete ? ( + + ) : ( + ) - const profileHref = profileUrl(actorHandle || actor) +} + +function LegacyFeedCardHead(props: FeedCardHeadProps) { + const { info } = useAuthorInfo(props.actor) + return ( + + ) +} +function FeedCardHeadView({ + actor, + actorProfile, + action, + createdAt, +}: FeedCardHeadProps) { + const actorName = + actorProfile.displayName || actorProfile.handle || actor.slice(0, 16) + const actorInitials = getInitials(actorProfile.displayName, actorProfile.handle) + const profileHref = profileUrl(actorProfile.handle || actor) return (
- {/* Row 1: display name + relative time pinned right. The - @handle gets its own second row below the name. */}

{actorName} -

- {actorHandle ? ( -

@{actorHandle}

+ {actorProfile.handle ? ( +

@{actorProfile.handle}

) : null}

{action}

@@ -116,10 +111,8 @@ function FeedCardHead({ ) } -// 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. +// Appending a page preserves existing event object identities, so +// unchanged rows can skip reconciliation on long feeds. export const HomeFeedRow = memo(function HomeFeedRow({ event, }: { @@ -134,29 +127,21 @@ export const HomeFeedRow = memo(function HomeFeedRow({ 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. */ +/** Expanded endorsement groups reveal one window at a time so a large + * batch does not mount hundreds of identity rows in one commit. */ const GROUP_EXPAND_PAGE = 50 /** @@ -165,16 +150,15 @@ const GROUP_EXPAND_PAGE = 50 * * 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 + * mixed single + grouped rows. The first subject is the most recent * endorsement in the burst. */ export const EndorsementGroupRow = memo( function EndorsementGroupRow({ group }: { group: EndorsementGroupItem }) { - const othersCount = group.subjectDids.length - 1 + const othersCount = group.subjects.length - 1 const [expanded, setExpanded] = useState(false) const [visibleCount, setVisibleCount] = useState(GROUP_EXPAND_PAGE) - const remaining = group.subjectDids.length - visibleCount + const remaining = group.subjects.length - visibleCount return (
@@ -186,7 +170,7 @@ export const EndorsementGroupRow = memo( <> endorsed{" "} @@ -198,21 +182,24 @@ export const EndorsementGroupRow = memo( pressed={expanded} aria-expanded={expanded} className="home-feed__group-toggle" - onClick={() => { - // Collapse resets the window so re-expanding starts at - // one page again. - if (expanded) setVisibleCount(GROUP_EXPAND_PAGE) - setExpanded(!expanded) - }} + onClick={() => + setExpanded((current) => { + if (current) setVisibleCount(GROUP_EXPAND_PAGE) + return !current + }) + } > {expanded ? "Show fewer" : "Show all"} {expanded ? ( <>
    - {group.subjectDids.slice(0, visibleCount).map((did) => ( -
  • - + {group.subjects.slice(0, visibleCount).map((subject, index) => ( +
  • +
  • ))}
@@ -221,7 +208,9 @@ export const EndorsementGroupRow = memo( variant="ghost" size="sm" className="home-feed__group-toggle" - onClick={() => setVisibleCount((c) => c + GROUP_EXPAND_PAGE)} + onClick={() => + setVisibleCount((count) => count + GROUP_EXPAND_PAGE) + } > Show more ({remaining} remaining) @@ -231,32 +220,70 @@ export const EndorsementGroupRow = memo(
) }, - // 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]), + (previous, next) => + previous.group.key === next.group.key && + previous.group.actor === next.group.actor && + previous.group.createdAt === next.group.createdAt && + previous.group.actorProfile === next.group.actorProfile && + previous.group.subjects.length === next.group.subjects.length && + previous.group.subjects.every((subject, index) => { + const candidate = next.group.subjects[index] + return ( + subject.did === candidate.did && + subject.handle === candidate.handle && + subject.displayName === candidate.displayName && + subject.avatarUrl === candidate.avatarUrl && + subject.complete === candidate.complete + ) + }), ) function EndorsementGroupSummary({ - firstDid, + first, + othersCount, +}: { + first: HomeFeedActor + othersCount: number +}) { + return first.complete ? ( + + ) : ( + + ) +} + +function LegacyEndorsementGroupSummary({ + first, othersCount, }: { - firstDid: string + first: HomeFeedActor othersCount: number }) { - const { info } = useAuthorInfo(firstDid) - const name = info?.displayName || (info?.handle ? `@${info.handle}` : null) - const href = profileUrl(info?.handle || firstDid) + const { info } = useAuthorInfo(first.did) + return ( + + ) +} + +function EndorsementGroupSummaryView({ + first, + othersCount, +}: { + first: HomeFeedActor + othersCount: number +}) { + const name = first.displayName || (first.handle ? `@${first.handle}` : null) return ( <> - + {name ?? "an account"} {othersCount > 0 ? ( @@ -269,16 +296,36 @@ function EndorsementGroupSummary({ ) } -function EndorsedAccountLink({ did }: { did: string }) { - const { info } = useAuthorInfo(did) - const href = profileUrl(info?.handle || did) +function EndorsedAccountLink({ subject }: { subject: HomeFeedActor }) { + return subject.complete ? ( + + ) : ( + + ) +} + +function LegacyEndorsedAccountLink({ subject }: { subject: HomeFeedActor }) { + const { info } = useAuthorInfo(subject.did) + return ( + + ) +} + +function EndorsedAccountLinkView({ subject }: { subject: HomeFeedActor }) { return ( ) @@ -289,20 +336,20 @@ function EventSentence({ event }: { event: HomeFeedEvent }) { case "cert.create": return <>created an activity case "collection.create": - return + return case "project.created_with_cert": return <>created a project with an activity case "endorsement.award": case "legacy.endorsement": - return + return case "evaluation.create": - return + return case "measurement.create": - return + return case "hyperboard.create": return <>created a hyperboard case "update.create": - return + return case "unknown": // The wire kind was known but hydration didn't return a // payload (or it was genuinely unknown). Recover the verb @@ -458,13 +505,8 @@ function UnhydratedSentence({ rawKind }: { rawKind: string }) { * 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) { +function CollectionSentence({ collectionType }: { collectionType: string | null }) { + switch (collectionType?.toLowerCase()) { case "project": return <>created a project case "list:endorsements": @@ -482,176 +524,107 @@ function CollectionSentence({ record }: { record: CollectionRecord }) { } } -function EndorsementSentence({ subjectDid }: { subjectDid: string }) { - const { info } = useAuthorInfo(subjectDid) - const name = info?.displayName || (info?.handle ? `@${info.handle}` : null) - const href = profileUrl(info?.handle || subjectDid) +function EndorsementSentence({ subject }: { subject: HomeFeedActor }) { + return subject.complete ? ( + + ) : ( + + ) +} + +function LegacyEndorsementSentence({ subject }: { subject: HomeFeedActor }) { + const { info } = useAuthorInfo(subject.did) + return ( + + ) +} + +function EndorsementSentenceView({ subject }: { subject: HomeFeedActor }) { + const name = subject.displayName || (subject.handle ? `@${subject.handle}` : null) 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, + view, uri, - labels, }: { - record: ActivityRecord + view: ActivityHomeFeedView 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 - + const href = parsed ? recordUrl(parsed.did, "activity", parsed.rkey) : null + const period = formatPeriod(view.startDate, view.endDate) return ( 0 ? ( + view.locationCount > 0 ? ( <> - {`${locationCount} location${locationCount === 1 ? "" : "s"}`} + {`${view.locationCount} location${view.locationCount === 1 ? "" : "s"}`} ) : null, - ].filter((m): m is NonNullable => m !== null && m !== undefined)} + ].filter((item): item is NonNullable => item !== null)} /> ) } -// ------------------------------ Collection preview -------------------------- - function CollectionPreview({ - record, + view, uri, }: { - record: CollectionRecord + view: CollectionHomeFeedView 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 collectionType = view.collectionType?.toLowerCase() ?? "project" 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 + ? view.itemCount === 1 ? "endorsement" : "endorsements" - : itemCount === 1 + : view.itemCount === 1 ? "activity" : "activities" - return ( 0 ? `${itemCount} ${itemNoun}` : null, - ].filter((s): s is string => !!s)} + title={view.title || "Untitled project"} + imageUrl={view.imageUrl} + description={view.shortDescription} + meta={view.itemCount > 0 ? [`${view.itemCount} ${itemNoun}`] : []} /> ) } -// ----------------------------- 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 +function UpdatePreview({ view }: { view: SimpleHomeFeedView }) { + const parsed = view.targetUri ? parseAtUri(view.targetUri) : null const href = parsed ? parsed.collection === "org.hypercerts.claim.activity" ? recordUrl(parsed.did, "activity", parsed.rkey) @@ -662,9 +635,9 @@ function UpdatePreview({ return ( ) @@ -698,14 +671,12 @@ function hideBrokenCardImage( 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[] @@ -714,7 +685,7 @@ function PreviewCard({ <> {imageUrl ? ( - {/* eslint-disable-next-line @next/next/no-img-element -- dynamic bsky-CDN/blob card image URL; next/image remotePatterns limited to **.certified.app */} + {/* eslint-disable-next-line @next/next/no-img-element */} {title {title} - {tags?.map((t) => ( - - {t.label} - - ))} {description ? ( {description} @@ -755,3 +721,16 @@ function PreviewCard({ } 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-feed.tsx b/src/components/home/home-feed.tsx index a03559f5..020eb5a3 100644 --- a/src/components/home/home-feed.tsx +++ b/src/components/home/home-feed.tsx @@ -1,51 +1,39 @@ "use client" -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react" +import { memo, useEffect, useMemo, useRef, useState } from "react" 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 { 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 { + useHomeFeed, + useLegacyHomeFeed, + type HomeFeedResult, +} from "@/hooks/use-home-feed" import { groupConsecutiveEndorsements } from "@/lib/utils/group-feed" import { useFollowing } from "@/hooks/use-following" import { hideBrokenThumb } from "@/lib/utils/image-fallback" import { - DEFAULT_HIDDEN_CERT_LABELS, DEFAULT_HIDDEN_ORG_LABELS, - HYPERLABEL_DISPLAY_LABELS, - HYPERLABEL_DISPLAY_ORDER, - HYPERLABEL_TIERS, ORGLABEL_TIERS, - type HyperlabelTier, type OrglabelTier, } from "@/lib/atproto/labels" import { fetchOrgDidsByLabel } from "@/lib/atproto/workspace" import { useTrustedEvaluators } from "@/hooks/use-trusted-evaluators" +import { + parseHomeFeedSource, + type OrganizationQuality, +} from "@/lib/atproto/certified-feed" import { EndorsementGroupRow, HomeFeedRow } from "./home-feed-rows" -const DEFAULT_INCLUDED_TIERS: ReadonlySet = new Set( - HYPERLABEL_TIERS.filter( - (t) => !DEFAULT_HIDDEN_CERT_LABELS.includes(t), - ), -) - -/** Sentinel for the "Not labeled yet" checkbox — separate from the - * Hyperlabel tier enum so the popover state can carry it without - * widening the tier type. Mirrors the explore-page convention. */ +const HOME_FEED_SOURCE = parseHomeFeedSource() const UNLABELED_SLUG = "unlabeled" as const type UnlabeledSlug = typeof UNLABELED_SLUG -type QualityFilterValue = HyperlabelTier | UnlabeledSlug type OrgQualityValue = OrglabelTier | UnlabeledSlug /** Default org-quality set — everything except the labels in @@ -101,147 +89,34 @@ const MAX_AUTO_LOADS = 25 * the viewport. */ export default function HomeFeed({ activeDid }: { activeDid: string }) { - // Home feed reads ONLY the Certified follow graph - // (`app.certified.graph.follow`). Viewers who want their Bluesky - // follows reflected here run the social-graph sync in Settings, - // which mirrors their Bluesky graph into the Certified collection - // once — after that, the Certified graph is the canonical source - // and the home feed reads from a single place instead of merging - // both live every page load. - const { - subjects: followedDids, - isLoading: followsLoading, - error: followsError, - } = useFollowing(activeDid) - // Default state has every visible Hyperlabel tier checked AND - // "Not labeled yet" checked — same default as the explore page so - // a viewer who hasn't touched the filter sees the same set of certs - // here and on /explore. - const [includedTiers, setIncludedTiers] = useState>( - () => new Set([...DEFAULT_INCLUDED_TIERS, UNLABELED_SLUG]), - ) - // Trusted evaluators are sourced live from the curated list; the - // hardcoded set is the fallback used until it resolves. - const { evaluatorDids } = useTrustedEvaluators() - // Evaluator selection: `null` = default (every current list member - // checked), a Set once the viewer customizes. Derived rather than - // stored so it tracks the live list as it resolves and is edited — - // no state-sync effect needed. - const [customEvaluators, setCustomEvaluators] = useState | null>( - null, - ) + const { evaluatorDids, isLoading: evaluatorsLoading } = useTrustedEvaluators() + const [customEvaluators, setCustomEvaluators] = useState | null>(null) const selectedEvaluators = useMemo( () => customEvaluators ?? new Set(evaluatorDids), [customEvaluators, evaluatorDids], ) - const handleEvaluatorsChange = useCallback( - (next: Set) => setCustomEvaluators(next), - [], - ) - // Organization-quality filter (Orglabeler tiers) — applied to the - // event ACTOR rather than the record, by adjusting the author DID - // set handed to useHomeFeed (see effectiveFollows below). const [includedOrgTiers, setIncludedOrgTiers] = useState>( () => new Set([...DEFAULT_INCLUDED_ORG_TIERS, UNLABELED_SLUG]), ) - // Inline filter panel under the "For you" tab (certs.social pattern: - // clicking the active tab toggles the disclosure). const [filterOpen, setFilterOpen] = useState(false) const filterWrapRef = useRef(null) useClickOutsideClose(filterOpen, filterWrapRef, () => setFilterOpen(false)) useEffect(() => { if (!filterOpen) return - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setFilterOpen(false) + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setFilterOpen(false) } document.addEventListener("keydown", onKey) return () => document.removeEventListener("keydown", onKey) }, [filterOpen]) - // Two filter modes, mirroring the explore page (see the - // `certIncludeUnlabeled` comment block there for the full rationale): - // - Unlabeled INCLUDED → use `excludeCertLabels` (drop specific - // tiers; unlabeled records pass because they have nothing to - // match the exclude list against). Default mode. - // - Unlabeled EXCLUDED → use `includeCertLabels` (only records - // carrying one of the checked tiers pass; unlabeled records - // don't qualify). - // Only one is non-undefined at a time. The hydration query treats - // null on either side as "no filter on that axis". - const includeUnlabeled = includedTiers.has(UNLABELED_SLUG) - const excludeCertLabels = useMemo( - () => - includeUnlabeled - ? HYPERLABEL_TIERS.filter((t) => !includedTiers.has(t)) - : undefined, - [includedTiers, includeUnlabeled], - ) - const includeCertLabels = useMemo( - () => - includeUnlabeled - ? undefined - : HYPERLABEL_TIERS.filter((t) => includedTiers.has(t)), - [includedTiers, includeUnlabeled], - ) - const { endorsedDids, isLoading: endorsementsLoading } = - useEvaluatorEndorsements(selectedEvaluators) - const orgFilter = useOrgQualityFilter(includedOrgTiers) - // Direct follows ∪ DIDs endorsed by any selected trusted evaluator, - // then narrowed by the organization-quality filter. The viewer's own - // DID is excluded so the feed doesn't show the viewer's own activity - // (matches the prior behaviour — direct follows never include self). - const effectiveFollows = useMemo(() => { - const out = new Set(followedDids) - for (const did of endorsedDids) { - if (did !== activeDid) out.add(did) - } - if (orgFilter.dids) { - if (orgFilter.mode === "exclude") { - // Unlabeled INCLUDED: drop actors carrying an excluded tier. - for (const did of orgFilter.dids) out.delete(did) - } else if (orgFilter.mode === "include-only") { - // Unlabeled EXCLUDED: keep only actors carrying a checked tier. - for (const did of [...out]) { - if (!orgFilter.dids.has(did)) out.delete(did) - } - } - } - return out - }, [followedDids, endorsedDids, activeDid, orgFilter.mode, orgFilter.dids]) - // Gate the feed fetch on every author source being resolved (follows, - // evaluator endorsements, org-label DID set) so the feed renders in a - // single frame instead of flashing direct-follows first and then a - // second pass with the narrowed union. All three fetches cache at - // module scope, so after the first /home visit `ready` flips - // effectively-synchronously — only the cold first paint waits. - const ready = !followsLoading && !endorsementsLoading && !orgFilter.isLoading - const { events, isLoading, isLoadingMore, hasMore, loadMore, error } = - useHomeFeed(effectiveFollows, { - excludeCertLabels, - includeCertLabels, - ready, - }) - // "For you" flips to "Custom" once any filter diverges from the - // defaults — same affordance certs.social uses on its feed tabs. const isDefaultFilters = - isQualityDefault(includedTiers) && isOrgQualityDefault(includedOrgTiers) && - selectedEvaluators.size === evaluatorDids.length - - const resetFilters = () => { - setIncludedTiers( - new Set([...DEFAULT_INCLUDED_TIERS, UNLABELED_SLUG]), - ) - setIncludedOrgTiers( - new Set([...DEFAULT_INCLUDED_ORG_TIERS, UNLABELED_SLUG]), - ) - setCustomEvaluators(null) - } + selectedEvaluators.size === evaluatorDids.length && + evaluatorDids.every((did) => selectedEvaluators.has(did)) return ( <> - {/* certs.social-style tab strip. One tab for now ("For you"); - clicking the active tab toggles the inline filter panel. */}
@@ -252,7 +127,7 @@ export default function HomeFeed({ activeDid }: { activeDid: string }) { aria-selected="true" aria-haspopup="dialog" aria-expanded={filterOpen} - onClick={() => setFilterOpen((v) => !v)} + onClick={() => setFilterOpen((value) => !value)} > {isDefaultFilters ? "For you" : "Custom"} { + setIncludedOrgTiers( + new Set([ + ...DEFAULT_INCLUDED_ORG_TIERS, + UNLABELED_SLUG, + ]), + ) + setCustomEvaluators(null) + }} /> ) : null}
- + {HOME_FEED_SOURCE === "service" ? ( + + ) : ( + + )} ) } -// 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, - isLoading, - error, - events, - hasMore, - isLoadingMore, - loadMore, +function ServiceHomeFeed({ + activeDid, + selectedEvaluators, + evaluatorsLoading, + includedOrgTiers, }: { - followsLoading: boolean - followsError: boolean - followedCount: number - isLoading: boolean - error: string | null - events: HomeFeedEvent[] - hasMore: boolean - isLoadingMore: boolean - loadMore: () => void + activeDid: string + selectedEvaluators: Set + evaluatorsLoading: boolean + includedOrgTiers: Set +}) { + const organizationQuality = useMemo( + () => ({ + allowed: ORGLABEL_TIERS.filter((tier) => includedOrgTiers.has(tier)) as OrganizationQuality[], + includeUnrated: includedOrgTiers.has(UNLABELED_SLUG), + }), + [includedOrgTiers], + ) + const result = useHomeFeed(activeDid, { + trustedEvaluators: [...selectedEvaluators], + organizationQuality, + ready: !evaluatorsLoading, + }) + return +} + +function LegacyHomeFeed({ + activeDid, + selectedEvaluators, + includedOrgTiers, +}: { + activeDid: string + selectedEvaluators: Set + includedOrgTiers: Set }) { - // Hooks at the top, before any early return — rules-of-hooks - // requires identical hook ordering on every render. The branches - // below all bail before render but the hooks above run regardless. - const items = useMemo( - () => groupConsecutiveEndorsements(events), - [events], + const { + subjects: followedDids, + isLoading: followsLoading, + error: followsError, + } = useFollowing(activeDid) + const { endorsedDids, isLoading: endorsementsLoading } = + useEvaluatorEndorsements(selectedEvaluators) + const orgFilter = useOrgQualityFilter(includedOrgTiers) + const effectiveFollows = useMemo(() => { + const next = new Set(followedDids) + for (const did of endorsedDids) if (did !== activeDid) next.add(did) + if (orgFilter.dids) { + if (orgFilter.mode === "exclude") { + for (const did of orgFilter.dids) next.delete(did) + } else if (orgFilter.mode === "include-only") { + for (const did of next) if (!orgFilter.dids.has(did)) next.delete(did) + } + } + return next + }, [followedDids, endorsedDids, activeDid, orgFilter.mode, orgFilter.dids]) + const result = useLegacyHomeFeed(effectiveFollows, { + ready: !followsLoading && !endorsementsLoading && !orgFilter.isLoading, + }) + return ( + ) +} - // Auto-load-more when grouping collapses a page into too few - // visible rows. The indexer pages by event count (PAGE_SIZE = 25 - // in useHomeFeed); a burst of 50+ endorsements by one user - // becomes a single grouped row, leaving the screen feeling - // empty. Trigger a follow-up loadMore when the visible-item - // count is below MIN_VISIBLE_ITEMS, until that's no longer true - // OR we've made MAX_AUTO_LOADS consecutive auto-fetches (cap - // so a run of 1000+ same-actor endorsements doesn't fan out - // dozens of requests). +export const HomeFeedBody = memo(function HomeFeedBody({ + events, + isLoading, + isLoadingMore, + hasMore, + error, + continuationError, + retryAt, + canAutoLoad, + requestKey, + retryInitial, + loadMore, + scopeLoading = false, + scopeError = false, + scopeEmpty = false, +}: HomeFeedResult & { + scopeLoading?: boolean + scopeError?: boolean + scopeEmpty?: boolean +}) { + const items = useMemo(() => groupConsecutiveEndorsements(events), [events]) const autoLoadAttemptsRef = useRef(0) useEffect(() => { - if (!hasMore || isLoading || isLoadingMore) return + autoLoadAttemptsRef.current = 0 + }, [requestKey]) + useEffect(() => { + if (!canAutoLoad || !hasMore || isLoading || isLoadingMore) return if (items.length >= MIN_VISIBLE_ITEMS) { autoLoadAttemptsRef.current = 0 return @@ -345,23 +278,28 @@ const HomeFeedBody = memo(function HomeFeedBody({ if (autoLoadAttemptsRef.current >= MAX_AUTO_LOADS) return autoLoadAttemptsRef.current++ loadMore() - }, [items.length, hasMore, isLoading, isLoadingMore, loadMore]) + }, [items.length, canAutoLoad, hasMore, isLoading, isLoadingMore, loadMore]) + + const [retryClock, setRetryClock] = useState(0) + useEffect(() => { + if (retryAt === null) return + const delay = Math.max(0, retryAt - Date.now()) + const timer = window.setTimeout(() => setRetryClock(retryAt), delay) + return () => window.clearTimeout(timer) + }, [retryAt]) + const retryBlocked = retryAt !== null && retryClock < retryAt - if (followsLoading || isLoading) { + if (scopeLoading || isLoading) { return (
) } - if (followsError) { - return ( - - Could not load your follow list. Please try again later. - - ) + if (scopeError) { + return Could not load your follow list. Try again later. } - if (followedCount === 0) { + if (scopeEmpty) { return ( - Could not load activity: {error} + +

{error}

+ +
+ ) + } + if (continuationError && events.length === 0) { + return ( + +

{continuationError}

+
) } if (events.length === 0) { return ( - + <> + + {hasMore || isLoadingMore ? ( +
+ +
+ ) : null} + ) } @@ -402,7 +379,20 @@ const HomeFeedBody = memo(function HomeFeedBody({ ), )} - {hasMore || isLoadingMore ? ( + {continuationError ? ( + +

{continuationError}

+ +
+ ) : hasMore || isLoadingMore ? ( ): boolean { - // Default = every tier in DEFAULT_INCLUDED_TIERS, plus unlabeled. - if (included.size !== DEFAULT_INCLUDED_TIERS.size + 1) return false - if (!included.has(UNLABELED_SLUG)) return false - for (const t of DEFAULT_INCLUDED_TIERS) if (!included.has(t)) return false - return true -} - function isOrgQualityDefault(included: Set): boolean { if (included.size !== DEFAULT_INCLUDED_ORG_TIERS.size + 1) return false if (!included.has(UNLABELED_SLUG)) return false @@ -483,19 +465,11 @@ function useOrgQualityFilter(included: Set): { return { mode, dids: fresh ? state.dids : null, isLoading: !fresh } } -/** - * Inline filter panel under the "For you" tab — the certs.social - * evaluator-panel pattern (`.feed-evaluator-panel` / `.feed-evaluators` - * styles), extended with three sections: trusted evaluators, activity - * quality (Hyperlabel tiers) and organization quality (Orglabeler - * tiers), plus a reset row. - */ +/** Ephemeral request filters supported by the hydrated service contract. */ function FeedFilterPanel({ evaluatorDids, selectedEvaluators, onEvaluatorsChange, - includedTiers, - onTiersChange, includedOrgTiers, onOrgTiersChange, isDefault, @@ -504,8 +478,6 @@ function FeedFilterPanel({ evaluatorDids: string[] selectedEvaluators: Set onEvaluatorsChange: (next: Set) => void - includedTiers: Set - onTiersChange: (next: Set) => void includedOrgTiers: Set onOrgTiersChange: (next: Set) => void isDefault: boolean @@ -540,23 +512,6 @@ function FeedFilterPanel({ ))}