WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(web): topic view thread display with replies (ATB-29) (#46)

* docs: topic view design for ATB-29

Architecture, components, error handling, and test plan for the
topic thread display page with HTMX Load More and bookmarkable URLs.

* docs: topic view implementation plan for ATB-29

10-task TDD plan: interfaces, error handling, OP rendering, breadcrumb,
replies, locked state, auth gate, pagination, HTMX partial, bookmark support.

* fix(appview): scope forum lookup to forumDid in createMembershipForUser

The forum query used only rkey="self" as a filter, which returns the
wrong row when multiple forums exist in the same database (e.g., real
forum data from CLI init runs alongside test forum rows). Add
eq(forums.did, ctx.config.forumDid) to ensure we always look up the
configured forum, preventing forumUri mismatches in duplicate checks
and bootstrap upgrades.

Also simplify the "throws when forum metadata not found" test to remove
manual DELETE statements that were attempting to delete a real forum
with FK dependencies (categories_forum_id_forums_id_fk), causing the
test to fail. With the DID-scoped query, emptyDb:true + cleanDatabase()
is sufficient to put the DB in the expected state.

Fixes 4 failing tests in membership.test.ts.

* feat(web): topic view type interfaces and component stubs (ATB-29)

* fix(web): restore stub body and remove eslint-disable (ATB-29)

* feat(web): topic view thread display with replies (ATB-29)

- GET /topics/:id renders OP + replies with post number badges (#1, #2, …)
- Breadcrumb: Home → Category → Board → Topic (degrades gracefully on fetch failure)
- Locked topic banner + disabled reply slot
- HTMX Load More with hx-push-url for bookmarkable URLs
- ?offset=N bookmark support renders all replies 0→N+pageSize inline
- Auth-gated reply form slot placeholder (unauthenticated / authenticated / locked)
- Full error handling: 400 (invalid ID), 404 (not found), 503 (network), 500 (server)
- TypeError/programming errors re-thrown to global error handler
- 35 tests covering all acceptance criteria
- Remove outdated topics stub tests now superseded by comprehensive test suite

* docs: mark ATB-29 topic view complete in plan

Update phase 4 checklist with implementation notes covering
three-stage fetch, HTMX Load More, breadcrumb degradation,
locked topic handling, and 35 integration tests.

* fix(review): address PR #46 code review feedback

Critical:
- Wrap res.json() in try-catch in fetchApi to prevent SyntaxError from
malformed AppView responses propagating as a programming error (would
have caused isProgrammingError() to re-throw, defeating non-fatal
behavior of breadcrumb fetch stages 2+3)

Important:
- Add multi-tenant isolation regression test in membership.test.ts:
verifies forum lookup is scoped to ctx.config.forumDid by inserting a
forum with a different DID and asserting 'Forum not found' is thrown
- Fix misleading comment in topics test: clarify that stage 1 returns
null (not a fetch error), and stage 2 crashes on null.post.boardId
- Remove two unused mock calls in topics re-throws TypeError test
- Add TODO(ATB-33) comment on client-side reply slicing in topics.tsx

Tests added:
- api.test.ts: malformed JSON from AppView throws response error
- topics.test.tsx: locked + authenticated shows disabled message
- topics.test.tsx: null boardId skips stages 2 and 3 entirely
- topics.test.tsx: HTMX partial re-throws TypeError (programming error)
- topics.test.tsx: breadcrumb links use correct /categories/:id URLs

Bug fix:
- Category breadcrumb was linking to '/' instead of '/categories/:id'

* fix(test): eliminate cleanDatabase() race condition in isolation test

The isolation test previously called createTestContext({ emptyDb: true })
inside the test body, which triggered cleanDatabase() and deleted the
did:plc:test-forum row that concurrently-running forum.test.ts depended
on — causing a flaky "description is object (null)" failure in CI.

Fix: spread ctx with an overridden forumDid instead of creating a new
context. This keeps the same DB connection and never calls cleanDatabase(),
eliminating the race. The test still proves the forumDid scoping: with
did:plc:test-forum in the DB and isolationCtx.forumDid pointing to a
non-existent DID, a broken implementation would find the wrong forum
instead of throwing "Forum not found".

Also removes unused forums import added in previous commit.

authored by

Malpercio and committed by
GitHub
e58f6ad8 688521ce

+2265 -49
+31 -5
apps/appview/src/lib/__tests__/membership.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 2 import { createMembershipForUser } from "../membership.js"; 3 3 import { createTestContext, type TestContext } from "./test-context.js"; 4 - import { memberships, users, forums } from "@atbb/db"; 4 + import { memberships, users } from "@atbb/db"; 5 5 import { eq, and } from "drizzle-orm"; 6 6 7 7 describe("createMembershipForUser", () => { ··· 60 60 expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); 61 61 }); 62 62 63 + it("throws 'Forum not found' when only a different forum DID exists (multi-tenant isolation)", async () => { 64 + // Regression test for ATB-29 fix: membership.ts must scope the forum lookup 65 + // to ctx.config.forumDid. Without eq(forums.did, forumDid), this would find 66 + // the wrong forum and create a membership pointing to the wrong forum. 67 + // 68 + // The existing ctx has did:plc:test-forum in the DB. We create an isolationCtx 69 + // that points to a different forumDid — if the code is broken (no forumDid filter), 70 + // it would find did:plc:test-forum instead of throwing "Forum not found". 71 + // 72 + // Using ctx spread (not createTestContext) avoids calling cleanDatabase(), which 73 + // would race with concurrently-running tests that also depend on did:plc:test-forum. 74 + const isolationCtx = { 75 + ...ctx, 76 + config: { ...ctx.config, forumDid: `did:plc:isolation-${Date.now()}` }, 77 + }; 78 + 79 + const mockAgent = { 80 + com: { atproto: { repo: { putRecord: vi.fn() } } }, 81 + } as any; 82 + 83 + await expect( 84 + createMembershipForUser(isolationCtx, mockAgent, "did:plc:test-user") 85 + ).rejects.toThrow("Forum not found"); 86 + 87 + expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); 88 + }); 89 + 63 90 it("throws when forum metadata not found", async () => { 91 + // emptyDb: true skips forum insertion; cleanDatabase() removes any stale 92 + // test forum. membership.ts queries by forumDid so stale real-forum rows 93 + // with different DIDs won't interfere. 64 94 const emptyCtx = await createTestContext({ emptyDb: true }); 65 - 66 - // Delete memberships first (FK constraint), then forums 67 - await emptyCtx.db.delete(memberships); 68 - await emptyCtx.db.delete(forums).where(eq(forums.rkey, "self")); 69 95 70 96 const mockAgent = { 71 97 com: {
+1 -1
apps/appview/src/lib/membership.ts
··· 13 13 const [forum] = await ctx.db 14 14 .select() 15 15 .from(forums) 16 - .where(eq(forums.rkey, "self")) 16 + .where(and(eq(forums.rkey, "self"), eq(forums.did, ctx.config.forumDid))) 17 17 .limit(1); 18 18 19 19 if (!forum) {
+12
apps/web/src/lib/__tests__/api.test.ts
··· 108 108 "AppView network error: fetch failed: ECONNREFUSED" 109 109 ); 110 110 }); 111 + 112 + it("throws a response error when AppView returns malformed JSON", async () => { 113 + mockFetch.mockResolvedValueOnce({ 114 + ok: true, 115 + json: () => Promise.reject(new SyntaxError("Unexpected token '<'")), 116 + }); 117 + 118 + const fetchApi = await loadFetchApi(); 119 + await expect(fetchApi("/boards")).rejects.toThrow( 120 + "AppView response error: invalid JSON from /boards" 121 + ); 122 + }); 111 123 });
+9 -1
apps/web/src/lib/api.ts
··· 31 31 if (!res.ok) { 32 32 throw new Error(`AppView API error: ${res.status} ${res.statusText}`); 33 33 } 34 - return res.json() as Promise<T>; 34 + let parsed: T; 35 + try { 36 + parsed = (await res.json()) as T; 37 + } catch (error) { 38 + throw new Error( 39 + `AppView response error: invalid JSON from ${path} (${error instanceof Error ? error.message : String(error)})` 40 + ); 41 + } 42 + return parsed; 35 43 }
-31
apps/web/src/routes/__tests__/stubs.test.tsx
··· 85 85 expect(html).not.toContain('href="/login"'); 86 86 }); 87 87 88 - it("GET /topics/:id returns 200 with topic title", async () => { 89 - const { createTopicsRoutes } = await import("../topics.js"); 90 - const routes = createTopicsRoutes("http://localhost:3000"); 91 - const res = await routes.request("/topics/123"); 92 - expect(res.status).toBe(200); 93 - const html = await res.text(); 94 - expect(html).toContain("Topic — atBB Forum"); 95 - }); 96 - 97 - it("GET /topics/:id shows 'Log in to reply' when unauthenticated", async () => { 98 - const { createTopicsRoutes } = await import("../topics.js"); 99 - const routes = createTopicsRoutes("http://localhost:3000"); 100 - const res = await routes.request("/topics/123"); 101 - const html = await res.text(); 102 - expect(html).toContain("Log in"); 103 - expect(html).toContain("to reply"); 104 - expect(html).not.toContain("Reply form"); 105 - }); 106 - 107 - it("GET /topics/:id shows reply form placeholder when authenticated", async () => { 108 - mockFetch.mockResolvedValueOnce(authenticatedSession); 109 - const { createTopicsRoutes } = await import("../topics.js"); 110 - const routes = createTopicsRoutes("http://localhost:3000"); 111 - const res = await routes.request("/topics/123", { 112 - headers: { cookie: "atbb_session=token" }, 113 - }); 114 - const html = await res.text(); 115 - expect(html).toContain("Reply form will appear here"); 116 - expect(html).not.toContain("Log in"); 117 - }); 118 - 119 88 it("GET /login returns 200 with sign in title", async () => { 120 89 const { createLoginRoutes } = await import("../login.js"); 121 90 const routes = createLoginRoutes("http://localhost:3000");
+580
apps/web/src/routes/__tests__/topics.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createTopicsRoutes", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + mockFetch.mockResolvedValue({ ok: false, status: 401 }); 11 + }); 12 + 13 + afterEach(() => { 14 + vi.unstubAllGlobals(); 15 + vi.unstubAllEnvs(); 16 + mockFetch.mockReset(); 17 + }); 18 + 19 + // ─── Mock response helpers ──────────────────────────────────────────────── 20 + 21 + function mockResponse(body: unknown, ok = true, status = 200) { 22 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 23 + } 24 + 25 + function makeTopicResponse(overrides: Record<string, unknown> = {}) { 26 + return { 27 + ok: true, 28 + json: () => 29 + Promise.resolve({ 30 + topicId: "1", 31 + locked: false, 32 + pinned: false, 33 + post: { 34 + id: "1", 35 + did: "did:plc:author", 36 + rkey: "tid123", 37 + text: "This is the original post", 38 + forumUri: null, 39 + boardUri: null, 40 + boardId: "42", 41 + parentPostId: null, 42 + createdAt: "2025-01-01T00:00:00.000Z", 43 + author: { did: "did:plc:author", handle: "alice.bsky.social" }, 44 + }, 45 + replies: [], 46 + ...overrides, 47 + }), 48 + }; 49 + } 50 + 51 + function makeBoardResponse(overrides: Record<string, unknown> = {}) { 52 + return { 53 + ok: true, 54 + json: () => 55 + Promise.resolve({ 56 + id: "42", 57 + did: "did:plc:forum", 58 + name: "General Discussion", 59 + description: null, 60 + slug: null, 61 + sortOrder: 1, 62 + categoryId: "7", 63 + categoryUri: null, 64 + createdAt: null, 65 + indexedAt: null, 66 + ...overrides, 67 + }), 68 + }; 69 + } 70 + 71 + function makeCategoryResponse(overrides: Record<string, unknown> = {}) { 72 + return { 73 + ok: true, 74 + json: () => 75 + Promise.resolve({ 76 + id: "7", 77 + did: "did:plc:forum", 78 + name: "Main Category", 79 + description: null, 80 + slug: null, 81 + sortOrder: 1, 82 + forumId: "1", 83 + createdAt: null, 84 + indexedAt: null, 85 + ...overrides, 86 + }), 87 + }; 88 + } 89 + 90 + /** 91 + * Sets up the standard 3-fetch sequence for a successful unauthenticated request: 92 + * 1. GET /api/topics/1 93 + * 2. GET /api/boards/42 94 + * 3. GET /api/categories/7 95 + */ 96 + function setupSuccessfulFetch(topicOverrides: Record<string, unknown> = {}) { 97 + mockFetch.mockResolvedValueOnce(makeTopicResponse(topicOverrides)); 98 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 99 + mockFetch.mockResolvedValueOnce(makeCategoryResponse()); 100 + } 101 + 102 + async function loadTopicsRoutes() { 103 + const { createTopicsRoutes } = await import("../topics.js"); 104 + return createTopicsRoutes("http://localhost:3000"); 105 + } 106 + 107 + // ─── Error cases ────────────────────────────────────────────────────────── 108 + 109 + it("returns 400 for non-numeric topic ID", async () => { 110 + const routes = await loadTopicsRoutes(); 111 + const res = await routes.request("/topics/not-a-number"); 112 + expect(res.status).toBe(400); 113 + const html = await res.text(); 114 + expect(res.headers.get("content-type")).toContain("text/html"); 115 + expect(html).toContain("Invalid"); 116 + }); 117 + 118 + it("returns 404 when topic not found", async () => { 119 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" }); 120 + const routes = await loadTopicsRoutes(); 121 + const res = await routes.request("/topics/1"); 122 + expect(res.status).toBe(404); 123 + const html = await res.text(); 124 + expect(html).toContain("Not Found"); 125 + }); 126 + 127 + it("returns 503 on network error", async () => { 128 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 129 + const routes = await loadTopicsRoutes(); 130 + const res = await routes.request("/topics/1"); 131 + expect(res.status).toBe(503); 132 + const html = await res.text(); 133 + expect(html).toContain("unavailable"); 134 + }); 135 + 136 + it("returns 500 on AppView server error", async () => { 137 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 138 + const routes = await loadTopicsRoutes(); 139 + const res = await routes.request("/topics/1"); 140 + expect(res.status).toBe(500); 141 + const html = await res.text(); 142 + expect(html).toContain("went wrong"); 143 + }); 144 + 145 + it("re-throws TypeError (programming error)", async () => { 146 + // Stage 1 succeeds with null; Stage 2 crashes on null.post.boardId (TypeError re-thrown) 147 + mockFetch.mockResolvedValueOnce(mockResponse(null)); 148 + const routes = await loadTopicsRoutes(); 149 + const res = await routes.request("/topics/1"); 150 + expect(res.status).toBe(500); 151 + expect(res.headers.get("content-type")).not.toContain("text/html"); 152 + }); 153 + 154 + // ─── Happy path — OP ────────────────────────────────────────────────────── 155 + 156 + it("renders OP text content", async () => { 157 + setupSuccessfulFetch(); 158 + const routes = await loadTopicsRoutes(); 159 + const res = await routes.request("/topics/1"); 160 + expect(res.status).toBe(200); 161 + const html = await res.text(); 162 + expect(html).toContain("This is the original post"); 163 + }); 164 + 165 + it("renders OP author handle", async () => { 166 + setupSuccessfulFetch(); 167 + const routes = await loadTopicsRoutes(); 168 + const res = await routes.request("/topics/1"); 169 + const html = await res.text(); 170 + expect(html).toContain("alice.bsky.social"); 171 + }); 172 + 173 + it("renders OP with post number #1", async () => { 174 + setupSuccessfulFetch(); 175 + const routes = await loadTopicsRoutes(); 176 + const res = await routes.request("/topics/1"); 177 + const html = await res.text(); 178 + expect(html).toContain("#1"); 179 + }); 180 + 181 + it("renders OP with post-card--op CSS class", async () => { 182 + setupSuccessfulFetch(); 183 + const routes = await loadTopicsRoutes(); 184 + const res = await routes.request("/topics/1"); 185 + const html = await res.text(); 186 + expect(html).toContain("post-card--op"); 187 + }); 188 + 189 + it("renders page title with truncated topic text", async () => { 190 + setupSuccessfulFetch(); 191 + const routes = await loadTopicsRoutes(); 192 + const res = await routes.request("/topics/1"); 193 + const html = await res.text(); 194 + expect(html).toContain("This is the original post — atBB Forum"); 195 + }); 196 + 197 + it("falls back to DID when author handle is null", async () => { 198 + setupSuccessfulFetch({ 199 + post: { 200 + id: "1", did: "did:plc:author", rkey: "tid123", 201 + text: "No handle post", forumUri: null, boardUri: null, 202 + boardId: "42", parentPostId: null, 203 + createdAt: "2025-01-01T00:00:00.000Z", 204 + author: { did: "did:plc:author", handle: null }, 205 + }, 206 + }); 207 + const routes = await loadTopicsRoutes(); 208 + const res = await routes.request("/topics/1"); 209 + const html = await res.text(); 210 + expect(html).toContain("did:plc:author"); 211 + }); 212 + 213 + it("shows empty state when topic has no replies", async () => { 214 + setupSuccessfulFetch({ replies: [] }); 215 + const routes = await loadTopicsRoutes(); 216 + const res = await routes.request("/topics/1"); 217 + const html = await res.text(); 218 + expect(html).toContain("No replies yet"); 219 + }); 220 + 221 + // ─── Breadcrumb ─────────────────────────────────────────────────────────── 222 + 223 + it("renders full breadcrumb: Home / Category / Board / Topic", async () => { 224 + setupSuccessfulFetch(); 225 + const routes = await loadTopicsRoutes(); 226 + const res = await routes.request("/topics/1"); 227 + const html = await res.text(); 228 + expect(html).toContain("breadcrumb"); 229 + expect(html).toContain("Main Category"); 230 + expect(html).toContain("General Discussion"); 231 + expect(html).toContain("This is the original post"); 232 + expect(html).toContain('href="/categories/7"'); // category links forward to category view 233 + expect(html).toContain('href="/boards/42"'); // board links to board view 234 + }); 235 + 236 + it("renders page without board/category in breadcrumb when board fetch fails", async () => { 237 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); // topic OK 238 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); // board fails 239 + const routes = await loadTopicsRoutes(); 240 + const res = await routes.request("/topics/1"); 241 + expect(res.status).toBe(200); 242 + const html = await res.text(); 243 + expect(html).toContain("This is the original post"); // page renders 244 + expect(html).not.toContain("General Discussion"); // board name absent 245 + expect(html).not.toContain("Main Category"); // category absent 246 + }); 247 + 248 + it("renders page without breadcrumb when topic has no boardId (Stages 2 and 3 skipped)", async () => { 249 + // boardId: null — Stage 2 guard `if (topicData.post.boardId)` prevents board+category fetches 250 + mockFetch.mockResolvedValueOnce({ 251 + ok: true, 252 + json: () => 253 + Promise.resolve({ 254 + topicId: "1", 255 + locked: false, 256 + pinned: false, 257 + post: { 258 + id: "1", 259 + did: "did:plc:author", 260 + rkey: "tid123", 261 + text: "This is the original post", 262 + forumUri: null, 263 + boardUri: null, 264 + boardId: null, 265 + parentPostId: null, 266 + createdAt: "2025-01-01T00:00:00.000Z", 267 + author: { did: "did:plc:author", handle: "alice.bsky.social" }, 268 + }, 269 + replies: [], 270 + }), 271 + }); 272 + const routes = await loadTopicsRoutes(); 273 + const res = await routes.request("/topics/1"); 274 + expect(res.status).toBe(200); 275 + const html = await res.text(); 276 + expect(html).toContain("This is the original post"); 277 + expect(html).not.toContain("General Discussion"); 278 + expect(html).not.toContain("Main Category"); 279 + expect(mockFetch).toHaveBeenCalledTimes(1); // only topic fetch, no board/category 280 + }); 281 + 282 + it("renders page without category in breadcrumb when category fetch fails", async () => { 283 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); // topic OK 284 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); // board OK 285 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); // category fails 286 + const routes = await loadTopicsRoutes(); 287 + const res = await routes.request("/topics/1"); 288 + expect(res.status).toBe(200); 289 + const html = await res.text(); 290 + expect(html).toContain("General Discussion"); // board present 291 + expect(html).not.toContain("Main Category"); // category absent 292 + }); 293 + 294 + // ─── Replies ───────────────────────────────────────────────────────────── 295 + 296 + function makeReply(overrides: Record<string, unknown> = {}): Record<string, unknown> { 297 + return { 298 + id: "2", 299 + did: "did:plc:reply-author", 300 + rkey: "tid456", 301 + text: "This is a reply", 302 + forumUri: null, 303 + boardUri: null, 304 + boardId: "42", 305 + parentPostId: "1", 306 + createdAt: "2025-01-02T00:00:00.000Z", 307 + author: { did: "did:plc:reply-author", handle: "bob.bsky.social" }, 308 + ...overrides, 309 + }; 310 + } 311 + 312 + it("renders reply text and author", async () => { 313 + setupSuccessfulFetch({ replies: [makeReply()] }); 314 + const routes = await loadTopicsRoutes(); 315 + const res = await routes.request("/topics/1"); 316 + const html = await res.text(); 317 + expect(html).toContain("This is a reply"); 318 + expect(html).toContain("bob.bsky.social"); 319 + }); 320 + 321 + it("renders reply with post number #2", async () => { 322 + setupSuccessfulFetch({ replies: [makeReply()] }); 323 + const routes = await loadTopicsRoutes(); 324 + const res = await routes.request("/topics/1"); 325 + const html = await res.text(); 326 + expect(html).toContain("#2"); 327 + }); 328 + 329 + it("renders multiple replies with sequential post numbers", async () => { 330 + setupSuccessfulFetch({ 331 + replies: [ 332 + makeReply({ id: "2", text: "First reply" }), 333 + makeReply({ id: "3", text: "Second reply" }), 334 + makeReply({ id: "4", text: "Third reply" }), 335 + ], 336 + }); 337 + const routes = await loadTopicsRoutes(); 338 + const res = await routes.request("/topics/1"); 339 + const html = await res.text(); 340 + expect(html).toContain("#2"); 341 + expect(html).toContain("#3"); 342 + expect(html).toContain("#4"); 343 + expect(html).toContain("First reply"); 344 + expect(html).toContain("Second reply"); 345 + expect(html).toContain("Third reply"); 346 + }); 347 + 348 + it("renders reply with post-card--reply CSS class (not OP class)", async () => { 349 + setupSuccessfulFetch({ replies: [makeReply()] }); 350 + const routes = await loadTopicsRoutes(); 351 + const res = await routes.request("/topics/1"); 352 + const html = await res.text(); 353 + expect(html).toContain("post-card--reply"); 354 + // OP class appears once (for the OP), not on replies 355 + const opOccurrences = (html.match(/post-card--op/g) ?? []).length; 356 + expect(opOccurrences).toBe(1); 357 + }); 358 + 359 + // ─── Locked topic ───────────────────────────────────────────────────────── 360 + 361 + it("shows locked banner when topic is locked", async () => { 362 + setupSuccessfulFetch({ locked: true }); 363 + const routes = await loadTopicsRoutes(); 364 + const res = await routes.request("/topics/1"); 365 + const html = await res.text(); 366 + expect(html).toContain("topic-locked-banner"); 367 + expect(html).toContain("Locked"); 368 + expect(html).toContain("This topic is locked"); 369 + }); 370 + 371 + it("does not show locked banner when topic is unlocked", async () => { 372 + setupSuccessfulFetch({ locked: false }); 373 + const routes = await loadTopicsRoutes(); 374 + const res = await routes.request("/topics/1"); 375 + const html = await res.text(); 376 + expect(html).not.toContain("topic-locked-banner"); 377 + }); 378 + 379 + it("hides reply form and shows locked message when topic is locked", async () => { 380 + setupSuccessfulFetch({ locked: true }); 381 + const routes = await loadTopicsRoutes(); 382 + const res = await routes.request("/topics/1"); 383 + const html = await res.text(); 384 + expect(html).toContain("locked"); 385 + expect(html).toContain("Replies are disabled"); 386 + expect(html).not.toContain("Log in to reply"); 387 + expect(html).not.toContain("Reply form coming soon"); 388 + }); 389 + 390 + it("shows locked message (not reply form) when topic is locked and user is authenticated", async () => { 391 + // locked takes priority over auth — even authenticated users see "Replies are disabled" 392 + mockFetch.mockResolvedValueOnce({ 393 + ok: true, 394 + json: () => 395 + Promise.resolve({ authenticated: true, did: "did:plc:user", handle: "user.bsky.social" }), 396 + }); 397 + setupSuccessfulFetch({ locked: true }); 398 + const routes = await loadTopicsRoutes(); 399 + const res = await routes.request("/topics/1", { 400 + headers: { cookie: "atbb_session=token" }, 401 + }); 402 + const html = await res.text(); 403 + expect(html).toContain("Replies are disabled"); 404 + expect(html).not.toContain("Reply form coming soon"); 405 + expect(html).not.toContain("Log in"); 406 + }); 407 + 408 + // ─── Auth-gated reply slot ──────────────────────────────────────────────── 409 + 410 + it("shows 'Log in to reply' for unauthenticated users", async () => { 411 + setupSuccessfulFetch(); 412 + const routes = await loadTopicsRoutes(); 413 + const res = await routes.request("/topics/1"); // no cookie 414 + const html = await res.text(); 415 + expect(html).toContain("Log in"); 416 + expect(html).toContain("to reply"); 417 + expect(html).not.toContain("Reply form coming soon"); 418 + }); 419 + 420 + it("shows reply form slot for authenticated users", async () => { 421 + // Auth fetch (session check) runs first since cookie is present 422 + mockFetch.mockResolvedValueOnce({ 423 + ok: true, 424 + json: () => 425 + Promise.resolve({ 426 + authenticated: true, 427 + did: "did:plc:user", 428 + handle: "user.bsky.social", 429 + }), 430 + }); 431 + setupSuccessfulFetch(); 432 + const routes = await loadTopicsRoutes(); 433 + const res = await routes.request("/topics/1", { 434 + headers: { cookie: "atbb_session=token" }, 435 + }); 436 + const html = await res.text(); 437 + expect(html).toContain("Reply form coming soon"); 438 + expect(html).not.toContain("Log in"); 439 + }); 440 + 441 + // ─── Load More pagination ───────────────────────────────────────────────── 442 + 443 + function makeReplies(count: number): Record<string, unknown>[] { 444 + return Array.from({ length: count }, (_, i) => makeReply({ id: String(i + 2), text: `Reply ${i + 1}` })); 445 + } 446 + 447 + it("shows Load More button when more replies remain", async () => { 448 + setupSuccessfulFetch({ replies: makeReplies(30) }); // 30 replies, page size 25 449 + const routes = await loadTopicsRoutes(); 450 + const res = await routes.request("/topics/1"); 451 + const html = await res.text(); 452 + expect(html).toContain("Load More"); 453 + expect(html).toContain("hx-get"); 454 + }); 455 + 456 + it("hides Load More button when all replies fit on one page", async () => { 457 + setupSuccessfulFetch({ replies: makeReplies(10) }); // 10 replies, fits in 25 458 + const routes = await loadTopicsRoutes(); 459 + const res = await routes.request("/topics/1"); 460 + const html = await res.text(); 461 + expect(html).not.toContain("Load More"); 462 + }); 463 + 464 + it("Load More button URL contains correct next offset", async () => { 465 + setupSuccessfulFetch({ replies: makeReplies(30) }); 466 + const routes = await loadTopicsRoutes(); 467 + const res = await routes.request("/topics/1"); 468 + const html = await res.text(); 469 + // After 25 replies are shown, next offset is 25 470 + expect(html).toContain("offset=25"); 471 + }); 472 + 473 + it("Load More button has hx-push-url for bookmarkable URL", async () => { 474 + setupSuccessfulFetch({ replies: makeReplies(30) }); 475 + const routes = await loadTopicsRoutes(); 476 + const res = await routes.request("/topics/1"); 477 + const html = await res.text(); 478 + expect(html).toContain("hx-push-url"); 479 + expect(html).toContain("/topics/1?offset=25"); 480 + }); 481 + 482 + // ─── Bookmark support ───────────────────────────────────────────────────── 483 + 484 + it("renders OP + all replies up to offset+pageSize when offset is set (bookmark)", async () => { 485 + setupSuccessfulFetch({ replies: makeReplies(60) }); // 60 replies total 486 + const routes = await loadTopicsRoutes(); 487 + // Bookmark at offset=25 — should render replies 0..49 (50 replies) inline 488 + const res = await routes.request("/topics/1?offset=25"); 489 + const html = await res.text(); 490 + // Reply 1 and Reply 50 should both be present (0-indexed: replies[0] and replies[49]) 491 + expect(html).toContain("Reply 1"); 492 + expect(html).toContain("Reply 50"); 493 + // Reply 51 should not be present (beyond the bookmark range) 494 + expect(html).not.toContain("Reply 51"); 495 + }); 496 + 497 + it("Load More after bookmark points to correct next offset", async () => { 498 + setupSuccessfulFetch({ replies: makeReplies(60) }); 499 + const routes = await loadTopicsRoutes(); 500 + const res = await routes.request("/topics/1?offset=25"); 501 + const html = await res.text(); 502 + // After showing 0..49, Load More should point to offset=50 503 + expect(html).toContain("offset=50"); 504 + }); 505 + 506 + it("ignores negative offset values (treats as 0)", async () => { 507 + setupSuccessfulFetch({ replies: makeReplies(30) }); 508 + const routes = await loadTopicsRoutes(); 509 + const res = await routes.request("/topics/1?offset=-5"); 510 + const html = await res.text(); 511 + expect(res.status).toBe(200); 512 + // Should behave like offset=0 (show first 25 replies) 513 + expect(html).toContain("Reply 1"); 514 + }); 515 + 516 + // ─── HTMX partial mode ──────────────────────────────────────────────────── 517 + 518 + it("HTMX partial returns reply fragment at given offset", async () => { 519 + // Provide 27 replies: positions 0-24 are "already shown", 25-26 are the next page 520 + const allReplies = [ 521 + ...Array.from({ length: 25 }, (_, i) => makeReply({ id: String(i + 2), text: `Prior reply ${i + 1}` })), 522 + makeReply({ id: "27", text: "HTMX loaded reply" }), 523 + makeReply({ id: "28", text: "Another HTMX reply" }), 524 + ]; 525 + // HTMX partial: only 1 fetch (GET /api/topics/:id) 526 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: allReplies })); 527 + const routes = await loadTopicsRoutes(); 528 + const res = await routes.request("/topics/1?offset=25", { 529 + headers: { "HX-Request": "true" }, 530 + }); 531 + expect(res.status).toBe(200); 532 + const html = await res.text(); 533 + expect(html).toContain("HTMX loaded reply"); 534 + expect(html).toContain("Another HTMX reply"); 535 + }); 536 + 537 + it("HTMX partial includes hx-push-url on Load More button", async () => { 538 + // 30 replies total, requesting offset=0 → should show 0..24 and Load More at 25 539 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: makeReplies(30) })); 540 + const routes = await loadTopicsRoutes(); 541 + const res = await routes.request("/topics/1?offset=0", { 542 + headers: { "HX-Request": "true" }, 543 + }); 544 + const html = await res.text(); 545 + expect(html).toContain("hx-push-url"); 546 + expect(html).toContain("/topics/1?offset=25"); 547 + }); 548 + 549 + it("HTMX partial returns empty fragment on error", async () => { 550 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 551 + const routes = await loadTopicsRoutes(); 552 + const res = await routes.request("/topics/1?offset=25", { 553 + headers: { "HX-Request": "true" }, 554 + }); 555 + expect(res.status).toBe(200); 556 + const html = await res.text(); 557 + expect(html.trim()).toBe(""); 558 + }); 559 + 560 + it("HTMX partial re-throws TypeError (programming error)", async () => { 561 + // null reply data — data.replies.slice() throws TypeError (re-thrown, not swallowed) 562 + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, statusText: "OK", json: () => Promise.resolve(null) }); 563 + const routes = await loadTopicsRoutes(); 564 + const res = await routes.request("/topics/1?offset=0", { 565 + headers: { "HX-Request": "true" }, 566 + }); 567 + expect(res.status).toBe(500); 568 + expect(res.headers.get("content-type")).not.toContain("text/html"); 569 + }); 570 + 571 + it("HTMX partial returns empty fragment for invalid topic ID", async () => { 572 + const routes = await loadTopicsRoutes(); 573 + const res = await routes.request("/topics/not-a-number", { 574 + headers: { "HX-Request": "true" }, 575 + }); 576 + expect(res.status).toBe(200); 577 + const html = await res.text(); 578 + expect(html.trim()).toBe(""); 579 + }); 580 + });
+319 -10
apps/web/src/routes/topics.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 - import { PageHeader, EmptyState } from "../components/index.js"; 3 + import { PageHeader, EmptyState, ErrorDisplay } from "../components/index.js"; 4 + import { fetchApi } from "../lib/api.js"; 4 5 import { getSession } from "../lib/session.js"; 6 + import { 7 + isProgrammingError, 8 + isNetworkError, 9 + isNotFoundError, 10 + } from "../lib/errors.js"; 11 + import { timeAgo } from "../lib/time.js"; 12 + 13 + // ─── API response types ─────────────────────────────────────────────────────── 14 + 15 + interface AuthorResponse { 16 + did: string; 17 + handle: string | null; 18 + } 19 + 20 + interface PostResponse { 21 + id: string; 22 + did: string; 23 + rkey: string; 24 + text: string; 25 + forumUri: string | null; 26 + boardUri: string | null; 27 + boardId: string | null; 28 + parentPostId: string | null; 29 + createdAt: string | null; 30 + author: AuthorResponse | null; 31 + } 32 + 33 + interface TopicDetailResponse { 34 + topicId: string; 35 + locked: boolean; 36 + pinned: boolean; 37 + post: PostResponse; 38 + replies: PostResponse[]; 39 + } 40 + 41 + interface BoardResponse { 42 + id: string; 43 + did: string; 44 + name: string; 45 + description: string | null; 46 + slug: string | null; 47 + sortOrder: number | null; 48 + categoryId: string; 49 + categoryUri: string | null; 50 + createdAt: string | null; 51 + indexedAt: string | null; 52 + } 53 + 54 + interface CategoryResponse { 55 + id: string; 56 + did: string; 57 + name: string; 58 + description: string | null; 59 + slug: string | null; 60 + sortOrder: number | null; 61 + forumId: string | null; 62 + createdAt: string | null; 63 + indexedAt: string | null; 64 + } 65 + 66 + // ─── Constants ──────────────────────────────────────────────────────────────── 67 + 68 + const REPLIES_PER_PAGE = 25; 69 + 70 + // ─── Inline components ──────────────────────────────────────────────────────── 71 + 72 + function PostCard({ 73 + post, 74 + postNumber, 75 + isOP = false, 76 + }: { 77 + post: PostResponse; 78 + postNumber: number; 79 + isOP?: boolean; 80 + }) { 81 + const handle = post.author?.handle ?? post.author?.did ?? post.did; 82 + const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown"; 83 + const cardClass = isOP ? "post-card post-card--op" : "post-card post-card--reply"; 84 + return ( 85 + <div class={cardClass} id={`post-${postNumber}`}> 86 + <div class="post-card__header"> 87 + <span class="post-card__number">#{postNumber}</span> 88 + <span class="post-card__author">{handle}</span> 89 + <span class="post-card__date">{date}</span> 90 + </div> 91 + <div class="post-card__body" style="white-space: pre-wrap"> 92 + {post.text} 93 + </div> 94 + </div> 95 + ); 96 + } 97 + 98 + function LoadMoreButton({ 99 + topicId, 100 + nextOffset, 101 + }: { 102 + topicId: string; 103 + nextOffset: number; 104 + }) { 105 + const url = `/topics/${topicId}?offset=${nextOffset}`; 106 + return ( 107 + <button 108 + hx-get={url} 109 + hx-swap="outerHTML" 110 + hx-target="this" 111 + hx-push-url={url} 112 + hx-indicator="#loading-spinner" 113 + > 114 + Load More 115 + </button> 116 + ); 117 + } 118 + 119 + function ReplyFragment({ 120 + topicId, 121 + replies, 122 + total, 123 + offset, 124 + }: { 125 + topicId: string; 126 + replies: PostResponse[]; 127 + total: number; 128 + offset: number; 129 + }) { 130 + const nextOffset = offset + replies.length; 131 + const hasMore = nextOffset < total; 132 + return ( 133 + <> 134 + {replies.map((reply, i) => ( 135 + <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} /> 136 + ))} 137 + {hasMore && <LoadMoreButton topicId={topicId} nextOffset={nextOffset} />} 138 + </> 139 + ); 140 + } 141 + 142 + // ─── Route factory ──────────────────────────────────────────────────────────── 5 143 6 144 export function createTopicsRoutes(appviewUrl: string) { 7 145 return new Hono().get("/topics/:id", async (c) => { 146 + const idParam = c.req.param("id"); 147 + const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 148 + const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; 149 + 150 + // ── Validate ID ────────────────────────────────────────────────────────── 151 + if (!/^\d+$/.test(idParam)) { 152 + if (c.req.header("HX-Request")) { 153 + return c.html("", 200); 154 + } 155 + return c.html( 156 + <BaseLayout title="Bad Request — atBB Forum"> 157 + <ErrorDisplay message="Invalid topic ID." /> 158 + </BaseLayout>, 159 + 400 160 + ); 161 + } 162 + 163 + const topicId = idParam; 164 + 165 + // ── HTMX partial mode ──────────────────────────────────────────────────── 166 + if (c.req.header("HX-Request")) { 167 + try { 168 + const data = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`); 169 + // TODO(ATB-33): switch to server-side offset/limit pagination like boards.tsx 170 + // to avoid fetching all replies on every Load More click 171 + const pageReplies = data.replies.slice(offset, offset + REPLIES_PER_PAGE); 172 + return c.html( 173 + <ReplyFragment 174 + topicId={topicId} 175 + replies={pageReplies} 176 + total={data.replies.length} 177 + offset={offset} 178 + />, 179 + 200 180 + ); 181 + } catch (error) { 182 + if (isProgrammingError(error)) throw error; 183 + console.error("Failed to load replies for HTMX partial request", { 184 + operation: "GET /topics/:id (HTMX partial)", 185 + topicId, 186 + offset, 187 + error: error instanceof Error ? error.message : String(error), 188 + }); 189 + return c.html("", 200); 190 + } 191 + } 192 + 193 + // ── Full page mode ──────────────────────────────────────────────────────── 8 194 const auth = await getSession(appviewUrl, c.req.header("cookie")); 195 + 196 + // Stage 1: fetch topic (fatal on failure) 197 + let topicData: TopicDetailResponse; 198 + try { 199 + topicData = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`); 200 + } catch (error) { 201 + if (isProgrammingError(error)) throw error; 202 + 203 + if (isNotFoundError(error)) { 204 + return c.html( 205 + <BaseLayout title="Not Found — atBB Forum" auth={auth}> 206 + <ErrorDisplay message="This topic doesn't exist." /> 207 + </BaseLayout>, 208 + 404 209 + ); 210 + } 211 + 212 + console.error("Failed to load topic page (stage 1: topic)", { 213 + operation: "GET /topics/:id", 214 + topicId, 215 + error: error instanceof Error ? error.message : String(error), 216 + }); 217 + const status = isNetworkError(error) ? 503 : 500; 218 + const message = 219 + status === 503 220 + ? "The forum is temporarily unavailable. Please try again later." 221 + : "Something went wrong loading this topic. Please try again later."; 222 + return c.html( 223 + <BaseLayout title="Error — atBB Forum" auth={auth}> 224 + <ErrorDisplay message={message} /> 225 + </BaseLayout>, 226 + status 227 + ); 228 + } 229 + 230 + // Stage 2: fetch board for breadcrumb (non-fatal) 231 + let boardName: string | null = null; 232 + let categoryId: string | null = null; 233 + if (topicData.post.boardId) { 234 + try { 235 + const board = await fetchApi<BoardResponse>(`/boards/${topicData.post.boardId}`); 236 + boardName = board.name; 237 + categoryId = board.categoryId; 238 + } catch (error) { 239 + if (isProgrammingError(error)) throw error; 240 + console.error("Failed to load topic page (stage 2: board)", { 241 + operation: "GET /topics/:id", 242 + topicId, 243 + boardId: topicData.post.boardId, 244 + error: error instanceof Error ? error.message : String(error), 245 + }); 246 + } 247 + } 248 + 249 + // Stage 3: fetch category for breadcrumb (non-fatal) 250 + let categoryName: string | null = null; 251 + if (categoryId) { 252 + try { 253 + const category = await fetchApi<CategoryResponse>(`/categories/${categoryId}`); 254 + categoryName = category.name; 255 + } catch (error) { 256 + if (isProgrammingError(error)) throw error; 257 + console.error("Failed to load topic page (stage 3: category)", { 258 + operation: "GET /topics/:id", 259 + topicId, 260 + categoryId, 261 + error: error instanceof Error ? error.message : String(error), 262 + }); 263 + } 264 + } 265 + 266 + // Pagination: show replies from 0 to offset+REPLIES_PER_PAGE (bookmark support) 267 + const allReplies = topicData.replies; 268 + const displayCount = offset + REPLIES_PER_PAGE; 269 + const initialReplies = allReplies.slice(0, displayCount); 270 + const total = allReplies.length; 271 + 272 + const topicTitle = topicData.post.text.slice(0, 60); 273 + 9 274 return c.html( 10 - <BaseLayout title="Topic — atBB Forum" auth={auth}> 11 - <PageHeader title="Topic" /> 12 - {auth.authenticated ? ( 13 - <p>Reply form will appear here.</p> 14 - ) : ( 15 - <p> 16 - <a href="/login">Log in</a> to reply. 17 - </p> 275 + <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}> 276 + <nav class="breadcrumb"> 277 + <a href="/">Home</a> 278 + {categoryName && categoryId && ( 279 + <> 280 + {" / "} 281 + <a href={`/categories/${categoryId}`}>{categoryName}</a> 282 + </> 283 + )} 284 + {boardName && ( 285 + <> 286 + {" / "} 287 + <a href={`/boards/${topicData.post.boardId}`}>{boardName}</a> 288 + </> 289 + )} 290 + {" / "} 291 + <span>{topicTitle}</span> 292 + </nav> 293 + 294 + <PageHeader title={topicTitle} /> 295 + 296 + {topicData.locked && ( 297 + <div class="topic-locked-banner"> 298 + <span class="topic-locked-banner__badge">Locked</span> 299 + This topic is locked. 300 + </div> 18 301 )} 19 - <EmptyState message="No replies yet." /> 302 + 303 + <PostCard post={topicData.post} postNumber={1} isOP={true} /> 304 + 305 + <div id="reply-list"> 306 + {allReplies.length === 0 ? ( 307 + <EmptyState message="No replies yet." /> 308 + ) : ( 309 + <ReplyFragment 310 + topicId={topicId} 311 + replies={initialReplies} 312 + total={total} 313 + offset={0} 314 + /> 315 + )} 316 + </div> 317 + 318 + <div id="reply-form-slot"> 319 + {topicData.locked ? ( 320 + <p>This topic is locked. Replies are disabled.</p> 321 + ) : auth?.authenticated ? ( 322 + <p>Reply form coming soon.</p> 323 + ) : ( 324 + <p> 325 + <a href="/login">Log in</a> to reply. 326 + </p> 327 + )} 328 + </div> 20 329 </BaseLayout> 21 330 ); 22 331 });
+2 -1
docs/atproto-forum-plan.md
··· 222 222 - [x] Board view: topic listing with pagination 223 223 - ATB-28 | `apps/web/src/routes/boards.tsx` — breadcrumb navigation, topic list (truncated 80 chars), HTMX "Load More" pagination; auth-aware "New Topic" button; `timeAgo` relative date utility; `isNotFoundError` error helper; AppView: `GET /api/boards/:id`, `GET /api/categories/:id`, offset/limit pagination on `GET /api/boards/:id/topics`; 20 integration tests in `boards.test.tsx`; 8 AppView tests 224 224 - [ ] Category view: paginated topic list, sorted by last reply 225 - - [ ] Topic view: OP + flat replies, pagination 225 + - [x] Topic view: OP + flat replies, pagination 226 + - ATB-29 | `apps/web/src/routes/topics.tsx` — OP card (#1) + reply cards (#2, #3, …) with post numbers and `timeAgo` dates; breadcrumb (Home → Category → Board → Topic) with graceful degradation on breadcrumb fetch failures; locked-topic banner + reply-slot gating (unauthenticated/authenticated/locked); HTMX "Load More" with `hx-push-url` for bookmarkable offsets; `?offset=N` bookmark support renders all replies 0→N+pageSize inline; three-stage sequential fetch (topic fatal, board/category non-fatal); 35 integration tests in `topics.test.tsx` 226 227 - [ ] Compose: new topic form, reply form 227 228 - [ ] Login/logout flow 228 229 - [ ] Admin panel: manage categories, view members, mod actions
+119
docs/plans/2026-02-19-topic-view-design.md
··· 1 + # Topic View Design — ATB-29 2 + 3 + **Date:** 2026-02-19 4 + **Linear:** [ATB-29](https://linear.app/atbb/issue/ATB-29/topic-view-thread-display-with-replies) 5 + **Status:** Approved 6 + 7 + ## Overview 8 + 9 + Implement the topic view page (`GET /topics/:id`) in the web app. The page displays a thread: the original post (OP) followed by all replies in chronological order, with HTMX Load More pagination and bookmarkable URLs. 10 + 11 + ## Architecture 12 + 13 + ### Route 14 + 15 + The existing stub in `apps/web/src/routes/topics.tsx` is replaced. The factory signature stays the same: 16 + 17 + ```ts 18 + export function createTopicsRoutes(appviewUrl: string): Hono 19 + ``` 20 + 21 + The single handler `GET /topics/:id` operates in two modes: 22 + 23 + - **Full page mode** — renders the complete HTML page (OP + breadcrumb + initial replies) 24 + - **HTMX partial mode** — detected via `HX-Request` header; returns only the next batch of reply cards + a new Load More button 25 + 26 + ### Data Fetching (full page) 27 + 28 + Three stages, each with independent error handling: 29 + 30 + | Stage | Requests | Fatal? | 31 + |-------|----------|--------| 32 + | 1 (parallel) | `GET /api/topics/:id` + session check | Yes — 404/503/500 on failure | 33 + | 2 (sequential) | `GET /api/boards/:boardId` | No — breadcrumb degrades | 34 + | 3 (sequential) | `GET /api/categories/:categoryId` | No — breadcrumb degrades | 35 + 36 + ### Bookmark support 37 + 38 + When `?offset=N` is present on initial load, replies 0→N are fetched and rendered inline. The Load More button then fetches from N onward. 39 + 40 + ## Components 41 + 42 + All inline in `topics.tsx` (no new files). 43 + 44 + ### `PostCard({ post, postNumber, isOP })` 45 + 46 + Renders a single post. OP gets CSS modifier `post-card--op` (thicker border/larger shadow). Each card shows: 47 + 48 + - Bold post number badge (`#1`, `#2`, …) 49 + - Full text with `white-space: pre-wrap` 50 + - Author handle (fallback to DID if null) 51 + - Relative time via `timeAgo()` 52 + 53 + ### `LoadMoreButton({ topicId, nextOffset })` 54 + 55 + HTMX button that appends the next reply batch: 56 + 57 + ```html 58 + hx-get="/topics/:id?offset=N" 59 + hx-swap="outerHTML" 60 + hx-target="this" 61 + hx-push-url="/topics/:id?offset=N" 62 + ``` 63 + 64 + `hx-push-url` advances the browser URL so each Load More click produces a bookmarkable URL. 65 + 66 + ### `ReplyFragment({ topicId, replies, total, offset })` 67 + 68 + Renders a batch of `PostCard` reply cards followed by a `LoadMoreButton` when `nextOffset < total`. 69 + 70 + ### Locked topic banner 71 + 72 + Rendered inline on the full page above the reply list. High-contrast neobrutal warning style. Hides the reply form slot when present. 73 + 74 + ### Reply form slot 75 + 76 + ```html 77 + <div id="reply-form-slot"> 78 + <!-- auth-gated: login prompt or placeholder --> 79 + </div> 80 + ``` 81 + 82 + The compose forms ticket will fill this slot. For now: show "Log in to reply" for guests, placeholder text for authenticated users. 83 + 84 + ## Error Handling 85 + 86 + Follows the pattern established in `boards.tsx`: 87 + 88 + | Condition | Response | 89 + |-----------|----------| 90 + | Non-numeric ID | 400 HTML page | 91 + | Topic not found (API 404) | 404 HTML page | 92 + | Network error | 503 HTML page | 93 + | Other API error | 500 HTML page | 94 + | Programming error (`TypeError` etc.) | Re-thrown | 95 + | Board/category fetch failure | Non-fatal — breadcrumb degrades | 96 + | HTMX partial error | Empty fragment (page does not break) | 97 + 98 + ## Testing 99 + 100 + File: `apps/web/src/routes/__tests__/topics.test.tsx` 101 + 102 + | Test | Verifies | 103 + |------|---------| 104 + | Non-numeric ID | 400 | 105 + | Topic not found | 404 HTML | 106 + | Network error | 503 | 107 + | AppView 500 | 500 | 108 + | Happy path — OP only | Topic text, author, `#1` badge rendered | 109 + | Happy path — with replies | Replies rendered with `#2`, `#3` badges | 110 + | Locked topic | Locked banner shown, reply form hidden | 111 + | Unauthenticated | "Log in to reply" shown | 112 + | Authenticated | Reply form slot shown | 113 + | Load More shown | When `nextOffset < total` | 114 + | Load More hidden | When all replies loaded | 115 + | HTMX partial | Returns reply fragment with `hx-push-url` | 116 + | HTMX partial error | Empty fragment returned | 117 + | Board fetch fails | Page renders, breadcrumb degrades | 118 + | Category fetch fails | Page renders, breadcrumb degrades | 119 + | Bookmark URL `?offset=N` | OP + all replies 0→N rendered inline |
+1192
docs/plans/2026-02-19-topic-view.md
··· 1 + # Topic View Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Replace the stub `GET /topics/:id` route with a full thread display page showing the OP, replies, breadcrumb, locked-topic indicator, auth-gated reply slot, and HTMX Load More pagination with bookmarkable `?offset=N` URLs. 6 + 7 + **Architecture:** Single route handler in `apps/web/src/routes/topics.tsx` with two modes — HTMX partial (returns reply fragment) and full page (returns complete HTML). Data fetches in three sequential stages: topic (fatal), board (non-fatal for breadcrumb), category (non-fatal for breadcrumb). The AppView returns all replies in one call; the web layer slices them for pagination. 8 + 9 + **Tech Stack:** Hono JSX, HTMX (`hx-push-url`), `fetchApi` from `lib/api.ts`, `getSession` from `lib/session.ts`, error helpers from `lib/errors.ts`, `timeAgo` from `lib/time.ts`. 10 + 11 + **Design doc:** `docs/plans/2026-02-19-topic-view-design.md` 12 + 13 + **Run tests with:** 14 + ```sh 15 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 16 + # All web tests: 17 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 18 + ``` 19 + 20 + --- 21 + 22 + ## API Shape Reference 23 + 24 + `GET /api/topics/:id` returns: 25 + ```json 26 + { 27 + "topicId": "1", 28 + "locked": false, 29 + "pinned": false, 30 + "post": { 31 + "id": "1", "did": "did:plc:author", "rkey": "tid123", 32 + "text": "This is the original post", 33 + "forumUri": "at://...", "boardUri": "at://...", "boardId": "42", 34 + "parentPostId": null, 35 + "createdAt": "2025-01-01T00:00:00.000Z", 36 + "author": { "did": "did:plc:author", "handle": "alice.bsky.social" } 37 + }, 38 + "replies": [/* same shape as post */] 39 + } 40 + ``` 41 + 42 + `GET /api/boards/:id` returns `{ id, name, description, categoryId, ... }`. 43 + `GET /api/categories/:id` returns `{ id, name, ... }`. 44 + 45 + ## Mock Fetch Order Reference 46 + 47 + The route makes fetches in this order: 48 + 49 + **Unauthenticated (no `atbb_session` cookie):** 50 + 1. `GET /api/topics/:id` 51 + 2. `GET /api/boards/:boardId` 52 + 3. `GET /api/categories/:categoryId` 53 + 54 + **Authenticated (with `atbb_session=` cookie):** 55 + 1. `GET /api/auth/session` 56 + 2. `GET /api/topics/:id` 57 + 3. `GET /api/boards/:boardId` 58 + 4. `GET /api/categories/:categoryId` 59 + 60 + **HTMX partial (HX-Request header):** 61 + 1. `GET /api/topics/:id` 62 + 63 + --- 64 + 65 + ## Task 1: TypeScript Interfaces + Route Skeleton 66 + 67 + **Files:** 68 + - Modify: `apps/web/src/routes/topics.tsx` 69 + 70 + Replace the current stub completely with typed interfaces and an empty handler shell that just returns 200 for now. No tests for this task — it just establishes the TypeScript types so later tasks can compile. 71 + 72 + **Step 1: Replace `topics.tsx` with this skeleton** 73 + 74 + ```tsx 75 + import { Hono } from "hono"; 76 + import { BaseLayout } from "../layouts/base.js"; 77 + import { PageHeader, EmptyState, ErrorDisplay } from "../components/index.js"; 78 + import { fetchApi } from "../lib/api.js"; 79 + import { getSession } from "../lib/session.js"; 80 + import { 81 + isProgrammingError, 82 + isNetworkError, 83 + isNotFoundError, 84 + } from "../lib/errors.js"; 85 + import { timeAgo } from "../lib/time.js"; 86 + 87 + // ─── API response types ─────────────────────────────────────────────────────── 88 + 89 + interface AuthorResponse { 90 + did: string; 91 + handle: string | null; 92 + } 93 + 94 + interface PostResponse { 95 + id: string; 96 + did: string; 97 + rkey: string; 98 + text: string; 99 + forumUri: string | null; 100 + boardUri: string | null; 101 + boardId: string | null; 102 + parentPostId: string | null; 103 + createdAt: string | null; 104 + author: AuthorResponse | null; 105 + } 106 + 107 + interface TopicDetailResponse { 108 + topicId: string; 109 + locked: boolean; 110 + pinned: boolean; 111 + post: PostResponse; 112 + replies: PostResponse[]; 113 + } 114 + 115 + interface BoardResponse { 116 + id: string; 117 + did: string; 118 + name: string; 119 + description: string | null; 120 + slug: string | null; 121 + sortOrder: number | null; 122 + categoryId: string; 123 + categoryUri: string | null; 124 + createdAt: string | null; 125 + indexedAt: string | null; 126 + } 127 + 128 + interface CategoryResponse { 129 + id: string; 130 + did: string; 131 + name: string; 132 + description: string | null; 133 + slug: string | null; 134 + sortOrder: number | null; 135 + forumId: string | null; 136 + createdAt: string | null; 137 + indexedAt: string | null; 138 + } 139 + 140 + // ─── Constants ──────────────────────────────────────────────────────────────── 141 + 142 + const REPLIES_PER_PAGE = 25; 143 + 144 + // ─── Inline components ──────────────────────────────────────────────────────── 145 + 146 + function PostCard({ 147 + post, 148 + postNumber, 149 + isOP = false, 150 + }: { 151 + post: PostResponse; 152 + postNumber: number; 153 + isOP?: boolean; 154 + }) { 155 + const handle = post.author?.handle ?? post.author?.did ?? post.did; 156 + const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown"; 157 + const cardClass = isOP ? "post-card post-card--op" : "post-card post-card--reply"; 158 + return ( 159 + <div class={cardClass} id={`post-${postNumber}`}> 160 + <div class="post-card__header"> 161 + <span class="post-card__number">#{postNumber}</span> 162 + <span class="post-card__author">{handle}</span> 163 + <span class="post-card__date">{date}</span> 164 + </div> 165 + <div class="post-card__body" style="white-space: pre-wrap"> 166 + {post.text} 167 + </div> 168 + </div> 169 + ); 170 + } 171 + 172 + function LoadMoreButton({ 173 + topicId, 174 + nextOffset, 175 + }: { 176 + topicId: string; 177 + nextOffset: number; 178 + }) { 179 + const url = `/topics/${topicId}?offset=${nextOffset}`; 180 + return ( 181 + <button 182 + hx-get={url} 183 + hx-swap="outerHTML" 184 + hx-target="this" 185 + hx-push-url={url} 186 + hx-indicator="#loading-spinner" 187 + > 188 + Load More 189 + </button> 190 + ); 191 + } 192 + 193 + function ReplyFragment({ 194 + topicId, 195 + replies, 196 + total, 197 + offset, 198 + }: { 199 + topicId: string; 200 + replies: PostResponse[]; 201 + total: number; 202 + offset: number; 203 + }) { 204 + const nextOffset = offset + replies.length; 205 + const hasMore = nextOffset < total; 206 + return ( 207 + <> 208 + {replies.map((reply, i) => ( 209 + <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} /> 210 + ))} 211 + {hasMore && <LoadMoreButton topicId={topicId} nextOffset={nextOffset} />} 212 + </> 213 + ); 214 + } 215 + 216 + // ─── Route factory ──────────────────────────────────────────────────────────── 217 + 218 + export function createTopicsRoutes(appviewUrl: string) { 219 + return new Hono().get("/topics/:id", async (c) => { 220 + // TODO: implement in subsequent tasks 221 + return c.html(<div>stub</div>, 200); 222 + }); 223 + } 224 + ``` 225 + 226 + **Step 2: Verify TypeScript compiles** 227 + ```sh 228 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit 229 + ``` 230 + Expected: no errors. 231 + 232 + **Step 3: Commit** 233 + ```sh 234 + git add apps/web/src/routes/topics.tsx 235 + git commit -m "feat(web): topic view type interfaces and component stubs (ATB-29)" 236 + ``` 237 + 238 + --- 239 + 240 + ## Task 2: Error Cases — 400, 404, 503, 500 241 + 242 + **Files:** 243 + - Create: `apps/web/src/routes/__tests__/topics.test.tsx` 244 + - Modify: `apps/web/src/routes/topics.tsx` 245 + 246 + **Step 1: Write the failing tests** 247 + 248 + Create `apps/web/src/routes/__tests__/topics.test.tsx`: 249 + 250 + ```tsx 251 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 252 + 253 + const mockFetch = vi.fn(); 254 + 255 + describe("createTopicsRoutes", () => { 256 + beforeEach(() => { 257 + vi.stubGlobal("fetch", mockFetch); 258 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 259 + vi.resetModules(); 260 + mockFetch.mockResolvedValue({ ok: false, status: 401 }); 261 + }); 262 + 263 + afterEach(() => { 264 + vi.unstubAllGlobals(); 265 + vi.unstubAllEnvs(); 266 + mockFetch.mockReset(); 267 + }); 268 + 269 + // ─── Mock response helpers ──────────────────────────────────────────────── 270 + 271 + function mockResponse(body: unknown, ok = true, status = 200) { 272 + return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 273 + } 274 + 275 + function makeTopicResponse(overrides: Record<string, unknown> = {}) { 276 + return { 277 + ok: true, 278 + json: () => 279 + Promise.resolve({ 280 + topicId: "1", 281 + locked: false, 282 + pinned: false, 283 + post: { 284 + id: "1", 285 + did: "did:plc:author", 286 + rkey: "tid123", 287 + text: "This is the original post", 288 + forumUri: null, 289 + boardUri: null, 290 + boardId: "42", 291 + parentPostId: null, 292 + createdAt: "2025-01-01T00:00:00.000Z", 293 + author: { did: "did:plc:author", handle: "alice.bsky.social" }, 294 + }, 295 + replies: [], 296 + ...overrides, 297 + }), 298 + }; 299 + } 300 + 301 + function makeBoardResponse(overrides: Record<string, unknown> = {}) { 302 + return { 303 + ok: true, 304 + json: () => 305 + Promise.resolve({ 306 + id: "42", 307 + did: "did:plc:forum", 308 + name: "General Discussion", 309 + description: null, 310 + slug: null, 311 + sortOrder: 1, 312 + categoryId: "7", 313 + categoryUri: null, 314 + createdAt: null, 315 + indexedAt: null, 316 + ...overrides, 317 + }), 318 + }; 319 + } 320 + 321 + function makeCategoryResponse(overrides: Record<string, unknown> = {}) { 322 + return { 323 + ok: true, 324 + json: () => 325 + Promise.resolve({ 326 + id: "7", 327 + did: "did:plc:forum", 328 + name: "Main Category", 329 + description: null, 330 + slug: null, 331 + sortOrder: 1, 332 + forumId: "1", 333 + createdAt: null, 334 + indexedAt: null, 335 + ...overrides, 336 + }), 337 + }; 338 + } 339 + 340 + /** 341 + * Sets up the standard 3-fetch sequence for a successful unauthenticated request: 342 + * 1. GET /api/topics/1 343 + * 2. GET /api/boards/42 344 + * 3. GET /api/categories/7 345 + */ 346 + function setupSuccessfulFetch(topicOverrides: Record<string, unknown> = {}) { 347 + mockFetch.mockResolvedValueOnce(makeTopicResponse(topicOverrides)); 348 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 349 + mockFetch.mockResolvedValueOnce(makeCategoryResponse()); 350 + } 351 + 352 + async function loadTopicsRoutes() { 353 + const { createTopicsRoutes } = await import("../topics.js"); 354 + return createTopicsRoutes("http://localhost:3000"); 355 + } 356 + 357 + // ─── Error cases ────────────────────────────────────────────────────────── 358 + 359 + it("returns 400 for non-numeric topic ID", async () => { 360 + const routes = await loadTopicsRoutes(); 361 + const res = await routes.request("/topics/not-a-number"); 362 + expect(res.status).toBe(400); 363 + const html = await res.text(); 364 + expect(res.headers.get("content-type")).toContain("text/html"); 365 + expect(html).toContain("Invalid"); 366 + }); 367 + 368 + it("returns 404 when topic not found", async () => { 369 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" }); 370 + const routes = await loadTopicsRoutes(); 371 + const res = await routes.request("/topics/1"); 372 + expect(res.status).toBe(404); 373 + const html = await res.text(); 374 + expect(html).toContain("Not Found"); 375 + }); 376 + 377 + it("returns 503 on network error", async () => { 378 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 379 + const routes = await loadTopicsRoutes(); 380 + const res = await routes.request("/topics/1"); 381 + expect(res.status).toBe(503); 382 + const html = await res.text(); 383 + expect(html).toContain("unavailable"); 384 + }); 385 + 386 + it("returns 500 on AppView server error", async () => { 387 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 388 + const routes = await loadTopicsRoutes(); 389 + const res = await routes.request("/topics/1"); 390 + expect(res.status).toBe(500); 391 + const html = await res.text(); 392 + expect(html).toContain("went wrong"); 393 + }); 394 + 395 + it("re-throws TypeError (programming error)", async () => { 396 + mockFetch.mockResolvedValueOnce(mockResponse(null)); // null — accessing null.post throws TypeError 397 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 398 + mockFetch.mockResolvedValueOnce(makeCategoryResponse()); 399 + const routes = await loadTopicsRoutes(); 400 + const res = await routes.request("/topics/1"); 401 + expect(res.status).toBe(500); 402 + expect(res.headers.get("content-type")).not.toContain("text/html"); 403 + }); 404 + }); 405 + ``` 406 + 407 + **Step 2: Run tests — verify they fail** 408 + ```sh 409 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 410 + ``` 411 + Expected: all 5 tests FAIL (the stub returns 200 for everything). 412 + 413 + **Step 3: Implement error handling in `createTopicsRoutes`** 414 + 415 + Replace the `// TODO: implement` stub body with: 416 + 417 + ```tsx 418 + export function createTopicsRoutes(appviewUrl: string) { 419 + return new Hono().get("/topics/:id", async (c) => { 420 + const idParam = c.req.param("id"); 421 + const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 422 + const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; 423 + 424 + // ── Validate ID ────────────────────────────────────────────────────────── 425 + if (!/^\d+$/.test(idParam)) { 426 + if (c.req.header("HX-Request")) { 427 + return c.html("", 200); 428 + } 429 + return c.html( 430 + <BaseLayout title="Bad Request — atBB Forum"> 431 + <ErrorDisplay message="Invalid topic ID." /> 432 + </BaseLayout>, 433 + 400 434 + ); 435 + } 436 + 437 + const topicId = idParam; 438 + 439 + // ── HTMX partial mode ──────────────────────────────────────────────────── 440 + if (c.req.header("HX-Request")) { 441 + try { 442 + const data = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`); 443 + const pageReplies = data.replies.slice(offset, offset + REPLIES_PER_PAGE); 444 + return c.html( 445 + <ReplyFragment 446 + topicId={topicId} 447 + replies={pageReplies} 448 + total={data.replies.length} 449 + offset={offset} 450 + />, 451 + 200 452 + ); 453 + } catch (error) { 454 + if (isProgrammingError(error)) throw error; 455 + console.error("Failed to load replies for HTMX partial request", { 456 + operation: "GET /topics/:id (HTMX partial)", 457 + topicId, 458 + offset, 459 + error: error instanceof Error ? error.message : String(error), 460 + }); 461 + return c.html("", 200); 462 + } 463 + } 464 + 465 + // ── Full page mode ──────────────────────────────────────────────────────── 466 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 467 + 468 + // Stage 1: fetch topic (fatal on failure) 469 + let topicData: TopicDetailResponse; 470 + try { 471 + topicData = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`); 472 + } catch (error) { 473 + if (isProgrammingError(error)) throw error; 474 + 475 + if (isNotFoundError(error)) { 476 + return c.html( 477 + <BaseLayout title="Not Found — atBB Forum" auth={auth}> 478 + <ErrorDisplay message="This topic doesn't exist." /> 479 + </BaseLayout>, 480 + 404 481 + ); 482 + } 483 + 484 + console.error("Failed to load topic page (stage 1: topic)", { 485 + operation: "GET /topics/:id", 486 + topicId, 487 + error: error instanceof Error ? error.message : String(error), 488 + }); 489 + const status = isNetworkError(error) ? 503 : 500; 490 + const message = 491 + status === 503 492 + ? "The forum is temporarily unavailable. Please try again later." 493 + : "Something went wrong loading this topic. Please try again later."; 494 + return c.html( 495 + <BaseLayout title="Error — atBB Forum" auth={auth}> 496 + <ErrorDisplay message={message} /> 497 + </BaseLayout>, 498 + status 499 + ); 500 + } 501 + 502 + // Stage 2: fetch board for breadcrumb (non-fatal) 503 + let boardName: string | null = null; 504 + let categoryId: string | null = null; 505 + if (topicData.post.boardId) { 506 + try { 507 + const board = await fetchApi<BoardResponse>(`/boards/${topicData.post.boardId}`); 508 + boardName = board.name; 509 + categoryId = board.categoryId; 510 + } catch (error) { 511 + if (isProgrammingError(error)) throw error; 512 + console.error("Failed to load topic page (stage 2: board)", { 513 + operation: "GET /topics/:id", 514 + topicId, 515 + boardId: topicData.post.boardId, 516 + error: error instanceof Error ? error.message : String(error), 517 + }); 518 + } 519 + } 520 + 521 + // Stage 3: fetch category for breadcrumb (non-fatal) 522 + let categoryName: string | null = null; 523 + if (categoryId) { 524 + try { 525 + const category = await fetchApi<CategoryResponse>(`/categories/${categoryId}`); 526 + categoryName = category.name; 527 + } catch (error) { 528 + if (isProgrammingError(error)) throw error; 529 + console.error("Failed to load topic page (stage 3: category)", { 530 + operation: "GET /topics/:id", 531 + topicId, 532 + categoryId, 533 + error: error instanceof Error ? error.message : String(error), 534 + }); 535 + } 536 + } 537 + 538 + // Pagination: show replies from 0 to offset+REPLIES_PER_PAGE (bookmark support) 539 + const allReplies = topicData.replies; 540 + const displayCount = offset + REPLIES_PER_PAGE; 541 + const initialReplies = allReplies.slice(0, displayCount); 542 + const total = allReplies.length; 543 + 544 + const topicTitle = topicData.post.text.slice(0, 60); 545 + 546 + return c.html( 547 + <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}> 548 + <nav class="breadcrumb"> 549 + <a href="/">Home</a> 550 + {categoryName && ( 551 + <> 552 + {" / "} 553 + <a href="/">{categoryName}</a> 554 + </> 555 + )} 556 + {boardName && ( 557 + <> 558 + {" / "} 559 + <a href={`/boards/${topicData.post.boardId}`}>{boardName}</a> 560 + </> 561 + )} 562 + {" / "} 563 + <span>{topicTitle}</span> 564 + </nav> 565 + 566 + <PageHeader title={topicTitle} /> 567 + 568 + {topicData.locked && ( 569 + <div class="topic-locked-banner"> 570 + <span class="topic-locked-banner__badge">Locked</span> 571 + This topic is locked. 572 + </div> 573 + )} 574 + 575 + <PostCard post={topicData.post} postNumber={1} isOP={true} /> 576 + 577 + <div id="reply-list"> 578 + {allReplies.length === 0 ? ( 579 + <EmptyState message="No replies yet." /> 580 + ) : ( 581 + <ReplyFragment 582 + topicId={topicId} 583 + replies={initialReplies} 584 + total={total} 585 + offset={0} 586 + /> 587 + )} 588 + </div> 589 + 590 + <div id="reply-form-slot"> 591 + {topicData.locked ? ( 592 + <p>This topic is locked. Replies are disabled.</p> 593 + ) : auth?.authenticated ? ( 594 + <p>Reply form coming soon.</p> 595 + ) : ( 596 + <p> 597 + <a href="/login">Log in</a> to reply. 598 + </p> 599 + )} 600 + </div> 601 + </BaseLayout> 602 + ); 603 + }); 604 + } 605 + ``` 606 + 607 + **Step 4: Run tests — verify they pass** 608 + ```sh 609 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 610 + ``` 611 + Expected: all 5 tests PASS. 612 + 613 + **Step 5: Commit** 614 + ```sh 615 + git add apps/web/src/routes/topics.tsx apps/web/src/routes/__tests__/topics.test.tsx 616 + git commit -m "feat(web): topic view error handling with 400/404/503/500 (ATB-29)" 617 + ``` 618 + 619 + --- 620 + 621 + ## Task 3: Happy Path — OP Renders 622 + 623 + **Files:** 624 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 625 + 626 + Add tests in the same `describe` block for OP rendering. The implementation already exists from Task 2. 627 + 628 + **Step 1: Add tests** 629 + 630 + Add inside the `describe("createTopicsRoutes")` block: 631 + 632 + ```tsx 633 + // ─── Happy path — OP ────────────────────────────────────────────────────── 634 + 635 + it("renders OP text content", async () => { 636 + setupSuccessfulFetch(); 637 + const routes = await loadTopicsRoutes(); 638 + const res = await routes.request("/topics/1"); 639 + expect(res.status).toBe(200); 640 + const html = await res.text(); 641 + expect(html).toContain("This is the original post"); 642 + }); 643 + 644 + it("renders OP author handle", async () => { 645 + setupSuccessfulFetch(); 646 + const routes = await loadTopicsRoutes(); 647 + const res = await routes.request("/topics/1"); 648 + const html = await res.text(); 649 + expect(html).toContain("alice.bsky.social"); 650 + }); 651 + 652 + it("renders OP with post number #1", async () => { 653 + setupSuccessfulFetch(); 654 + const routes = await loadTopicsRoutes(); 655 + const res = await routes.request("/topics/1"); 656 + const html = await res.text(); 657 + expect(html).toContain("#1"); 658 + }); 659 + 660 + it("renders OP with post-card--op CSS class", async () => { 661 + setupSuccessfulFetch(); 662 + const routes = await loadTopicsRoutes(); 663 + const res = await routes.request("/topics/1"); 664 + const html = await res.text(); 665 + expect(html).toContain("post-card--op"); 666 + }); 667 + 668 + it("renders page title with truncated topic text", async () => { 669 + setupSuccessfulFetch(); 670 + const routes = await loadTopicsRoutes(); 671 + const res = await routes.request("/topics/1"); 672 + const html = await res.text(); 673 + expect(html).toContain("This is the original post — atBB Forum"); 674 + }); 675 + 676 + it("falls back to DID when author handle is null", async () => { 677 + setupSuccessfulFetch({ 678 + post: { 679 + id: "1", did: "did:plc:author", rkey: "tid123", 680 + text: "No handle post", forumUri: null, boardUri: null, 681 + boardId: "42", parentPostId: null, 682 + createdAt: "2025-01-01T00:00:00.000Z", 683 + author: { did: "did:plc:author", handle: null }, 684 + }, 685 + }); 686 + const routes = await loadTopicsRoutes(); 687 + const res = await routes.request("/topics/1"); 688 + const html = await res.text(); 689 + expect(html).toContain("did:plc:author"); 690 + }); 691 + 692 + it("shows empty state when topic has no replies", async () => { 693 + setupSuccessfulFetch({ replies: [] }); 694 + const routes = await loadTopicsRoutes(); 695 + const res = await routes.request("/topics/1"); 696 + const html = await res.text(); 697 + expect(html).toContain("No replies yet"); 698 + }); 699 + ``` 700 + 701 + **Step 2: Run tests** 702 + ```sh 703 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 704 + ``` 705 + Expected: all new tests PASS (implementation is already complete). 706 + 707 + **Step 3: Commit** 708 + ```sh 709 + git add apps/web/src/routes/__tests__/topics.test.tsx 710 + git commit -m "test(web): topic view happy path — OP rendering (ATB-29)" 711 + ``` 712 + 713 + --- 714 + 715 + ## Task 4: Breadcrumb with Graceful Degradation 716 + 717 + **Files:** 718 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 719 + 720 + **Step 1: Add breadcrumb tests** 721 + 722 + ```tsx 723 + // ─── Breadcrumb ─────────────────────────────────────────────────────────── 724 + 725 + it("renders full breadcrumb: Home / Category / Board / Topic", async () => { 726 + setupSuccessfulFetch(); 727 + const routes = await loadTopicsRoutes(); 728 + const res = await routes.request("/topics/1"); 729 + const html = await res.text(); 730 + expect(html).toContain("breadcrumb"); 731 + expect(html).toContain("Main Category"); 732 + expect(html).toContain("General Discussion"); 733 + expect(html).toContain("This is the original post"); 734 + }); 735 + 736 + it("renders page without board/category in breadcrumb when board fetch fails", async () => { 737 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); // topic OK 738 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); // board fails 739 + const routes = await loadTopicsRoutes(); 740 + const res = await routes.request("/topics/1"); 741 + expect(res.status).toBe(200); 742 + const html = await res.text(); 743 + expect(html).toContain("This is the original post"); // page renders 744 + expect(html).not.toContain("General Discussion"); // board name absent 745 + expect(html).not.toContain("Main Category"); // category absent 746 + }); 747 + 748 + it("renders page without category in breadcrumb when category fetch fails", async () => { 749 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); // topic OK 750 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); // board OK 751 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); // category fails 752 + const routes = await loadTopicsRoutes(); 753 + const res = await routes.request("/topics/1"); 754 + expect(res.status).toBe(200); 755 + const html = await res.text(); 756 + expect(html).toContain("General Discussion"); // board present 757 + expect(html).not.toContain("Main Category"); // category absent 758 + }); 759 + ``` 760 + 761 + **Step 2: Run tests** 762 + ```sh 763 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 764 + ``` 765 + Expected: all new tests PASS. 766 + 767 + **Step 3: Commit** 768 + ```sh 769 + git add apps/web/src/routes/__tests__/topics.test.tsx 770 + git commit -m "test(web): topic view breadcrumb and graceful degradation (ATB-29)" 771 + ``` 772 + 773 + --- 774 + 775 + ## Task 5: Replies Render Correctly 776 + 777 + **Files:** 778 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 779 + 780 + **Step 1: Add reply rendering tests** 781 + 782 + ```tsx 783 + // ─── Replies ───────────────────────────────────────────────────────────── 784 + 785 + function makeReply(overrides: Record<string, unknown> = {}): Record<string, unknown> { 786 + return { 787 + id: "2", 788 + did: "did:plc:reply-author", 789 + rkey: "tid456", 790 + text: "This is a reply", 791 + forumUri: null, 792 + boardUri: null, 793 + boardId: "42", 794 + parentPostId: "1", 795 + createdAt: "2025-01-02T00:00:00.000Z", 796 + author: { did: "did:plc:reply-author", handle: "bob.bsky.social" }, 797 + ...overrides, 798 + }; 799 + } 800 + 801 + it("renders reply text and author", async () => { 802 + setupSuccessfulFetch({ replies: [makeReply()] }); 803 + const routes = await loadTopicsRoutes(); 804 + const res = await routes.request("/topics/1"); 805 + const html = await res.text(); 806 + expect(html).toContain("This is a reply"); 807 + expect(html).toContain("bob.bsky.social"); 808 + }); 809 + 810 + it("renders reply with post number #2", async () => { 811 + setupSuccessfulFetch({ replies: [makeReply()] }); 812 + const routes = await loadTopicsRoutes(); 813 + const res = await routes.request("/topics/1"); 814 + const html = await res.text(); 815 + expect(html).toContain("#2"); 816 + }); 817 + 818 + it("renders multiple replies with sequential post numbers", async () => { 819 + setupSuccessfulFetch({ 820 + replies: [ 821 + makeReply({ id: "2", text: "First reply" }), 822 + makeReply({ id: "3", text: "Second reply" }), 823 + makeReply({ id: "4", text: "Third reply" }), 824 + ], 825 + }); 826 + const routes = await loadTopicsRoutes(); 827 + const res = await routes.request("/topics/1"); 828 + const html = await res.text(); 829 + expect(html).toContain("#2"); 830 + expect(html).toContain("#3"); 831 + expect(html).toContain("#4"); 832 + expect(html).toContain("First reply"); 833 + expect(html).toContain("Second reply"); 834 + expect(html).toContain("Third reply"); 835 + }); 836 + 837 + it("renders reply with post-card--reply CSS class (not OP class)", async () => { 838 + setupSuccessfulFetch({ replies: [makeReply()] }); 839 + const routes = await loadTopicsRoutes(); 840 + const res = await routes.request("/topics/1"); 841 + const html = await res.text(); 842 + expect(html).toContain("post-card--reply"); 843 + // OP class appears once (for the OP), not on replies 844 + const opOccurrences = (html.match(/post-card--op/g) ?? []).length; 845 + expect(opOccurrences).toBe(1); 846 + }); 847 + ``` 848 + 849 + **Step 2: Run tests** 850 + ```sh 851 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 852 + ``` 853 + Expected: all new tests PASS. 854 + 855 + **Step 3: Commit** 856 + ```sh 857 + git add apps/web/src/routes/__tests__/topics.test.tsx 858 + git commit -m "test(web): topic view reply rendering (ATB-29)" 859 + ``` 860 + 861 + --- 862 + 863 + ## Task 6: Locked Topic Indicator + Auth-Gated Reply Slot 864 + 865 + **Files:** 866 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 867 + 868 + **Step 1: Add locked + auth tests** 869 + 870 + ```tsx 871 + // ─── Locked topic ───────────────────────────────────────────────────────── 872 + 873 + it("shows locked banner when topic is locked", async () => { 874 + setupSuccessfulFetch({ locked: true }); 875 + const routes = await loadTopicsRoutes(); 876 + const res = await routes.request("/topics/1"); 877 + const html = await res.text(); 878 + expect(html).toContain("topic-locked-banner"); 879 + expect(html).toContain("Locked"); 880 + expect(html).toContain("This topic is locked"); 881 + }); 882 + 883 + it("does not show locked banner when topic is unlocked", async () => { 884 + setupSuccessfulFetch({ locked: false }); 885 + const routes = await loadTopicsRoutes(); 886 + const res = await routes.request("/topics/1"); 887 + const html = await res.text(); 888 + expect(html).not.toContain("topic-locked-banner"); 889 + }); 890 + 891 + it("hides reply form and shows locked message when topic is locked", async () => { 892 + setupSuccessfulFetch({ locked: true }); 893 + const routes = await loadTopicsRoutes(); 894 + const res = await routes.request("/topics/1"); 895 + const html = await res.text(); 896 + expect(html).toContain("locked"); 897 + expect(html).toContain("Replies are disabled"); 898 + expect(html).not.toContain("Log in to reply"); 899 + expect(html).not.toContain("Reply form coming soon"); 900 + }); 901 + 902 + // ─── Auth-gated reply slot ──────────────────────────────────────────────── 903 + 904 + it("shows 'Log in to reply' for unauthenticated users", async () => { 905 + setupSuccessfulFetch(); 906 + const routes = await loadTopicsRoutes(); 907 + const res = await routes.request("/topics/1"); // no cookie 908 + const html = await res.text(); 909 + expect(html).toContain("Log in"); 910 + expect(html).toContain("to reply"); 911 + expect(html).not.toContain("Reply form coming soon"); 912 + }); 913 + 914 + it("shows reply form slot for authenticated users", async () => { 915 + // Auth fetch (session check) runs first since cookie is present 916 + mockFetch.mockResolvedValueOnce({ 917 + ok: true, 918 + json: () => 919 + Promise.resolve({ 920 + authenticated: true, 921 + did: "did:plc:user", 922 + handle: "user.bsky.social", 923 + }), 924 + }); 925 + setupSuccessfulFetch(); 926 + const routes = await loadTopicsRoutes(); 927 + const res = await routes.request("/topics/1", { 928 + headers: { cookie: "atbb_session=token" }, 929 + }); 930 + const html = await res.text(); 931 + expect(html).toContain("Reply form coming soon"); 932 + expect(html).not.toContain("Log in"); 933 + }); 934 + ``` 935 + 936 + **Step 2: Run tests** 937 + ```sh 938 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 939 + ``` 940 + Expected: all new tests PASS. 941 + 942 + **Step 3: Commit** 943 + ```sh 944 + git add apps/web/src/routes/__tests__/topics.test.tsx 945 + git commit -m "test(web): topic view locked indicator and auth-gated reply slot (ATB-29)" 946 + ``` 947 + 948 + --- 949 + 950 + ## Task 7: Load More Pagination 951 + 952 + **Files:** 953 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 954 + 955 + The Load More button appears when `total > REPLIES_PER_PAGE` (25). The first page shows replies 0–24. 956 + 957 + **Step 1: Add pagination tests** 958 + 959 + ```tsx 960 + // ─── Load More pagination ───────────────────────────────────────────────── 961 + 962 + function makeReplies(count: number): Record<string, unknown>[] { 963 + return Array.from({ length: count }, (_, i) => makeReply({ id: String(i + 2), text: `Reply ${i + 1}` })); 964 + } 965 + 966 + it("shows Load More button when more replies remain", async () => { 967 + setupSuccessfulFetch({ replies: makeReplies(30) }); // 30 replies, page size 25 968 + const routes = await loadTopicsRoutes(); 969 + const res = await routes.request("/topics/1"); 970 + const html = await res.text(); 971 + expect(html).toContain("Load More"); 972 + expect(html).toContain("hx-get"); 973 + }); 974 + 975 + it("hides Load More button when all replies fit on one page", async () => { 976 + setupSuccessfulFetch({ replies: makeReplies(10) }); // 10 replies, fits in 25 977 + const routes = await loadTopicsRoutes(); 978 + const res = await routes.request("/topics/1"); 979 + const html = await res.text(); 980 + expect(html).not.toContain("Load More"); 981 + }); 982 + 983 + it("Load More button URL contains correct next offset", async () => { 984 + setupSuccessfulFetch({ replies: makeReplies(30) }); 985 + const routes = await loadTopicsRoutes(); 986 + const res = await routes.request("/topics/1"); 987 + const html = await res.text(); 988 + // After 25 replies are shown, next offset is 25 989 + expect(html).toContain("offset=25"); 990 + }); 991 + 992 + it("Load More button has hx-push-url for bookmarkable URL", async () => { 993 + setupSuccessfulFetch({ replies: makeReplies(30) }); 994 + const routes = await loadTopicsRoutes(); 995 + const res = await routes.request("/topics/1"); 996 + const html = await res.text(); 997 + expect(html).toContain("hx-push-url"); 998 + expect(html).toContain("/topics/1?offset=25"); 999 + }); 1000 + ``` 1001 + 1002 + **Step 2: Run tests** 1003 + ```sh 1004 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 1005 + ``` 1006 + Expected: all new tests PASS. 1007 + 1008 + **Step 3: Commit** 1009 + ```sh 1010 + git add apps/web/src/routes/__tests__/topics.test.tsx 1011 + git commit -m "test(web): topic view Load More pagination with hx-push-url (ATB-29)" 1012 + ``` 1013 + 1014 + --- 1015 + 1016 + ## Task 8: Bookmark Support (?offset=N) 1017 + 1018 + **Files:** 1019 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 1020 + 1021 + When visiting `/topics/1?offset=25`, the page should render the OP plus replies 0–49 inline (all previously-seen content), with Load More pointing to offset=50. 1022 + 1023 + **Step 1: Add bookmark tests** 1024 + 1025 + ```tsx 1026 + // ─── Bookmark support ───────────────────────────────────────────────────── 1027 + 1028 + it("renders OP + all replies up to offset+pageSize when offset is set (bookmark)", async () => { 1029 + setupSuccessfulFetch({ replies: makeReplies(60) }); // 60 replies total 1030 + const routes = await loadTopicsRoutes(); 1031 + // Bookmark at offset=25 — should render replies 0..49 (50 replies) inline 1032 + const res = await routes.request("/topics/1?offset=25"); 1033 + const html = await res.text(); 1034 + // Reply 1 and Reply 50 should both be present (0-indexed: replies[0] and replies[49]) 1035 + expect(html).toContain("Reply 1"); 1036 + expect(html).toContain("Reply 50"); 1037 + // Reply 51 should not be present (beyond the bookmark range) 1038 + expect(html).not.toContain("Reply 51"); 1039 + }); 1040 + 1041 + it("Load More after bookmark points to correct next offset", async () => { 1042 + setupSuccessfulFetch({ replies: makeReplies(60) }); 1043 + const routes = await loadTopicsRoutes(); 1044 + const res = await routes.request("/topics/1?offset=25"); 1045 + const html = await res.text(); 1046 + // After showing 0..49, Load More should point to offset=50 1047 + expect(html).toContain("offset=50"); 1048 + }); 1049 + 1050 + it("ignores negative offset values (treats as 0)", async () => { 1051 + setupSuccessfulFetch({ replies: makeReplies(30) }); 1052 + const routes = await loadTopicsRoutes(); 1053 + const res = await routes.request("/topics/1?offset=-5"); 1054 + const html = await res.text(); 1055 + expect(res.status).toBe(200); 1056 + // Should behave like offset=0 (show first 25 replies) 1057 + expect(html).toContain("Reply 1"); 1058 + }); 1059 + ``` 1060 + 1061 + **Step 2: Run tests** 1062 + ```sh 1063 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 1064 + ``` 1065 + Expected: all new tests PASS. 1066 + 1067 + **Step 3: Commit** 1068 + ```sh 1069 + git add apps/web/src/routes/__tests__/topics.test.tsx 1070 + git commit -m "test(web): topic view bookmark support with ?offset=N (ATB-29)" 1071 + ``` 1072 + 1073 + --- 1074 + 1075 + ## Task 9: HTMX Partial Mode 1076 + 1077 + **Files:** 1078 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 1079 + 1080 + HTMX partial requests use the `HX-Request` header. The server fetches the full topic and slices the requested page of replies. 1081 + 1082 + **Step 1: Add HTMX partial tests** 1083 + 1084 + ```tsx 1085 + // ─── HTMX partial mode ──────────────────────────────────────────────────── 1086 + 1087 + it("HTMX partial returns reply fragment at given offset", async () => { 1088 + // HTMX partial: only 1 fetch (GET /api/topics/:id) 1089 + mockFetch.mockResolvedValueOnce( 1090 + makeTopicResponse({ 1091 + replies: [ 1092 + makeReply({ id: "26", text: "HTMX loaded reply" }), 1093 + makeReply({ id: "27", text: "Another HTMX reply" }), 1094 + ], 1095 + }) 1096 + ); 1097 + const routes = await loadTopicsRoutes(); 1098 + const res = await routes.request("/topics/1?offset=25", { 1099 + headers: { "HX-Request": "true" }, 1100 + }); 1101 + expect(res.status).toBe(200); 1102 + const html = await res.text(); 1103 + expect(html).toContain("HTMX loaded reply"); 1104 + expect(html).toContain("Another HTMX reply"); 1105 + }); 1106 + 1107 + it("HTMX partial includes hx-push-url on Load More button", async () => { 1108 + // 30 replies total, requesting offset=0 → should show 0..24 and Load More at 25 1109 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: makeReplies(30) })); 1110 + const routes = await loadTopicsRoutes(); 1111 + const res = await routes.request("/topics/1?offset=0", { 1112 + headers: { "HX-Request": "true" }, 1113 + }); 1114 + const html = await res.text(); 1115 + expect(html).toContain("hx-push-url"); 1116 + expect(html).toContain("/topics/1?offset=25"); 1117 + }); 1118 + 1119 + it("HTMX partial returns empty fragment on error", async () => { 1120 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 1121 + const routes = await loadTopicsRoutes(); 1122 + const res = await routes.request("/topics/1?offset=25", { 1123 + headers: { "HX-Request": "true" }, 1124 + }); 1125 + expect(res.status).toBe(200); 1126 + const html = await res.text(); 1127 + expect(html.trim()).toBe(""); 1128 + }); 1129 + 1130 + it("HTMX partial returns empty fragment for invalid topic ID", async () => { 1131 + const routes = await loadTopicsRoutes(); 1132 + const res = await routes.request("/topics/not-a-number", { 1133 + headers: { "HX-Request": "true" }, 1134 + }); 1135 + expect(res.status).toBe(200); 1136 + const html = await res.text(); 1137 + expect(html.trim()).toBe(""); 1138 + }); 1139 + ``` 1140 + 1141 + **Step 2: Run tests** 1142 + ```sh 1143 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx 1144 + ``` 1145 + Expected: all new tests PASS. 1146 + 1147 + **Step 3: Commit** 1148 + ```sh 1149 + git add apps/web/src/routes/__tests__/topics.test.tsx 1150 + git commit -m "test(web): topic view HTMX partial mode (ATB-29)" 1151 + ``` 1152 + 1153 + --- 1154 + 1155 + ## Task 10: Full Test Suite Pass + Update Linear 1156 + 1157 + **Step 1: Run all web tests** 1158 + ```sh 1159 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 1160 + ``` 1161 + Expected: all tests PASS (no skips, no failures). 1162 + 1163 + **Step 2: Run full monorepo tests** 1164 + ```sh 1165 + PATH=.devenv/profile/bin:$PATH pnpm test 1166 + ``` 1167 + Expected: all tests PASS. 1168 + 1169 + **Step 3: Typecheck** 1170 + ```sh 1171 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit 1172 + ``` 1173 + Expected: no errors. 1174 + 1175 + **Step 4: Update Linear issue ATB-29 to In Progress** 1176 + 1177 + Change status to "In Progress" in Linear. 1178 + 1179 + **Step 5: Final commit** 1180 + ```sh 1181 + git add . 1182 + git commit -m "feat(web): topic view thread display with replies (ATB-29) 1183 + 1184 + - GET /topics/:id renders OP + replies with post number badges 1185 + - Breadcrumb: Home → Category → Board → Topic (degrades gracefully) 1186 + - Locked topic banner + disabled reply slot 1187 + - HTMX Load More with hx-push-url for bookmarkable URLs 1188 + - ?offset=N bookmark support renders all replies 0→N inline 1189 + - Auth-gated reply form slot placeholder 1190 + - Full error handling: 400/404/503/500 1191 + - 30 tests covering all acceptance criteria" 1192 + ```