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-consumption/plan.md b/docs/feed-service-consumption/plan.md new file mode 100644 index 00000000..c5eddda4 --- /dev/null +++ b/docs/feed-service-consumption/plan.md @@ -0,0 +1,41 @@ +# Feed service consumption update + +## Goal + +Make the Certified app consume the current `http/configurable-cors` feed-service contract without spreading service-specific wire types through the home-feed UI. + +## Main API / ownership + +```ts +fetchCertifiedFeed(input: HomeFeedRequest): Promise +// adapter emits { feedId, params: { $type, viewerDid, ... }, limit, cursor } +// adapter normalizes { feed, cursor? } into the app-owned page model +``` + +- `src/lib/atproto/certified-feed.ts` owns wire request construction, response validation, and normalization. +- `src/hooks/use-home-feed.ts` maps normalized items into the existing `HomeFeedEvent` model. +- Existing home-feed components remain unchanged unless nullable timestamps require a minimal rendering adjustment. + +## Alternatives + +- Keep the current UI model and translate at the boundary (chosen: smallest surface and preserves rollback hook). +- Propagate `feed/view/content` through UI components (drop: unnecessary coupling). +- Support both old and new wire contracts (drop: beta service has no required compatibility window). + +## Acceptance criteria + +- Requests include the feed ID and typed nested params; pagination remains top-level. +- Responses parse `feed` entries with URI-only subjects and nested Certified views. +- Current activity, collection, endorsement, evaluation, measurement, hyperboard, and update cards map correctly. +- Missing optional wire fields are safe; source CID/feed timestamp are not assumed. +- CORS remains credentialless and requires no app proxy/configuration. +- Adapter and hook regression tests fail before implementation and pass afterward. + +## Out of scope / rollback + +- No service-repository changes, CORS policy changes, activity-quality UI, or new feed UI. +- Roll back by reverting the app adapter/hook commit; `NEXT_PUBLIC_HOME_FEED_SOURCE=indexer` remains available. + +## Open question + +- If the service rebase adds `activityQuality`, model it as an optional top-level request field in a follow-up or include it only when the app exposes that filter. diff --git a/docs/feed-service-consumption/review-round-1.md b/docs/feed-service-consumption/review-round-1.md new file mode 100644 index 00000000..c4bd3f47 --- /dev/null +++ b/docs/feed-service-consumption/review-round-1.md @@ -0,0 +1,13 @@ +# Review round 1 + +## Reviewer status + +Two independent read-only reviewer runs were requested for contract correctness and test coverage. Both timed out after 120 seconds and produced no findings. No reviewer approval is claimed. + +## Manual decisions + +- **Keep boundary adapter:** the current home-feed UI model is already stable and the wire change is isolated to the transport adapter plus its mapper. +- **Update request and response together:** the old flat request and `items` response cannot interoperate with the current service contract. +- **Preserve nullable internal normalization:** the service omits optional fields and no longer exposes source feed timestamps/CIDs. +- **Do not add CORS configuration:** the committed service HEAD uses wildcard credentialless CORS and the app already uses `credentials: omit`. +- **Defer activity-quality support:** it is not part of committed `51594fb` and is not exposed by this app's current UI. 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..698a124b 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,7 @@ 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" +import type { ActivityHomeFeedView } from "@/hooks/use-home-feed" // bug-010: the feed PreviewCard's MapPin was gated on // `i === 0 && withLocationIcon && i === meta.length - 1`, which is only @@ -20,24 +20,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, + } satisfies ActivityHomeFeedView + + 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..f6bcf956 --- /dev/null +++ b/src/components/home/__tests__/home-feed-service-render.test.tsx @@ -0,0 +1,418 @@ +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, +} +function serviceItem( + kind: string, + rkey: string, + content: CertifiedFeedView, + collection = "org.hypercerts.claim.activity", +): CertifiedFeedItem { + const uri = `at://${issuer.did}/${collection}/${rkey}` + return { + subject: uri, + view: { + $type: "app.certified.feed.beta.defs#certifiedFeedView", + kind, + actor: serviceActor, + content, + }, + } +} + +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..ed56ecf7 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({ +interface FeedCardHeadProps { + actor: string + actorProfile: HomeFeedActor + action: ReactNode + createdAt: string | null +} + +function FeedCardHead(props: FeedCardHeadProps) { + return props.actorProfile.complete ? ( + + ) : ( + + ) +} + +function useResolvedLegacyActor(actor: HomeFeedActor): HomeFeedActor { + const { info } = useAuthorInfo(actor.did) + return { + ...actor, + handle: info?.handle || actor.handle || null, + displayName: info?.displayName || actor.displayName || null, + avatarUrl: info?.avatarUrl || actor.avatarUrl || null, + } +} + +function LegacyFeedCardHead(props: FeedCardHeadProps) { + const actorProfile = useResolvedLegacyActor(props.actorProfile) + return +} + +function FeedCardHeadView({ actor, actorProfile, action, createdAt, -}: { - actor: string - actorProfile: FeedActor - action: ReactNode - createdAt: string -}) { - const { info: lookup } = useAuthorInfo(actor) +}: FeedCardHeadProps) { const actorName = - lookup?.displayName || - actorProfile.displayName || - lookup?.handle || - actorProfile.handle || - actor.slice(0, 16) - const actorHandle = lookup?.handle || actorProfile.handle || null - const actorAvatar = - lookup?.avatarUrl || - buildAvatarUrlFromCid(actorProfile.did, actorProfile.avatarCid) - const actorInitials = getInitials( - lookup?.displayName ?? actorProfile.displayName, - actorHandle, - ) - const profileHref = profileUrl(actorHandle || actor) - + 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} - + {createdAt ? ( + + ) : null}

- {actorHandle ? ( -

@{actorHandle}

+ {actorProfile.handle ? ( +

@{actorProfile.handle}

) : null}

{action}

@@ -116,10 +113,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 +129,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 +152,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 +172,7 @@ export const EndorsementGroupRow = memo( <> endorsed{" "} @@ -199,10 +185,8 @@ export const EndorsementGroupRow = memo( 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) + setVisibleCount(GROUP_EXPAND_PAGE) + setExpanded((current) => !current) }} > {expanded ? "Show fewer" : "Show all"} @@ -210,9 +194,12 @@ export const EndorsementGroupRow = memo( {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,65 @@ 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, }: { - 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) + return first.complete ? ( + + ) : ( + + ) +} + +function LegacyEndorsementGroupSummary({ + first, + othersCount, +}: { + first: HomeFeedActor + othersCount: number +}) { + const resolvedFirst = useResolvedLegacyActor(first) + 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 +291,27 @@ 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 resolvedSubject = useResolvedLegacyActor(subject) + return +} + +function EndorsedAccountLinkView({ subject }: { subject: HomeFeedActor }) { return ( ) @@ -289,20 +322,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 +491,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 +510,98 @@ 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 resolvedSubject = useResolvedLegacyActor(subject) + 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 +612,9 @@ function UpdatePreview({ return ( ) @@ -698,14 +648,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 +662,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 +698,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..5b96d406 100644 --- a/src/components/home/home-feed.tsx +++ b/src/components/home/home-feed.tsx @@ -1,51 +1,36 @@ "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 } 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 +86,26 @@ 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) - } - 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 +116,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 }) { - // 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 organizationQuality = useMemo( + () => ({ + allowed: ORGLABEL_TIERS.filter((tier) => includedOrgTiers.has(tier)), + includeUnrated: includedOrgTiers.has(UNLABELED_SLUG), + }), + [includedOrgTiers], ) + const result = useHomeFeed(activeDid, { + trustedEvaluators: [...selectedEvaluators], + organizationQuality, + ready: !evaluatorsLoading, + }) + 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). +function LegacyHomeFeed({ + activeDid, + selectedEvaluators, + includedOrgTiers, +}: { + activeDid: string + selectedEvaluators: Set + includedOrgTiers: Set +}) { + 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 ( + + ) +} + +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 +267,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 +368,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 +454,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 +467,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 +501,6 @@ function FeedFilterPanel({ ))}