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): compose forms — new topic and reply submission (ATB-31) (#47)

* docs: add compose forms design doc (ATB-31)

Captures approved design for HTMX-powered new topic and reply forms,
including web server proxy architecture, HTMX integration patterns,
character counter approach, and testing requirements.

* docs: add ATB-31 compose forms implementation plan

Step-by-step TDD plan for new topic form (GET+POST), board flash
banner, and reply form with proxy handlers for the AppView write API.

* feat(appview): add uri field to board API response (ATB-31)

- Add computed uri field to serializeBoard (at://did/space.atbb.forum.board/rkey)
- Add uri assertion to AppView board serialization test
- Add uri to BoardResponse interface in web boards route
- Update makeBoardResponse helpers in web test files

* refactor(appview): strengthen boards uri test assertion and update JSDoc

* feat(web): new topic form GET handler with board context (ATB-31)

* refactor(web): add network error test and fix spinner text in new-topic form

* feat(web): new topic POST handler proxied to AppView (ATB-31)

* test(web): add missing POST /new-topic edge case tests (ATB-31)

- Non-numeric boardId validation test
- AppView 5xx error path with console.error assertion

* feat(web): success banner on board page after new topic (ATB-31)

* feat(web): reply form and POST /topics/:id/reply handler (ATB-31)

* test(web): add missing reply POST edge case tests (ATB-31)

- Non-numeric topic ID in POST handler
- Cookie forwarding assertion for AppView proxy call

* test(web): add missing reply POST error branch tests (ATB-31)

- AppView 5xx with console.error assertion
- 403 banned branch
- 403 non-JSON body fallback

* test(appview): update serializeBoard toEqual assertions to include uri field (ATB-31)

* fix(web): address code review feedback on ATB-31 compose forms

- Bruno: add uri field to Get Board and List All Boards docs and assertions
- boards.tsx: gate ?posted=1 success banner on auth?.authenticated
- new-topic.tsx: parse 403 JSON body to distinguish banned vs no-permission;
add logging to all silent catch blocks (parseBody, 400, 403 inner catches);
add else-if branch logging for unexpected 4xx responses
- topics.tsx: add uri to BoardResponse interface; remove unused rootPostId and
parentPostId hidden form fields; add logging to all silent catch blocks
(parseBody, 400, 403 inner catches); add else-if branch logging for 4xx
- tests: update hidden-field assertions for removed reply form fields; add URL
assertion to reply POST body test; add 400 non-JSON body test for new-topic;
add unauthenticated banner suppression test for boards

authored by

Malpercio and committed by
GitHub
817e470b e58f6ad8

+2486 -52
+4
apps/appview/src/routes/__tests__/boards.test.ts
··· 157 157 expect(board).toHaveProperty("slug"); 158 158 expect(board).toHaveProperty("sortOrder"); 159 159 expect(board).toHaveProperty("categoryUri"); 160 + 161 + // Verify uri field is present and correct (catches collection namespace typos) 162 + expect(board).toHaveProperty("uri"); 163 + expect(board.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.forum.board/board1`); 160 164 }); 161 165 162 166 it("does not leak internal fields (rkey, cid)", async () => {
+2
apps/appview/src/routes/__tests__/helpers-boards.test.ts
··· 73 73 expect(result).toEqual({ 74 74 id: "1", 75 75 did: "did:plc:forum", 76 + uri: "at://did:plc:forum/space.atbb.forum.board/3lbk9board", 76 77 name: "General Discussion", 77 78 description: "A place for general conversation", 78 79 slug: "general", ··· 97 98 expect(result).toEqual({ 98 99 id: "1", 99 100 did: "did:plc:forum", 101 + uri: "at://did:plc:forum/space.atbb.forum.board/3lbk9board", 100 102 name: "General Discussion", 101 103 description: null, 102 104 slug: null,
+2
apps/appview/src/routes/helpers.ts
··· 326 326 * { 327 327 * "id": "1234", // BigInt → string 328 328 * "did": "did:plc:...", 329 + * "uri": "at://did:plc:.../space.atbb.forum.board/...", // computed AT URI 329 330 * "name": "General Discussion", 330 331 * "description": "A place for..." | null, 331 332 * "slug": "general" | null, ··· 341 342 return { 342 343 id: serializeBigInt(board.id), 343 344 did: board.did, 345 + uri: `at://${board.did}/space.atbb.forum.board/${board.rkey}`, 344 346 name: board.name, 345 347 description: board.description, 346 348 slug: board.slug,
+32
apps/web/src/routes/__tests__/boards.test.tsx
··· 43 43 Promise.resolve({ 44 44 id: "42", 45 45 did: "did:plc:forum", 46 + uri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 46 47 name: "General Discussion", 47 48 description: "Talk about anything", 48 49 slug: null, ··· 391 392 const html = await res.text(); 392 393 // Empty fragment — no error display, no crash 393 394 expect(html.trim()).toBe(""); 395 + }); 396 + 397 + it("shows success banner for authenticated user with ?posted=1", async () => { 398 + mockFetch.mockResolvedValueOnce(authSession); 399 + setupSuccessfulFetch(); 400 + const routes = await loadBoardsRoutes(); 401 + const res = await routes.request("/boards/42?posted=1", { 402 + headers: { cookie: "atbb_session=token" }, 403 + }); 404 + expect(res.status).toBe(200); 405 + const html = await res.text(); 406 + expect(html).toContain("success-banner"); 407 + expect(html).toContain("posted"); 408 + }); 409 + 410 + it("does not show success banner for unauthenticated user even with ?posted=1", async () => { 411 + setupSuccessfulFetch(); 412 + const routes = await loadBoardsRoutes(); 413 + const res = await routes.request("/boards/42?posted=1"); 414 + expect(res.status).toBe(200); 415 + const html = await res.text(); 416 + expect(html).not.toContain("success-banner"); 417 + }); 418 + 419 + it("does not show success banner without ?posted=1", async () => { 420 + setupSuccessfulFetch(); 421 + const routes = await loadBoardsRoutes(); 422 + const res = await routes.request("/boards/42"); 423 + expect(res.status).toBe(200); 424 + const html = await res.text(); 425 + expect(html).not.toContain("success-banner"); 394 426 }); 395 427 });
+412
apps/web/src/routes/__tests__/new-topic.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createNewTopicRoutes", () => { 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 + function makeBoardResponse(overrides: Record<string, unknown> = {}) { 20 + return { 21 + ok: true, 22 + json: () => 23 + Promise.resolve({ 24 + id: "42", 25 + did: "did:plc:forum", 26 + uri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 27 + name: "General Discussion", 28 + description: "A board for general chat", 29 + slug: null, 30 + sortOrder: 1, 31 + categoryId: "7", 32 + categoryUri: null, 33 + createdAt: "2025-01-01T00:00:00.000Z", 34 + indexedAt: "2025-01-01T00:00:00.000Z", 35 + ...overrides, 36 + }), 37 + }; 38 + } 39 + 40 + const authSession = { 41 + ok: true, 42 + json: () => 43 + Promise.resolve({ 44 + authenticated: true, 45 + did: "did:plc:abc", 46 + handle: "alice.bsky.social", 47 + }), 48 + }; 49 + 50 + async function loadNewTopicRoutes() { 51 + const { createNewTopicRoutes } = await import("../new-topic.js"); 52 + return createNewTopicRoutes("http://localhost:3000"); 53 + } 54 + 55 + // ─── GET /new-topic ────────────────────────────────────────────────────────── 56 + 57 + it("redirects to / when boardId query param is missing", async () => { 58 + const routes = await loadNewTopicRoutes(); 59 + const res = await routes.request("/new-topic"); 60 + expect(res.status).toBe(302); 61 + expect(res.headers.get("location")).toBe("/"); 62 + }); 63 + 64 + it("redirects to / when boardId is non-numeric", async () => { 65 + const routes = await loadNewTopicRoutes(); 66 + const res = await routes.request("/new-topic?boardId=abc"); 67 + expect(res.status).toBe(302); 68 + expect(res.headers.get("location")).toBe("/"); 69 + }); 70 + 71 + it("shows login prompt for unauthenticated user", async () => { 72 + // No session cookie → getSession returns unauthenticated → board fetch NOT called 73 + const routes = await loadNewTopicRoutes(); 74 + const res = await routes.request("/new-topic?boardId=42"); 75 + expect(res.status).toBe(200); 76 + const html = await res.text(); 77 + expect(html).toContain("Log in"); 78 + expect(html).toContain("to create a topic"); 79 + expect(html).not.toContain("<form"); 80 + }); 81 + 82 + it("returns 404 when board not found", async () => { 83 + mockFetch.mockResolvedValueOnce(authSession); 84 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" }); 85 + const routes = await loadNewTopicRoutes(); 86 + const res = await routes.request("/new-topic?boardId=42", { 87 + headers: { cookie: "atbb_session=token" }, 88 + }); 89 + expect(res.status).toBe(404); 90 + const html = await res.text(); 91 + expect(html).toContain("Not Found"); 92 + }); 93 + 94 + it("redirects to / on AppView server error loading board", async () => { 95 + mockFetch.mockResolvedValueOnce(authSession); 96 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" }); 97 + const routes = await loadNewTopicRoutes(); 98 + const res = await routes.request("/new-topic?boardId=42", { 99 + headers: { cookie: "atbb_session=token" }, 100 + }); 101 + expect(res.status).toBe(302); 102 + expect(res.headers.get("location")).toBe("/"); 103 + }); 104 + 105 + it("renders form with boardUri hidden field when authenticated", async () => { 106 + mockFetch.mockResolvedValueOnce(authSession); 107 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 108 + const routes = await loadNewTopicRoutes(); 109 + const res = await routes.request("/new-topic?boardId=42", { 110 + headers: { cookie: "atbb_session=token" }, 111 + }); 112 + expect(res.status).toBe(200); 113 + const html = await res.text(); 114 + expect(html).toContain("at://did:plc:forum/space.atbb.forum.board/boardrkey1"); 115 + expect(html).toContain('name="boardUri"'); 116 + }); 117 + 118 + it("renders form with boardId hidden field for redirect after success", async () => { 119 + mockFetch.mockResolvedValueOnce(authSession); 120 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 121 + const routes = await loadNewTopicRoutes(); 122 + const res = await routes.request("/new-topic?boardId=42", { 123 + headers: { cookie: "atbb_session=token" }, 124 + }); 125 + const html = await res.text(); 126 + expect(html).toContain('name="boardId"'); 127 + expect(html).toContain('value="42"'); 128 + }); 129 + 130 + it("renders form with textarea for message text", async () => { 131 + mockFetch.mockResolvedValueOnce(authSession); 132 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 133 + const routes = await loadNewTopicRoutes(); 134 + const res = await routes.request("/new-topic?boardId=42", { 135 + headers: { cookie: "atbb_session=token" }, 136 + }); 137 + const html = await res.text(); 138 + expect(html).toContain('<textarea'); 139 + expect(html).toContain('name="text"'); 140 + }); 141 + 142 + it("renders character counter element", async () => { 143 + mockFetch.mockResolvedValueOnce(authSession); 144 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 145 + const routes = await loadNewTopicRoutes(); 146 + const res = await routes.request("/new-topic?boardId=42", { 147 + headers: { cookie: "atbb_session=token" }, 148 + }); 149 + const html = await res.text(); 150 + expect(html).toContain("char-count"); 151 + expect(html).toContain("300"); 152 + }); 153 + 154 + it("renders board name in page", async () => { 155 + mockFetch.mockResolvedValueOnce(authSession); 156 + mockFetch.mockResolvedValueOnce(makeBoardResponse({ name: "My Awesome Board" })); 157 + const routes = await loadNewTopicRoutes(); 158 + const res = await routes.request("/new-topic?boardId=42", { 159 + headers: { cookie: "atbb_session=token" }, 160 + }); 161 + const html = await res.text(); 162 + expect(html).toContain("My Awesome Board"); 163 + }); 164 + 165 + it("uses hx-post for form submission", async () => { 166 + mockFetch.mockResolvedValueOnce(authSession); 167 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 168 + const routes = await loadNewTopicRoutes(); 169 + const res = await routes.request("/new-topic?boardId=42", { 170 + headers: { cookie: "atbb_session=token" }, 171 + }); 172 + const html = await res.text(); 173 + expect(html).toContain("hx-post"); 174 + expect(html).toContain('hx-target="#form-error"'); 175 + }); 176 + 177 + it("redirects to / on network error loading board (not a 503 page)", async () => { 178 + // Unlike /boards/:id which returns a 503 HTML error page, this form redirects 179 + // to safety on network errors — the user can navigate back and try again. 180 + mockFetch.mockResolvedValueOnce(authSession); 181 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 182 + const routes = await loadNewTopicRoutes(); 183 + const res = await routes.request("/new-topic?boardId=42", { 184 + headers: { cookie: "atbb_session=token" }, 185 + }); 186 + expect(res.status).toBe(302); 187 + expect(res.headers.get("location")).toBe("/"); 188 + }); 189 + 190 + // ─── POST /new-topic ───────────────────────────────────────────────────────── 191 + 192 + describe("POST /new-topic", () => { 193 + function makePostBody(overrides: Record<string, string> = {}) { 194 + return new URLSearchParams({ 195 + text: "Hello forum!", 196 + boardUri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 197 + boardId: "42", 198 + ...overrides, 199 + }).toString(); 200 + } 201 + 202 + const postHeaders = { 203 + "Content-Type": "application/x-www-form-urlencoded", 204 + cookie: "atbb_session=token", 205 + }; 206 + 207 + it("returns HX-Redirect to board on successful topic creation", async () => { 208 + mockFetch.mockResolvedValueOnce({ 209 + ok: true, 210 + status: 201, 211 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid1" }), 212 + }); 213 + const routes = await loadNewTopicRoutes(); 214 + const res = await routes.request("/new-topic", { 215 + method: "POST", 216 + headers: postHeaders, 217 + body: makePostBody(), 218 + }); 219 + expect(res.status).toBe(200); 220 + expect(res.headers.get("HX-Redirect")).toBe("/boards/42?posted=1"); 221 + }); 222 + 223 + it("returns error fragment when text field is missing", async () => { 224 + const routes = await loadNewTopicRoutes(); 225 + const res = await routes.request("/new-topic", { 226 + method: "POST", 227 + headers: postHeaders, 228 + body: makePostBody({ text: "" }), 229 + }); 230 + expect(res.status).toBe(200); 231 + const html = await res.text(); 232 + expect(html).toContain("form-error"); 233 + expect(html).toContain("required"); 234 + // No fetch call made (validated before proxying) 235 + expect(mockFetch).not.toHaveBeenCalled(); 236 + }); 237 + 238 + it("returns error fragment when boardUri field is missing", async () => { 239 + const routes = await loadNewTopicRoutes(); 240 + const res = await routes.request("/new-topic", { 241 + method: "POST", 242 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 243 + body: new URLSearchParams({ text: "Hello", boardId: "42" }).toString(), 244 + }); 245 + expect(res.status).toBe(200); 246 + const html = await res.text(); 247 + expect(html).toContain("form-error"); 248 + expect(mockFetch).not.toHaveBeenCalled(); 249 + }); 250 + 251 + it("returns error fragment when boardId is non-numeric", async () => { 252 + const routes = await loadNewTopicRoutes(); 253 + const res = await routes.request("/new-topic", { 254 + method: "POST", 255 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 256 + body: new URLSearchParams({ 257 + text: "Hello", 258 + boardUri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 259 + boardId: "abc", 260 + }).toString(), 261 + }); 262 + expect(res.status).toBe(200); 263 + const html = await res.text(); 264 + expect(html).toContain("form-error"); 265 + expect(mockFetch).not.toHaveBeenCalled(); 266 + }); 267 + 268 + it("returns login error fragment when AppView returns 401", async () => { 269 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }); 270 + const routes = await loadNewTopicRoutes(); 271 + const res = await routes.request("/new-topic", { 272 + method: "POST", 273 + headers: postHeaders, 274 + body: makePostBody(), 275 + }); 276 + expect(res.status).toBe(200); 277 + const html = await res.text(); 278 + expect(html).toContain("form-error"); 279 + expect(html).toContain("logged in"); 280 + }); 281 + 282 + it("returns banned error fragment when AppView returns 403", async () => { 283 + mockFetch.mockResolvedValueOnce({ 284 + ok: false, 285 + status: 403, 286 + json: () => Promise.resolve({ error: "You are banned from this forum" }), 287 + }); 288 + const routes = await loadNewTopicRoutes(); 289 + const res = await routes.request("/new-topic", { 290 + method: "POST", 291 + headers: postHeaders, 292 + body: makePostBody(), 293 + }); 294 + expect(res.status).toBe(200); 295 + const html = await res.text(); 296 + expect(html).toContain("form-error"); 297 + expect(html).toContain("banned"); 298 + }); 299 + 300 + it("returns AppView validation message on 400", async () => { 301 + mockFetch.mockResolvedValueOnce({ 302 + ok: false, 303 + status: 400, 304 + json: () => Promise.resolve({ error: "Text must be between 1 and 300 graphemes" }), 305 + }); 306 + const routes = await loadNewTopicRoutes(); 307 + const res = await routes.request("/new-topic", { 308 + method: "POST", 309 + headers: postHeaders, 310 + body: makePostBody(), 311 + }); 312 + expect(res.status).toBe(200); 313 + const html = await res.text(); 314 + expect(html).toContain("form-error"); 315 + expect(html).toContain("300 graphemes"); 316 + }); 317 + 318 + it("returns default error message and logs when AppView 400 body is not JSON", async () => { 319 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 320 + mockFetch.mockResolvedValueOnce({ 321 + ok: false, 322 + status: 400, 323 + json: () => Promise.reject(new SyntaxError("Unexpected token")), 324 + }); 325 + const routes = await loadNewTopicRoutes(); 326 + const res = await routes.request("/new-topic", { 327 + method: "POST", 328 + headers: postHeaders, 329 + body: makePostBody(), 330 + }); 331 + expect(res.status).toBe(200); 332 + const html = await res.text(); 333 + expect(html).toContain("form-error"); 334 + expect(html).toContain("Something went wrong"); 335 + expect(consoleSpy).toHaveBeenCalledWith( 336 + "Failed to parse AppView 400 response body for new topic", 337 + expect.any(Object) 338 + ); 339 + consoleSpy.mockRestore(); 340 + }); 341 + 342 + it("returns generic error fragment and logs on AppView 5xx", async () => { 343 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 344 + mockFetch.mockResolvedValueOnce({ ok: false, status: 502 }); 345 + const routes = await loadNewTopicRoutes(); 346 + const res = await routes.request("/new-topic", { 347 + method: "POST", 348 + headers: postHeaders, 349 + body: makePostBody(), 350 + }); 351 + expect(res.status).toBe(200); 352 + const html = await res.text(); 353 + expect(html).toContain("form-error"); 354 + expect(html).toContain("Something went wrong"); 355 + expect(consoleSpy).toHaveBeenCalledWith( 356 + "AppView returned server error for new topic", 357 + expect.objectContaining({ status: 502 }) 358 + ); 359 + consoleSpy.mockRestore(); 360 + }); 361 + 362 + it("returns unavailable error fragment on AppView network error", async () => { 363 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 364 + const routes = await loadNewTopicRoutes(); 365 + const res = await routes.request("/new-topic", { 366 + method: "POST", 367 + headers: postHeaders, 368 + body: makePostBody(), 369 + }); 370 + expect(res.status).toBe(200); 371 + const html = await res.text(); 372 + expect(html).toContain("form-error"); 373 + expect(html).toContain("unavailable"); 374 + }); 375 + 376 + it("forwards session cookie to AppView", async () => { 377 + mockFetch.mockResolvedValueOnce({ 378 + ok: true, 379 + status: 201, 380 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid1" }), 381 + }); 382 + const routes = await loadNewTopicRoutes(); 383 + await routes.request("/new-topic", { 384 + method: "POST", 385 + headers: postHeaders, 386 + body: makePostBody(), 387 + }); 388 + const [, fetchOptions] = mockFetch.mock.calls[0]; 389 + expect(fetchOptions.headers["Cookie"]).toContain("atbb_session=token"); 390 + }); 391 + 392 + it("sends text and boardUri as JSON to AppView", async () => { 393 + mockFetch.mockResolvedValueOnce({ 394 + ok: true, 395 + status: 201, 396 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid1" }), 397 + }); 398 + const routes = await loadNewTopicRoutes(); 399 + await routes.request("/new-topic", { 400 + method: "POST", 401 + headers: postHeaders, 402 + body: makePostBody({ text: "My post text" }), 403 + }); 404 + const [fetchUrl, fetchOptions] = mockFetch.mock.calls[0]; 405 + const sentBody = JSON.parse(fetchOptions.body); 406 + expect(sentBody.text).toBe("My post text"); 407 + expect(sentBody.boardUri).toBe("at://did:plc:forum/space.atbb.forum.board/boardrkey1"); 408 + expect(fetchUrl).toContain("/api/topics"); 409 + }); 410 + }); 411 + 412 + });
-30
apps/web/src/routes/__tests__/stubs.test.tsx
··· 105 105 expect(res.headers.get("location")).toBe("/"); 106 106 }); 107 107 108 - it("GET /new-topic returns 200 with new topic title", async () => { 109 - const { createNewTopicRoutes } = await import("../new-topic.js"); 110 - const routes = createNewTopicRoutes("http://localhost:3000"); 111 - const res = await routes.request("/new-topic"); 112 - expect(res.status).toBe(200); 113 - const html = await res.text(); 114 - expect(html).toContain("New Topic — atBB Forum"); 115 - }); 116 - 117 - it("GET /new-topic shows 'Log in to create a topic' when unauthenticated", async () => { 118 - const { createNewTopicRoutes } = await import("../new-topic.js"); 119 - const routes = createNewTopicRoutes("http://localhost:3000"); 120 - const res = await routes.request("/new-topic"); 121 - const html = await res.text(); 122 - expect(html).toContain("Log in"); 123 - expect(html).toContain("to create a topic"); 124 - expect(html).not.toContain("Compose form"); 125 - }); 126 - 127 - it("GET /new-topic shows compose form placeholder when authenticated", async () => { 128 - mockFetch.mockResolvedValueOnce(authenticatedSession); 129 - const { createNewTopicRoutes } = await import("../new-topic.js"); 130 - const routes = createNewTopicRoutes("http://localhost:3000"); 131 - const res = await routes.request("/new-topic", { 132 - headers: { cookie: "atbb_session=token" }, 133 - }); 134 - const html = await res.text(); 135 - expect(html).toContain("Compose form will appear here"); 136 - expect(html).not.toContain("Log in"); 137 - }); 138 108 });
+252 -4
apps/web/src/routes/__tests__/topics.test.tsx
··· 16 16 mockFetch.mockReset(); 17 17 }); 18 18 19 + // ─── Auth session helper ────────────────────────────────────────────────── 20 + 21 + const authSession = { 22 + ok: true, 23 + status: 200, 24 + json: () => 25 + Promise.resolve({ 26 + authenticated: true, 27 + did: "did:plc:user", 28 + handle: "user.bsky.social", 29 + }), 30 + }; 31 + 19 32 // ─── Mock response helpers ──────────────────────────────────────────────── 20 33 21 34 function mockResponse(body: unknown, ok = true, status = 200) { ··· 55 68 Promise.resolve({ 56 69 id: "42", 57 70 did: "did:plc:forum", 71 + uri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 58 72 name: "General Discussion", 59 73 description: null, 60 74 slug: null, ··· 384 398 expect(html).toContain("locked"); 385 399 expect(html).toContain("Replies are disabled"); 386 400 expect(html).not.toContain("Log in to reply"); 387 - expect(html).not.toContain("Reply form coming soon"); 401 + expect(html).not.toContain('name="text"'); 388 402 }); 389 403 390 404 it("shows locked message (not reply form) when topic is locked and user is authenticated", async () => { ··· 401 415 }); 402 416 const html = await res.text(); 403 417 expect(html).toContain("Replies are disabled"); 404 - expect(html).not.toContain("Reply form coming soon"); 418 + expect(html).not.toContain('name="text"'); 405 419 expect(html).not.toContain("Log in"); 406 420 }); 407 421 ··· 414 428 const html = await res.text(); 415 429 expect(html).toContain("Log in"); 416 430 expect(html).toContain("to reply"); 417 - expect(html).not.toContain("Reply form coming soon"); 431 + expect(html).not.toContain('name="text"'); 418 432 }); 419 433 420 434 it("shows reply form slot for authenticated users", async () => { ··· 434 448 headers: { cookie: "atbb_session=token" }, 435 449 }); 436 450 const html = await res.text(); 437 - expect(html).toContain("Reply form coming soon"); 451 + expect(html).toContain('name="text"'); 438 452 expect(html).not.toContain("Log in"); 439 453 }); 440 454 ··· 576 590 expect(res.status).toBe(200); 577 591 const html = await res.text(); 578 592 expect(html.trim()).toBe(""); 593 + }); 594 + 595 + // ─── Reply form rendering ──────────────────────────────────────────────────── 596 + 597 + it("shows reply form with textarea when authenticated", async () => { 598 + mockFetch.mockResolvedValueOnce(authSession); 599 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); 600 + const routes = await loadTopicsRoutes(); 601 + const res = await routes.request("/topics/1", { 602 + headers: { cookie: "atbb_session=token" }, 603 + }); 604 + expect(res.status).toBe(200); 605 + const html = await res.text(); 606 + expect(html).toContain('name="text"'); 607 + expect(html).toContain("<textarea"); 608 + }); 609 + 610 + it("shows 'Log in to reply' instead of form when unauthenticated", async () => { 611 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); 612 + const routes = await loadTopicsRoutes(); 613 + const res = await routes.request("/topics/1"); 614 + const html = await res.text(); 615 + expect(html).toContain("Log in"); 616 + expect(html).toContain("to reply"); 617 + expect(html).not.toContain('name="rootPostId"'); 618 + }); 619 + 620 + it("shows locked message instead of form when topic is locked", async () => { 621 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ locked: true })); 622 + const routes = await loadTopicsRoutes(); 623 + const res = await routes.request("/topics/1"); 624 + const html = await res.text(); 625 + expect(html).toContain("locked"); 626 + expect(html).not.toContain('name="rootPostId"'); 627 + }); 628 + 629 + it("uses hx-post for reply form submission", async () => { 630 + mockFetch.mockResolvedValueOnce(authSession); 631 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); 632 + const routes = await loadTopicsRoutes(); 633 + const res = await routes.request("/topics/1", { 634 + headers: { cookie: "atbb_session=token" }, 635 + }); 636 + const html = await res.text(); 637 + expect(html).toContain("hx-post"); 638 + expect(html).toContain("/topics/1/reply"); 639 + }); 640 + 641 + // ─── POST /topics/:id/reply ─────────────────────────────────────────────────── 642 + 643 + it("returns HX-Redirect to topic on successful reply", async () => { 644 + mockFetch.mockResolvedValueOnce({ 645 + ok: true, 646 + status: 201, 647 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid2" }), 648 + }); 649 + const routes = await loadTopicsRoutes(); 650 + const res = await routes.request("/topics/1/reply", { 651 + method: "POST", 652 + headers: { 653 + "Content-Type": "application/x-www-form-urlencoded", 654 + cookie: "atbb_session=token", 655 + }, 656 + body: new URLSearchParams({ text: "Great post!" }).toString(), 657 + }); 658 + expect(res.status).toBe(200); 659 + expect(res.headers.get("HX-Redirect")).toBe("/topics/1"); 660 + }); 661 + 662 + it("returns error fragment when text is missing", async () => { 663 + const routes = await loadTopicsRoutes(); 664 + const res = await routes.request("/topics/1/reply", { 665 + method: "POST", 666 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 667 + body: new URLSearchParams({ text: "" }).toString(), 668 + }); 669 + expect(res.status).toBe(200); 670 + const html = await res.text(); 671 + expect(html).toContain("form-error"); 672 + expect(html).toContain("required"); 673 + expect(mockFetch).not.toHaveBeenCalled(); 674 + }); 675 + 676 + it("returns login error fragment when AppView returns 401", async () => { 677 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }); 678 + const routes = await loadTopicsRoutes(); 679 + const res = await routes.request("/topics/1/reply", { 680 + method: "POST", 681 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 682 + body: new URLSearchParams({ text: "Hello" }).toString(), 683 + }); 684 + const html = await res.text(); 685 + expect(html).toContain("form-error"); 686 + expect(html).toContain("logged in"); 687 + }); 688 + 689 + it("returns locked error fragment when AppView returns 403 locked", async () => { 690 + mockFetch.mockResolvedValueOnce({ 691 + ok: false, 692 + status: 403, 693 + json: () => 694 + Promise.resolve({ error: "This topic is locked and not accepting new replies" }), 695 + }); 696 + const routes = await loadTopicsRoutes(); 697 + const res = await routes.request("/topics/1/reply", { 698 + method: "POST", 699 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 700 + body: new URLSearchParams({ text: "Hello" }).toString(), 701 + }); 702 + const html = await res.text(); 703 + expect(html).toContain("form-error"); 704 + expect(html).toContain("locked"); 705 + }); 706 + 707 + it("returns banned error fragment when AppView returns 403 banned", async () => { 708 + mockFetch.mockResolvedValueOnce({ 709 + ok: false, 710 + status: 403, 711 + json: () => Promise.resolve({ error: "You are banned from this forum" }), 712 + }); 713 + const routes = await loadTopicsRoutes(); 714 + const res = await routes.request("/topics/1/reply", { 715 + method: "POST", 716 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 717 + body: new URLSearchParams({ text: "Hello" }).toString(), 718 + }); 719 + const html = await res.text(); 720 + expect(html).toContain("form-error"); 721 + expect(html).toContain("banned"); 722 + }); 723 + 724 + it("returns generic 403 message when AppView 403 body is not JSON", async () => { 725 + mockFetch.mockResolvedValueOnce({ 726 + ok: false, 727 + status: 403, 728 + json: () => Promise.reject(new SyntaxError("Unexpected end of JSON")), 729 + }); 730 + const routes = await loadTopicsRoutes(); 731 + const res = await routes.request("/topics/1/reply", { 732 + method: "POST", 733 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 734 + body: new URLSearchParams({ text: "Hello" }).toString(), 735 + }); 736 + const html = await res.text(); 737 + expect(html).toContain("form-error"); 738 + expect(html).toContain("not allowed to reply"); 739 + }); 740 + 741 + it("returns generic error fragment and logs on AppView 5xx", async () => { 742 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 743 + mockFetch.mockResolvedValueOnce({ ok: false, status: 502 }); 744 + const routes = await loadTopicsRoutes(); 745 + const res = await routes.request("/topics/1/reply", { 746 + method: "POST", 747 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 748 + body: new URLSearchParams({ text: "Hello" }).toString(), 749 + }); 750 + expect(res.status).toBe(200); 751 + const html = await res.text(); 752 + expect(html).toContain("form-error"); 753 + expect(html).toContain("Something went wrong"); 754 + expect(consoleSpy).toHaveBeenCalledWith( 755 + "AppView returned server error for reply", 756 + expect.objectContaining({ status: 502 }) 757 + ); 758 + consoleSpy.mockRestore(); 759 + }); 760 + 761 + it("returns error fragment on AppView network error", async () => { 762 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 763 + const routes = await loadTopicsRoutes(); 764 + const res = await routes.request("/topics/1/reply", { 765 + method: "POST", 766 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 767 + body: new URLSearchParams({ text: "Hello" }).toString(), 768 + }); 769 + const html = await res.text(); 770 + expect(html).toContain("form-error"); 771 + expect(html).toContain("unavailable"); 772 + }); 773 + 774 + it("sends text, rootPostId, and parentPostId to AppView", async () => { 775 + mockFetch.mockResolvedValueOnce({ 776 + ok: true, 777 + status: 201, 778 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid2" }), 779 + }); 780 + const routes = await loadTopicsRoutes(); 781 + await routes.request("/topics/99/reply", { 782 + method: "POST", 783 + headers: { 784 + "Content-Type": "application/x-www-form-urlencoded", 785 + cookie: "atbb_session=token", 786 + }, 787 + body: new URLSearchParams({ text: "My reply" }).toString(), 788 + }); 789 + const [fetchUrl, fetchOptions] = mockFetch.mock.calls[0]; 790 + const sentBody = JSON.parse(fetchOptions.body); 791 + expect(sentBody.text).toBe("My reply"); 792 + expect(sentBody.rootPostId).toBe("99"); 793 + expect(sentBody.parentPostId).toBe("99"); 794 + expect(fetchUrl).toContain("/api/posts"); 795 + }); 796 + 797 + it("returns error fragment when topic ID is non-numeric", async () => { 798 + const routes = await loadTopicsRoutes(); 799 + const res = await routes.request("/topics/not-a-number/reply", { 800 + method: "POST", 801 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 802 + body: new URLSearchParams({ text: "Hello" }).toString(), 803 + }); 804 + expect(res.status).toBe(200); 805 + const html = await res.text(); 806 + expect(html).toContain("form-error"); 807 + expect(mockFetch).not.toHaveBeenCalled(); 808 + }); 809 + 810 + it("forwards session cookie to AppView", async () => { 811 + mockFetch.mockResolvedValueOnce({ 812 + ok: true, 813 + status: 201, 814 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid2" }), 815 + }); 816 + const routes = await loadTopicsRoutes(); 817 + await routes.request("/topics/1/reply", { 818 + method: "POST", 819 + headers: { 820 + "Content-Type": "application/x-www-form-urlencoded", 821 + cookie: "atbb_session=token", 822 + }, 823 + body: new URLSearchParams({ text: "My reply" }).toString(), 824 + }); 825 + const [, fetchOptions] = mockFetch.mock.calls[0]; 826 + expect(fetchOptions.headers["Cookie"]).toContain("atbb_session=token"); 579 827 }); 580 828 });
+8
apps/web/src/routes/boards.tsx
··· 11 11 interface BoardResponse { 12 12 id: string; 13 13 did: string; 14 + uri: string; 14 15 name: string; 15 16 description: string | null; 16 17 slug: string | null; ··· 178 179 } 179 180 180 181 // ── Full page mode ──────────────────────────────────────────────────── 182 + const postedSuccess = c.req.query("posted") === "1"; 181 183 const auth = await getSession(appviewUrl, c.req.header("cookie")); 182 184 183 185 // Stage 1: fetch board metadata and topics in parallel ··· 256 258 title={board.name} 257 259 description={board.description ?? undefined} 258 260 /> 261 + 262 + {postedSuccess && auth?.authenticated && ( 263 + <div class="success-banner"> 264 + Your topic has been posted. It will appear shortly. 265 + </div> 266 + )} 259 267 260 268 {auth?.authenticated ? ( 261 269 <a href={`/new-topic?boardId=${boardId}`} class="btn">
+228 -16
apps/web/src/routes/new-topic.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 - import { PageHeader } from "../components/index.js"; 3 + import { PageHeader, ErrorDisplay } from "../components/index.js"; 4 4 import { getSession } from "../lib/session.js"; 5 + import { fetchApi } from "../lib/api.js"; 6 + import { 7 + isProgrammingError, 8 + isNotFoundError, 9 + } from "../lib/errors.js"; 10 + 11 + interface BoardResponse { 12 + id: string; 13 + did: string; 14 + uri: string; 15 + name: string; 16 + description: string | null; 17 + slug: string | null; 18 + sortOrder: number | null; 19 + categoryId: string; 20 + categoryUri: string | null; 21 + createdAt: string | null; 22 + indexedAt: string | null; 23 + } 24 + 25 + const CHAR_COUNTER_SCRIPT = ` 26 + function updateCharCount(el) { 27 + var seg = new Intl.Segmenter(); 28 + var n = Array.from(seg.segment(el.value)).length; 29 + var counter = document.getElementById("char-count"); 30 + counter.textContent = (300 - n) + " left"; 31 + counter.dataset.over = n > 300 ? "true" : "false"; 32 + } 33 + `; 5 34 6 35 export function createNewTopicRoutes(appviewUrl: string) { 7 - return new Hono().get("/new-topic", async (c) => { 8 - const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 - return c.html( 10 - <BaseLayout title="New Topic — atBB Forum" auth={auth}> 11 - <PageHeader title="New Topic" description="Compose a new topic." /> 12 - {auth.authenticated ? ( 13 - <p>Compose form will appear here.</p> 14 - ) : ( 15 - <p> 16 - <a href="/login">Log in</a> to create a topic. 17 - </p> 18 - )} 19 - </BaseLayout> 20 - ); 21 - }); 36 + return new Hono() 37 + .get("/new-topic", async (c) => { 38 + const boardIdParam = c.req.query("boardId"); 39 + 40 + // boardId required and must be numeric 41 + if (!boardIdParam || !/^\d+$/.test(boardIdParam)) { 42 + return c.redirect("/"); 43 + } 44 + 45 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 46 + 47 + if (!auth.authenticated) { 48 + return c.html( 49 + <BaseLayout title="New Topic — atBB Forum" auth={auth}> 50 + <PageHeader title="New Topic" /> 51 + <p> 52 + <a href="/login">Log in</a> to create a topic. 53 + </p> 54 + </BaseLayout> 55 + ); 56 + } 57 + 58 + // Fetch board data — need the AT URI for the hidden form field 59 + let board: BoardResponse; 60 + try { 61 + board = await fetchApi<BoardResponse>(`/boards/${boardIdParam}`); 62 + } catch (error) { 63 + if (isProgrammingError(error)) throw error; 64 + 65 + if (isNotFoundError(error)) { 66 + return c.html( 67 + <BaseLayout title="Not Found — atBB Forum" auth={auth}> 68 + <ErrorDisplay message="This board doesn't exist." /> 69 + </BaseLayout>, 70 + 404 71 + ); 72 + } 73 + 74 + console.error("Failed to load board for new topic form", { 75 + operation: "GET /new-topic", 76 + boardId: boardIdParam, 77 + error: error instanceof Error ? error.message : String(error), 78 + }); 79 + return c.redirect("/"); 80 + } 81 + 82 + return c.html( 83 + <BaseLayout title="New Topic — atBB Forum" auth={auth}> 84 + <nav class="breadcrumb"> 85 + <a href="/">Home</a> 86 + {" / "} 87 + <a href={`/boards/${board.id}`}>{board.name}</a> 88 + {" / "} 89 + <span>New Topic</span> 90 + </nav> 91 + 92 + <PageHeader title="New Topic" description={`Posting to ${board.name}`} /> 93 + 94 + <form 95 + hx-post="/new-topic" 96 + hx-target="#form-error" 97 + hx-swap="innerHTML" 98 + hx-indicator="#spinner" 99 + hx-disabled-elt="[type=submit]" 100 + > 101 + <input type="hidden" name="boardUri" value={board.uri} /> 102 + <input type="hidden" name="boardId" value={board.id} /> 103 + 104 + <div class="form-group"> 105 + <label for="compose-text">Your message</label> 106 + <textarea 107 + id="compose-text" 108 + name="text" 109 + rows={8} 110 + placeholder="What's on your mind?" 111 + oninput="updateCharCount(this)" 112 + /> 113 + <div id="char-count" class="char-count">300 left</div> 114 + </div> 115 + 116 + <div id="form-error" /> 117 + 118 + <div class="form-actions"> 119 + <button type="submit" class="btn btn-primary"> 120 + Post Topic 121 + <span id="spinner" class="htmx-indicator">Posting…</span> 122 + </button> 123 + </div> 124 + </form> 125 + 126 + <script dangerouslySetInnerHTML={{ __html: CHAR_COUNTER_SCRIPT }} /> 127 + </BaseLayout> 128 + ); 129 + }) 130 + .post("/new-topic", async (c) => { 131 + const cookieHeader = c.req.header("cookie") ?? ""; 132 + 133 + // Parse URL-encoded form body (HTMX default encoding) 134 + let body: Record<string, string | File>; 135 + try { 136 + body = await c.req.parseBody(); 137 + } catch (error) { 138 + console.error("Failed to parse request body for POST /new-topic", { 139 + operation: "POST /new-topic", 140 + error: error instanceof Error ? error.message : String(error), 141 + }); 142 + return c.html(<p class="form-error">Invalid form submission.</p>); 143 + } 144 + 145 + const { text, boardUri, boardId } = body; 146 + 147 + // Validate required fields before proxying 148 + if (typeof text !== "string" || !text.trim()) { 149 + return c.html(<p class="form-error">Message text is required.</p>); 150 + } 151 + if (typeof boardUri !== "string" || !boardUri.trim()) { 152 + return c.html( 153 + <p class="form-error">Board information is missing. Please try again.</p> 154 + ); 155 + } 156 + if (typeof boardId !== "string" || !/^\d+$/.test(boardId)) { 157 + return c.html( 158 + <p class="form-error">Board information is missing. Please try again.</p> 159 + ); 160 + } 161 + 162 + // Proxy to AppView 163 + let appviewRes: Response; 164 + try { 165 + appviewRes = await fetch(`${appviewUrl}/api/topics`, { 166 + method: "POST", 167 + headers: { 168 + "Content-Type": "application/json", 169 + "Cookie": cookieHeader, 170 + }, 171 + body: JSON.stringify({ text, boardUri }), 172 + }); 173 + } catch (error) { 174 + if (isProgrammingError(error)) throw error; 175 + console.error("Failed to proxy new topic to AppView", { 176 + operation: "POST /new-topic", 177 + error: error instanceof Error ? error.message : String(error), 178 + }); 179 + return c.html( 180 + <p class="form-error">Forum temporarily unavailable. Please try again.</p> 181 + ); 182 + } 183 + 184 + // Success: instruct HTMX to navigate to the board with a flash message 185 + if (appviewRes.status === 201) { 186 + const headers = new Headers(); 187 + headers.set("HX-Redirect", `/boards/${boardId}?posted=1`); 188 + return new Response(null, { status: 200, headers }); 189 + } 190 + 191 + // Map AppView error to user-friendly message 192 + let errorMessage = "Something went wrong. Please try again."; 193 + 194 + if (appviewRes.status === 401) { 195 + errorMessage = "You must be logged in to post."; 196 + } else if (appviewRes.status === 403) { 197 + try { 198 + const errBody = (await appviewRes.json()) as { error?: string }; 199 + const msg = errBody.error ?? ""; 200 + if (msg.toLowerCase().includes("banned")) { 201 + errorMessage = "You are banned from this forum."; 202 + } else { 203 + errorMessage = msg || "You are not allowed to create topics."; 204 + } 205 + } catch { 206 + console.error("Failed to parse AppView 403 response body for new topic", { 207 + operation: "POST /new-topic", 208 + }); 209 + errorMessage = "You are not allowed to create topics."; 210 + } 211 + } else if (appviewRes.status === 400) { 212 + try { 213 + const errBody = (await appviewRes.json()) as { error?: string }; 214 + errorMessage = errBody.error ?? errorMessage; 215 + } catch { 216 + console.error("Failed to parse AppView 400 response body for new topic", { 217 + operation: "POST /new-topic", 218 + }); 219 + } 220 + } else if (appviewRes.status >= 400 && appviewRes.status < 500) { 221 + console.error("AppView returned unexpected client error for new topic", { 222 + operation: "POST /new-topic", 223 + status: appviewRes.status, 224 + }); 225 + } else if (appviewRes.status >= 500) { 226 + console.error("AppView returned server error for new topic", { 227 + operation: "POST /new-topic", 228 + status: appviewRes.status, 229 + }); 230 + } 231 + 232 + return c.html(<p class="form-error">{errorMessage}</p>); 233 + }); 22 234 }
+146 -1
apps/web/src/routes/topics.tsx
··· 41 41 interface BoardResponse { 42 42 id: string; 43 43 did: string; 44 + uri: string; 44 45 name: string; 45 46 description: string | null; 46 47 slug: string | null; ··· 66 67 // ─── Constants ──────────────────────────────────────────────────────────────── 67 68 68 69 const REPLIES_PER_PAGE = 25; 70 + 71 + const REPLY_CHAR_COUNTER_SCRIPT = ` 72 + function updateReplyCharCount(el) { 73 + var seg = new Intl.Segmenter(); 74 + var n = Array.from(seg.segment(el.value)).length; 75 + var counter = document.getElementById("reply-char-count"); 76 + counter.textContent = (300 - n) + " left"; 77 + counter.dataset.over = n > 300 ? "true" : "false"; 78 + } 79 + `; 69 80 70 81 // ─── Inline components ──────────────────────────────────────────────────────── 71 82 ··· 319 330 {topicData.locked ? ( 320 331 <p>This topic is locked. Replies are disabled.</p> 321 332 ) : auth?.authenticated ? ( 322 - <p>Reply form coming soon.</p> 333 + <> 334 + <form 335 + hx-post={`/topics/${topicId}/reply`} 336 + hx-target="#reply-form-error" 337 + hx-swap="innerHTML" 338 + hx-indicator="#reply-spinner" 339 + hx-disabled-elt="[type=submit]" 340 + > 341 + 342 + <div class="form-group"> 343 + <label for="reply-text">Your reply</label> 344 + <textarea 345 + id="reply-text" 346 + name="text" 347 + rows={5} 348 + placeholder="Write a reply…" 349 + oninput="updateReplyCharCount(this)" 350 + /> 351 + <div id="reply-char-count" class="char-count">300 left</div> 352 + </div> 353 + 354 + <div id="reply-form-error" /> 355 + 356 + <div class="form-actions"> 357 + <button type="submit" class="btn btn-primary"> 358 + Post Reply 359 + <span id="reply-spinner" class="htmx-indicator">Posting…</span> 360 + </button> 361 + </div> 362 + </form> 363 + <script dangerouslySetInnerHTML={{ __html: REPLY_CHAR_COUNTER_SCRIPT }} /> 364 + </> 323 365 ) : ( 324 366 <p> 325 367 <a href="/login">Log in</a> to reply. ··· 328 370 </div> 329 371 </BaseLayout> 330 372 ); 373 + }) 374 + .post("/topics/:id/reply", async (c) => { 375 + const topicId = c.req.param("id"); 376 + 377 + if (!/^\d+$/.test(topicId)) { 378 + return c.html(<p class="form-error">Invalid topic ID.</p>); 379 + } 380 + 381 + const cookieHeader = c.req.header("cookie") ?? ""; 382 + 383 + let body: Record<string, string | File>; 384 + try { 385 + body = await c.req.parseBody(); 386 + } catch (error) { 387 + console.error("Failed to parse request body for POST /topics/:id/reply", { 388 + operation: `POST /topics/${topicId}/reply`, 389 + topicId, 390 + error: error instanceof Error ? error.message : String(error), 391 + }); 392 + return c.html(<p class="form-error">Invalid form submission.</p>); 393 + } 394 + 395 + const { text } = body; 396 + 397 + if (typeof text !== "string" || !text.trim()) { 398 + return c.html(<p class="form-error">Reply text is required.</p>); 399 + } 400 + 401 + let appviewRes: Response; 402 + try { 403 + appviewRes = await fetch(`${appviewUrl}/api/posts`, { 404 + method: "POST", 405 + headers: { 406 + "Content-Type": "application/json", 407 + "Cookie": cookieHeader, 408 + }, 409 + body: JSON.stringify({ 410 + text, 411 + rootPostId: topicId, 412 + parentPostId: topicId, 413 + }), 414 + }); 415 + } catch (error) { 416 + if (isProgrammingError(error)) throw error; 417 + console.error("Failed to proxy reply to AppView", { 418 + operation: `POST /topics/${topicId}/reply`, 419 + topicId, 420 + error: error instanceof Error ? error.message : String(error), 421 + }); 422 + return c.html( 423 + <p class="form-error">Forum temporarily unavailable. Please try again.</p> 424 + ); 425 + } 426 + 427 + if (appviewRes.status === 201) { 428 + const headers = new Headers(); 429 + headers.set("HX-Redirect", `/topics/${topicId}`); 430 + return new Response(null, { status: 200, headers }); 431 + } 432 + 433 + let errorMessage = "Something went wrong. Please try again."; 434 + 435 + if (appviewRes.status === 401) { 436 + errorMessage = "You must be logged in to reply."; 437 + } else if (appviewRes.status === 403) { 438 + try { 439 + const errBody = (await appviewRes.json()) as { error?: string }; 440 + const msg = errBody.error ?? ""; 441 + if (msg.toLowerCase().includes("locked")) { 442 + errorMessage = "This topic is locked."; 443 + } else if (msg.toLowerCase().includes("banned")) { 444 + errorMessage = "You are banned from this forum."; 445 + } else { 446 + errorMessage = msg || "You are not allowed to reply."; 447 + } 448 + } catch { 449 + console.error("Failed to parse AppView 403 response body for reply", { 450 + operation: `POST /topics/${topicId}/reply`, 451 + }); 452 + errorMessage = "You are not allowed to reply."; 453 + } 454 + } else if (appviewRes.status === 400) { 455 + try { 456 + const errBody = (await appviewRes.json()) as { error?: string }; 457 + errorMessage = errBody.error ?? errorMessage; 458 + } catch { 459 + console.error("Failed to parse AppView 400 response body for reply", { 460 + operation: `POST /topics/${topicId}/reply`, 461 + }); 462 + } 463 + } else if (appviewRes.status >= 400 && appviewRes.status < 500) { 464 + console.error("AppView returned unexpected client error for reply", { 465 + operation: `POST /topics/${topicId}/reply`, 466 + status: appviewRes.status, 467 + }); 468 + } else if (appviewRes.status >= 500) { 469 + console.error("AppView returned server error for reply", { 470 + operation: `POST /topics/${topicId}/reply`, 471 + status: appviewRes.status, 472 + }); 473 + } 474 + 475 + return c.html(<p class="form-error">{errorMessage}</p>); 331 476 }); 332 477 }
+3 -1
bruno/AppView API/Boards/Get Board.bru
··· 12 12 res.status: eq 200 13 13 res.body.id: isDefined 14 14 res.body.name: isDefined 15 + res.body.uri: isDefined 15 16 } 16 17 17 18 docs { ··· 24 25 { 25 26 "id": "1", 26 27 "did": "did:plc:...", 28 + "uri": "at://did:plc:.../space.atbb.forum.board/rkey", 27 29 "name": "General Discussion", 28 30 "description": "A place for general topics", 29 31 "categoryId": "1", 32 + "categoryUri": "at://did:plc:.../space.atbb.forum.category/rkey", 30 33 "slug": null, 31 34 "sortOrder": null, 32 - "forumId": "1", 33 35 "createdAt": "...", 34 36 "indexedAt": "..." 35 37 }
+1
bruno/AppView API/Boards/List All Boards.bru
··· 22 22 { 23 23 "id": "1", 24 24 "did": "did:plc:forum", 25 + "uri": "at://did:plc:forum/space.atbb.forum.board/rkey", 25 26 "name": "Board name", 26 27 "description": "Board description" | null, 27 28 "slug": "board-slug" | null,
+1244
docs/plans/2026-02-19-atb-31-compose-forms.md
··· 1 + # ATB-31: Compose Forms Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add HTMX-powered new-topic and reply compose forms to the web UI, proxied through the web server to the AppView JSON write API. 6 + 7 + **Architecture:** HTMX forms submit to web server POST endpoints; the web server forwards requests to the AppView API with the session cookie and returns either an `HX-Redirect` header (success) or an HTML error fragment (failure). This matches the existing logout proxy pattern in `auth.ts`. 8 + 9 + **Tech Stack:** Hono JSX (server-rendered HTML), HTMX 2.x (`hx-post`, `hx-target`, `hx-disabled-elt`, `HX-Redirect` header), `Intl.Segmenter` (grapheme count), Vitest. 10 + 11 + --- 12 + 13 + ## Task 1: Add `uri` field to board API response 14 + 15 + The new topic form needs the board's AT URI (`at://did:plc:.../space.atbb.forum.board/rkey`) to post to the AppView. Currently `serializeBoard` omits it. This task adds a computed `uri` field (same pattern as `categoryUri` already present). 16 + 17 + **Files:** 18 + - Modify: `apps/appview/src/routes/helpers.ts` (add `uri` to `serializeBoard`) 19 + - Modify: `apps/appview/src/routes/__tests__/boards.test.ts` (add `uri` assertion) 20 + - Modify: `apps/web/src/routes/boards.tsx` (add `uri` to `BoardResponse` interface) 21 + - Modify: `apps/web/src/routes/__tests__/boards.test.tsx` (add `uri` to `makeBoardResponse`) 22 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` (add `uri` to `makeBoardResponse`) 23 + 24 + --- 25 + 26 + **Step 1: Add failing test — board response includes `uri`** 27 + 28 + In `apps/appview/src/routes/__tests__/boards.test.ts`, find the test `"returns board with expected fields"` and add a `uri` assertion: 29 + 30 + ```typescript 31 + it("returns board with expected fields", async () => { 32 + // (existing test body...) 33 + expect(board).toHaveProperty("uri"); 34 + expect(board.uri).toMatch(/^at:\/\//); 35 + }); 36 + ``` 37 + 38 + **Step 2: Run test to verify it fails** 39 + 40 + ```bash 41 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview test src/routes/__tests__/boards.test.ts 42 + ``` 43 + Expected: FAIL — `uri` property missing. 44 + 45 + **Step 3: Add `uri` to `serializeBoard` in `apps/appview/src/routes/helpers.ts`** 46 + 47 + Find the `serializeBoard` function. After `did: board.did,` add: 48 + ```typescript 49 + uri: `at://${board.did}/space.atbb.forum.board/${board.rkey}`, 50 + ``` 51 + 52 + The full function becomes: 53 + ```typescript 54 + export function serializeBoard(board: BoardRow) { 55 + return { 56 + id: serializeBigInt(board.id), 57 + did: board.did, 58 + uri: `at://${board.did}/space.atbb.forum.board/${board.rkey}`, 59 + name: board.name, 60 + description: board.description, 61 + slug: board.slug, 62 + sortOrder: board.sortOrder, 63 + categoryId: serializeBigInt(board.categoryId), 64 + categoryUri: board.categoryUri, 65 + createdAt: serializeDate(board.createdAt), 66 + indexedAt: serializeDate(board.indexedAt), 67 + }; 68 + } 69 + ``` 70 + 71 + **Step 4: Run AppView board tests to verify they pass** 72 + 73 + ```bash 74 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview test src/routes/__tests__/boards.test.ts 75 + ``` 76 + Expected: All board tests pass. The "does not leak internal fields (rkey, cid)" test still passes because `uri` is not `rkey` or `cid`. 77 + 78 + **Step 5: Add `uri` to `BoardResponse` in web routes** 79 + 80 + In `apps/web/src/routes/boards.tsx`, find `interface BoardResponse` and add: 81 + ```typescript 82 + uri: string; 83 + ``` 84 + 85 + Also add to the `BoardResponse` in `apps/web/src/routes/__tests__/boards.test.tsx` mock helper `makeBoardResponse`: 86 + ```typescript 87 + uri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 88 + ``` 89 + 90 + And the same in the `makeBoardResponse` helper in `apps/web/src/routes/__tests__/topics.test.tsx`. 91 + 92 + **Step 6: Run web tests to verify no regressions** 93 + 94 + ```bash 95 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 96 + ``` 97 + Expected: All existing web tests pass. 98 + 99 + **Step 7: Commit** 100 + 101 + ```bash 102 + git add apps/appview/src/routes/helpers.ts \ 103 + apps/appview/src/routes/__tests__/boards.test.ts \ 104 + apps/web/src/routes/boards.tsx \ 105 + apps/web/src/routes/__tests__/boards.test.tsx \ 106 + apps/web/src/routes/__tests__/topics.test.tsx 107 + git commit -m "feat(appview): add uri field to board API response (ATB-31)" 108 + ``` 109 + 110 + --- 111 + 112 + ## Task 2: New topic GET form — replace stub with real form 113 + 114 + The current `GET /new-topic` is a stub (see `apps/web/src/routes/new-topic.tsx`). Replace it with a real form that fetches board data and renders a textarea with a character counter and hidden fields. Also remove the 3 stale `GET /new-topic` tests from `stubs.test.tsx` — they test placeholder content that will no longer exist. 115 + 116 + **Files:** 117 + - Create: `apps/web/src/routes/__tests__/new-topic.test.tsx` 118 + - Modify: `apps/web/src/routes/new-topic.tsx` (replace GET stub) 119 + - Modify: `apps/web/src/routes/__tests__/stubs.test.tsx` (remove 3 stale new-topic tests) 120 + 121 + --- 122 + 123 + **Step 1: Create `apps/web/src/routes/__tests__/new-topic.test.tsx` with failing GET tests** 124 + 125 + ```typescript 126 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 127 + 128 + const mockFetch = vi.fn(); 129 + 130 + describe("createNewTopicRoutes", () => { 131 + beforeEach(() => { 132 + vi.stubGlobal("fetch", mockFetch); 133 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 134 + vi.resetModules(); 135 + mockFetch.mockResolvedValue({ ok: false, status: 401 }); 136 + }); 137 + 138 + afterEach(() => { 139 + vi.unstubAllGlobals(); 140 + vi.unstubAllEnvs(); 141 + mockFetch.mockReset(); 142 + }); 143 + 144 + function makeBoardResponse(overrides: Record<string, unknown> = {}) { 145 + return { 146 + ok: true, 147 + json: () => 148 + Promise.resolve({ 149 + id: "42", 150 + did: "did:plc:forum", 151 + uri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 152 + name: "General Discussion", 153 + description: "A board for general chat", 154 + slug: null, 155 + sortOrder: 1, 156 + categoryId: "7", 157 + categoryUri: null, 158 + createdAt: "2025-01-01T00:00:00.000Z", 159 + indexedAt: "2025-01-01T00:00:00.000Z", 160 + ...overrides, 161 + }), 162 + }; 163 + } 164 + 165 + const authSession = { 166 + ok: true, 167 + json: () => 168 + Promise.resolve({ 169 + authenticated: true, 170 + did: "did:plc:abc", 171 + handle: "alice.bsky.social", 172 + }), 173 + }; 174 + 175 + async function loadNewTopicRoutes() { 176 + const { createNewTopicRoutes } = await import("../new-topic.js"); 177 + return createNewTopicRoutes("http://localhost:3000"); 178 + } 179 + 180 + // ─── GET /new-topic ────────────────────────────────────────────────────────── 181 + 182 + it("redirects to / when boardId query param is missing", async () => { 183 + const routes = await loadNewTopicRoutes(); 184 + const res = await routes.request("/new-topic"); 185 + expect(res.status).toBe(302); 186 + expect(res.headers.get("location")).toBe("/"); 187 + }); 188 + 189 + it("redirects to / when boardId is non-numeric", async () => { 190 + const routes = await loadNewTopicRoutes(); 191 + const res = await routes.request("/new-topic?boardId=abc"); 192 + expect(res.status).toBe(302); 193 + expect(res.headers.get("location")).toBe("/"); 194 + }); 195 + 196 + it("shows login prompt for unauthenticated user", async () => { 197 + // No session cookie → no session fetch → board fetch NOT called 198 + const routes = await loadNewTopicRoutes(); 199 + const res = await routes.request("/new-topic?boardId=42"); 200 + expect(res.status).toBe(200); 201 + const html = await res.text(); 202 + expect(html).toContain("Log in"); 203 + expect(html).toContain("to create a topic"); 204 + expect(html).not.toContain("<form"); 205 + }); 206 + 207 + it("returns 404 when board not found", async () => { 208 + mockFetch.mockResolvedValueOnce(authSession); 209 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" }); 210 + const routes = await loadNewTopicRoutes(); 211 + const res = await routes.request("/new-topic?boardId=42", { 212 + headers: { cookie: "atbb_session=token" }, 213 + }); 214 + expect(res.status).toBe(404); 215 + const html = await res.text(); 216 + expect(html).toContain("Not Found"); 217 + }); 218 + 219 + it("redirects to / on AppView server error loading board", async () => { 220 + mockFetch.mockResolvedValueOnce(authSession); 221 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" }); 222 + const routes = await loadNewTopicRoutes(); 223 + const res = await routes.request("/new-topic?boardId=42", { 224 + headers: { cookie: "atbb_session=token" }, 225 + }); 226 + expect(res.status).toBe(302); 227 + expect(res.headers.get("location")).toBe("/"); 228 + }); 229 + 230 + it("renders form with boardUri hidden field when authenticated", async () => { 231 + mockFetch.mockResolvedValueOnce(authSession); 232 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 233 + const routes = await loadNewTopicRoutes(); 234 + const res = await routes.request("/new-topic?boardId=42", { 235 + headers: { cookie: "atbb_session=token" }, 236 + }); 237 + expect(res.status).toBe(200); 238 + const html = await res.text(); 239 + expect(html).toContain("at://did:plc:forum/space.atbb.forum.board/boardrkey1"); 240 + expect(html).toContain('name="boardUri"'); 241 + }); 242 + 243 + it("renders form with boardId hidden field for redirect after success", async () => { 244 + mockFetch.mockResolvedValueOnce(authSession); 245 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 246 + const routes = await loadNewTopicRoutes(); 247 + const res = await routes.request("/new-topic?boardId=42", { 248 + headers: { cookie: "atbb_session=token" }, 249 + }); 250 + const html = await res.text(); 251 + expect(html).toContain('name="boardId"'); 252 + expect(html).toContain('value="42"'); 253 + }); 254 + 255 + it("renders form with textarea for message text", async () => { 256 + mockFetch.mockResolvedValueOnce(authSession); 257 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 258 + const routes = await loadNewTopicRoutes(); 259 + const res = await routes.request("/new-topic?boardId=42", { 260 + headers: { cookie: "atbb_session=token" }, 261 + }); 262 + const html = await res.text(); 263 + expect(html).toContain('<textarea'); 264 + expect(html).toContain('name="text"'); 265 + }); 266 + 267 + it("renders character counter element", async () => { 268 + mockFetch.mockResolvedValueOnce(authSession); 269 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 270 + const routes = await loadNewTopicRoutes(); 271 + const res = await routes.request("/new-topic?boardId=42", { 272 + headers: { cookie: "atbb_session=token" }, 273 + }); 274 + const html = await res.text(); 275 + expect(html).toContain("char-count"); 276 + expect(html).toContain("300"); 277 + }); 278 + 279 + it("renders board name in page", async () => { 280 + mockFetch.mockResolvedValueOnce(authSession); 281 + mockFetch.mockResolvedValueOnce(makeBoardResponse({ name: "My Awesome Board" })); 282 + const routes = await loadNewTopicRoutes(); 283 + const res = await routes.request("/new-topic?boardId=42", { 284 + headers: { cookie: "atbb_session=token" }, 285 + }); 286 + const html = await res.text(); 287 + expect(html).toContain("My Awesome Board"); 288 + }); 289 + 290 + it("uses hx-post for form submission", async () => { 291 + mockFetch.mockResolvedValueOnce(authSession); 292 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 293 + const routes = await loadNewTopicRoutes(); 294 + const res = await routes.request("/new-topic?boardId=42", { 295 + headers: { cookie: "atbb_session=token" }, 296 + }); 297 + const html = await res.text(); 298 + expect(html).toContain("hx-post"); 299 + expect(html).toContain('hx-target="#form-error"'); 300 + }); 301 + }); 302 + ``` 303 + 304 + **Step 2: Run tests to confirm they fail** 305 + 306 + ```bash 307 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/new-topic.test.tsx 308 + ``` 309 + Expected: Multiple FAIL — stub doesn't redirect, doesn't show form, etc. 310 + 311 + **Step 3: Replace `apps/web/src/routes/new-topic.tsx` with real GET handler** 312 + 313 + Full file content: 314 + 315 + ```typescript 316 + import { Hono } from "hono"; 317 + import { BaseLayout } from "../layouts/base.js"; 318 + import { PageHeader, ErrorDisplay } from "../components/index.js"; 319 + import { getSession } from "../lib/session.js"; 320 + import { fetchApi } from "../lib/api.js"; 321 + import { isProgrammingError, isNetworkError, isNotFoundError } from "../lib/errors.js"; 322 + 323 + interface BoardResponse { 324 + id: string; 325 + did: string; 326 + uri: string; 327 + name: string; 328 + description: string | null; 329 + slug: string | null; 330 + sortOrder: number | null; 331 + categoryId: string; 332 + categoryUri: string | null; 333 + createdAt: string | null; 334 + indexedAt: string | null; 335 + } 336 + 337 + const CHAR_COUNTER_SCRIPT = ` 338 + function updateCharCount(el) { 339 + var seg = new Intl.Segmenter(); 340 + var n = Array.from(seg.segment(el.value)).length; 341 + var counter = document.getElementById("char-count"); 342 + counter.textContent = (300 - n) + " left"; 343 + counter.dataset.over = n > 300 ? "true" : "false"; 344 + } 345 + `; 346 + 347 + export function createNewTopicRoutes(appviewUrl: string) { 348 + return new Hono() 349 + .get("/new-topic", async (c) => { 350 + const boardIdParam = c.req.query("boardId"); 351 + 352 + // boardId required and must be numeric 353 + if (!boardIdParam || !/^\d+$/.test(boardIdParam)) { 354 + return c.redirect("/"); 355 + } 356 + 357 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 358 + 359 + if (!auth.authenticated) { 360 + return c.html( 361 + <BaseLayout title="New Topic — atBB Forum" auth={auth}> 362 + <PageHeader title="New Topic" /> 363 + <p> 364 + <a href="/login">Log in</a> to create a topic. 365 + </p> 366 + </BaseLayout> 367 + ); 368 + } 369 + 370 + // Fetch board data — need the AT URI for the hidden form field 371 + let board: BoardResponse; 372 + try { 373 + board = await fetchApi<BoardResponse>(`/boards/${boardIdParam}`); 374 + } catch (error) { 375 + if (isProgrammingError(error)) throw error; 376 + 377 + if (isNotFoundError(error)) { 378 + return c.html( 379 + <BaseLayout title="Not Found — atBB Forum" auth={auth}> 380 + <ErrorDisplay message="This board doesn't exist." /> 381 + </BaseLayout>, 382 + 404 383 + ); 384 + } 385 + 386 + console.error("Failed to load board for new topic form", { 387 + operation: "GET /new-topic", 388 + boardId: boardIdParam, 389 + error: error instanceof Error ? error.message : String(error), 390 + }); 391 + return c.redirect("/"); 392 + } 393 + 394 + return c.html( 395 + <BaseLayout title="New Topic — atBB Forum" auth={auth}> 396 + <nav class="breadcrumb"> 397 + <a href="/">Home</a> 398 + {" / "} 399 + <a href={`/boards/${board.id}`}>{board.name}</a> 400 + {" / "} 401 + <span>New Topic</span> 402 + </nav> 403 + 404 + <PageHeader title="New Topic" description={`Posting to ${board.name}`} /> 405 + 406 + <form 407 + hx-post="/new-topic" 408 + hx-target="#form-error" 409 + hx-swap="innerHTML" 410 + hx-indicator="#spinner" 411 + hx-disabled-elt="[type=submit]" 412 + > 413 + <input type="hidden" name="boardUri" value={board.uri} /> 414 + <input type="hidden" name="boardId" value={board.id} /> 415 + 416 + <div class="form-group"> 417 + <label for="compose-text">Your message</label> 418 + <textarea 419 + id="compose-text" 420 + name="text" 421 + rows={8} 422 + placeholder="What's on your mind?" 423 + oninput="updateCharCount(this)" 424 + /> 425 + <div id="char-count" class="char-count">300 left</div> 426 + </div> 427 + 428 + <div id="form-error" /> 429 + 430 + <div class="form-actions"> 431 + <button type="submit" class="btn btn-primary"> 432 + Post Topic 433 + <span id="spinner" class="htmx-indicator"> …</span> 434 + </button> 435 + </div> 436 + </form> 437 + 438 + <script dangerouslySetInnerHTML={{ __html: CHAR_COUNTER_SCRIPT }} /> 439 + </BaseLayout> 440 + ); 441 + }); 442 + } 443 + ``` 444 + 445 + **Step 4: Run GET tests to verify they pass** 446 + 447 + ```bash 448 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/new-topic.test.tsx 449 + ``` 450 + Expected: All GET tests pass. 451 + 452 + **Step 5: Remove stale new-topic tests from `stubs.test.tsx`** 453 + 454 + In `apps/web/src/routes/__tests__/stubs.test.tsx`, delete these three tests (they test placeholder text that no longer exists and will now fail because no `boardId`): 455 + - `"GET /new-topic returns 200 with new topic title"` 456 + - `"GET /new-topic shows 'Log in to create a topic' when unauthenticated"` 457 + - `"GET /new-topic shows compose form placeholder when authenticated"` 458 + 459 + **Step 6: Run full web test suite** 460 + 461 + ```bash 462 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 463 + ``` 464 + Expected: All tests pass. 465 + 466 + **Step 7: Commit** 467 + 468 + ```bash 469 + git add apps/web/src/routes/new-topic.tsx \ 470 + apps/web/src/routes/__tests__/new-topic.test.tsx \ 471 + apps/web/src/routes/__tests__/stubs.test.tsx 472 + git commit -m "feat(web): new topic form GET handler with board context (ATB-31)" 473 + ``` 474 + 475 + --- 476 + 477 + ## Task 3: New topic POST handler 478 + 479 + Add `POST /new-topic` to `new-topic.tsx`. It parses the URL-encoded form body, forwards to AppView `POST /api/topics` as JSON with the session cookie, and returns either an `HX-Redirect` header (201) or an HTML error fragment (any error). 480 + 481 + **Files:** 482 + - Modify: `apps/web/src/routes/new-topic.tsx` (chain `.post("/new-topic", ...)`) 483 + - Modify: `apps/web/src/routes/__tests__/new-topic.test.tsx` (add POST tests) 484 + 485 + --- 486 + 487 + **Step 1: Add failing POST tests to `new-topic.test.tsx`** 488 + 489 + Add this `describe` block after the GET tests: 490 + 491 + ```typescript 492 + // ─── POST /new-topic ───────────────────────────────────────────────────────── 493 + 494 + describe("POST /new-topic", () => { 495 + function makePostBody(overrides: Record<string, string> = {}) { 496 + return new URLSearchParams({ 497 + text: "Hello forum!", 498 + boardUri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 499 + boardId: "42", 500 + ...overrides, 501 + }).toString(); 502 + } 503 + 504 + const postHeaders = { 505 + "Content-Type": "application/x-www-form-urlencoded", 506 + cookie: "atbb_session=token", 507 + }; 508 + 509 + it("returns HX-Redirect to board on successful topic creation", async () => { 510 + mockFetch.mockResolvedValueOnce({ 511 + ok: true, 512 + status: 201, 513 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid1" }), 514 + }); 515 + const routes = await loadNewTopicRoutes(); 516 + const res = await routes.request("/new-topic", { 517 + method: "POST", 518 + headers: postHeaders, 519 + body: makePostBody(), 520 + }); 521 + expect(res.status).toBe(200); 522 + expect(res.headers.get("HX-Redirect")).toBe("/boards/42?posted=1"); 523 + }); 524 + 525 + it("returns error fragment when text field is missing", async () => { 526 + const routes = await loadNewTopicRoutes(); 527 + const res = await routes.request("/new-topic", { 528 + method: "POST", 529 + headers: postHeaders, 530 + body: makePostBody({ text: "" }), 531 + }); 532 + expect(res.status).toBe(200); 533 + const html = await res.text(); 534 + expect(html).toContain("form-error"); 535 + expect(html).toContain("required"); 536 + // No fetch call made (validated before proxying) 537 + expect(mockFetch).not.toHaveBeenCalled(); 538 + }); 539 + 540 + it("returns error fragment when boardUri field is missing", async () => { 541 + const routes = await loadNewTopicRoutes(); 542 + const res = await routes.request("/new-topic", { 543 + method: "POST", 544 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 545 + body: new URLSearchParams({ text: "Hello", boardId: "42" }).toString(), 546 + }); 547 + expect(res.status).toBe(200); 548 + const html = await res.text(); 549 + expect(html).toContain("form-error"); 550 + expect(mockFetch).not.toHaveBeenCalled(); 551 + }); 552 + 553 + it("returns login error fragment when AppView returns 401", async () => { 554 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }); 555 + const routes = await loadNewTopicRoutes(); 556 + const res = await routes.request("/new-topic", { 557 + method: "POST", 558 + headers: postHeaders, 559 + body: makePostBody(), 560 + }); 561 + expect(res.status).toBe(200); 562 + const html = await res.text(); 563 + expect(html).toContain("form-error"); 564 + expect(html).toContain("logged in"); 565 + }); 566 + 567 + it("returns banned error fragment when AppView returns 403", async () => { 568 + mockFetch.mockResolvedValueOnce({ 569 + ok: false, 570 + status: 403, 571 + json: () => Promise.resolve({ error: "You are banned from this forum" }), 572 + }); 573 + const routes = await loadNewTopicRoutes(); 574 + const res = await routes.request("/new-topic", { 575 + method: "POST", 576 + headers: postHeaders, 577 + body: makePostBody(), 578 + }); 579 + expect(res.status).toBe(200); 580 + const html = await res.text(); 581 + expect(html).toContain("form-error"); 582 + expect(html).toContain("banned"); 583 + }); 584 + 585 + it("returns AppView validation message on 400", async () => { 586 + mockFetch.mockResolvedValueOnce({ 587 + ok: false, 588 + status: 400, 589 + json: () => Promise.resolve({ error: "Text must be between 1 and 300 graphemes" }), 590 + }); 591 + const routes = await loadNewTopicRoutes(); 592 + const res = await routes.request("/new-topic", { 593 + method: "POST", 594 + headers: postHeaders, 595 + body: makePostBody(), 596 + }); 597 + expect(res.status).toBe(200); 598 + const html = await res.text(); 599 + expect(html).toContain("form-error"); 600 + expect(html).toContain("300 graphemes"); 601 + }); 602 + 603 + it("returns unavailable error fragment on AppView network error", async () => { 604 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 605 + const routes = await loadNewTopicRoutes(); 606 + const res = await routes.request("/new-topic", { 607 + method: "POST", 608 + headers: postHeaders, 609 + body: makePostBody(), 610 + }); 611 + expect(res.status).toBe(200); 612 + const html = await res.text(); 613 + expect(html).toContain("form-error"); 614 + expect(html).toContain("unavailable"); 615 + }); 616 + 617 + it("forwards session cookie to AppView", async () => { 618 + mockFetch.mockResolvedValueOnce({ 619 + ok: true, 620 + status: 201, 621 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid1" }), 622 + }); 623 + const routes = await loadNewTopicRoutes(); 624 + await routes.request("/new-topic", { 625 + method: "POST", 626 + headers: postHeaders, 627 + body: makePostBody(), 628 + }); 629 + const [, fetchOptions] = mockFetch.mock.calls[0]; 630 + expect(fetchOptions.headers["Cookie"]).toContain("atbb_session=token"); 631 + }); 632 + 633 + it("sends text and boardUri as JSON to AppView", async () => { 634 + mockFetch.mockResolvedValueOnce({ 635 + ok: true, 636 + status: 201, 637 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid1" }), 638 + }); 639 + const routes = await loadNewTopicRoutes(); 640 + await routes.request("/new-topic", { 641 + method: "POST", 642 + headers: postHeaders, 643 + body: makePostBody({ text: "My post text" }), 644 + }); 645 + const [, fetchOptions] = mockFetch.mock.calls[0]; 646 + const sentBody = JSON.parse(fetchOptions.body); 647 + expect(sentBody.text).toBe("My post text"); 648 + expect(sentBody.boardUri).toBe("at://did:plc:forum/space.atbb.forum.board/boardrkey1"); 649 + }); 650 + }); 651 + ``` 652 + 653 + **Step 2: Run tests to confirm they fail** 654 + 655 + ```bash 656 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/new-topic.test.tsx 657 + ``` 658 + Expected: POST test block fails — no POST handler exists yet. 659 + 660 + **Step 3: Add POST handler to `apps/web/src/routes/new-topic.tsx`** 661 + 662 + Chain `.post("/new-topic", async (c) => { ... })` to the existing Hono instance. The `createNewTopicRoutes` function should now return: 663 + 664 + ```typescript 665 + export function createNewTopicRoutes(appviewUrl: string) { 666 + return new Hono() 667 + .get("/new-topic", async (c) => { 668 + // ... (existing GET handler — unchanged) 669 + }) 670 + .post("/new-topic", async (c) => { 671 + const cookieHeader = c.req.header("cookie") ?? ""; 672 + 673 + // Parse URL-encoded form body (HTMX default encoding) 674 + let body: Record<string, string | File>; 675 + try { 676 + body = await c.req.parseBody(); 677 + } catch { 678 + return c.html(<p class="form-error">Invalid form submission.</p>); 679 + } 680 + 681 + const { text, boardUri, boardId } = body; 682 + 683 + // Validate required fields before proxying 684 + if (typeof text !== "string" || !text.trim()) { 685 + return c.html(<p class="form-error">Message text is required.</p>); 686 + } 687 + if (typeof boardUri !== "string" || !boardUri.trim()) { 688 + return c.html( 689 + <p class="form-error">Board information is missing. Please try again.</p> 690 + ); 691 + } 692 + if (typeof boardId !== "string" || !/^\d+$/.test(boardId)) { 693 + return c.html( 694 + <p class="form-error">Board information is missing. Please try again.</p> 695 + ); 696 + } 697 + 698 + // Proxy to AppView 699 + let appviewRes: Response; 700 + try { 701 + appviewRes = await fetch(`${appviewUrl}/api/topics`, { 702 + method: "POST", 703 + headers: { 704 + "Content-Type": "application/json", 705 + "Cookie": cookieHeader, 706 + }, 707 + body: JSON.stringify({ text, boardUri }), 708 + }); 709 + } catch (error) { 710 + if (isProgrammingError(error)) throw error; 711 + console.error("Failed to proxy new topic to AppView", { 712 + operation: "POST /new-topic", 713 + error: error instanceof Error ? error.message : String(error), 714 + }); 715 + return c.html( 716 + <p class="form-error">Forum temporarily unavailable. Please try again.</p> 717 + ); 718 + } 719 + 720 + // Success: instruct HTMX to navigate to the board with a flash message 721 + if (appviewRes.status === 201) { 722 + const headers = new Headers(); 723 + headers.set("HX-Redirect", `/boards/${boardId}?posted=1`); 724 + return new Response(null, { status: 200, headers }); 725 + } 726 + 727 + // Map AppView error to user-friendly message 728 + let errorMessage = "Something went wrong. Please try again."; 729 + 730 + if (appviewRes.status === 401) { 731 + errorMessage = "You must be logged in to post."; 732 + } else if (appviewRes.status === 403) { 733 + errorMessage = "You are banned from this forum."; 734 + } else if (appviewRes.status === 400) { 735 + try { 736 + const errBody = (await appviewRes.json()) as { error?: string }; 737 + errorMessage = errBody.error ?? errorMessage; 738 + } catch { 739 + // keep default message 740 + } 741 + } else if (appviewRes.status >= 500) { 742 + console.error("AppView returned server error for new topic", { 743 + operation: "POST /new-topic", 744 + status: appviewRes.status, 745 + }); 746 + } 747 + 748 + return c.html(<p class="form-error">{errorMessage}</p>); 749 + }); 750 + } 751 + ``` 752 + 753 + **Step 4: Run all new-topic tests** 754 + 755 + ```bash 756 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/new-topic.test.tsx 757 + ``` 758 + Expected: All GET and POST tests pass. 759 + 760 + **Step 5: Commit** 761 + 762 + ```bash 763 + git add apps/web/src/routes/new-topic.tsx \ 764 + apps/web/src/routes/__tests__/new-topic.test.tsx 765 + git commit -m "feat(web): new topic POST handler proxied to AppView (ATB-31)" 766 + ``` 767 + 768 + --- 769 + 770 + ## Task 4: Board flash banner for successful topic creation 771 + 772 + After redirecting to the board with `?posted=1`, the board page should show a success banner: "Your topic has been posted. It will appear shortly." 773 + 774 + **Files:** 775 + - Modify: `apps/web/src/routes/boards.tsx` 776 + - Modify: `apps/web/src/routes/__tests__/boards.test.tsx` 777 + 778 + --- 779 + 780 + **Step 1: Add failing tests to `boards.test.tsx`** 781 + 782 + Add at the end of the `describe("createBoardsRoutes")` block: 783 + 784 + ```typescript 785 + it("shows success banner when ?posted=1 query param is present", async () => { 786 + setupSuccessfulFetch(); 787 + const routes = await loadBoardsRoutes(); 788 + const res = await routes.request("/boards/42?posted=1"); 789 + expect(res.status).toBe(200); 790 + const html = await res.text(); 791 + expect(html).toContain("success-banner"); 792 + expect(html).toContain("posted"); 793 + }); 794 + 795 + it("does not show success banner without ?posted=1", async () => { 796 + setupSuccessfulFetch(); 797 + const routes = await loadBoardsRoutes(); 798 + const res = await routes.request("/boards/42"); 799 + expect(res.status).toBe(200); 800 + const html = await res.text(); 801 + expect(html).not.toContain("success-banner"); 802 + }); 803 + ``` 804 + 805 + **Step 2: Run tests to confirm they fail** 806 + 807 + ```bash 808 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/boards.test.tsx 809 + ``` 810 + Expected: 2 new tests FAIL. 811 + 812 + **Step 3: Add flash banner to `boards.tsx`** 813 + 814 + In the full-page GET handler of `createBoardsRoutes`, immediately after reading `auth` add: 815 + 816 + ```typescript 817 + const postedSuccess = c.req.query("posted") === "1"; 818 + ``` 819 + 820 + Then in the JSX return, immediately after `<PageHeader ... />` and before the new-topic link, insert: 821 + 822 + ```tsx 823 + {postedSuccess && ( 824 + <div class="success-banner"> 825 + Your topic has been posted. It will appear shortly. 826 + </div> 827 + )} 828 + ``` 829 + 830 + **Step 4: Run board tests** 831 + 832 + ```bash 833 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/boards.test.tsx 834 + ``` 835 + Expected: All board tests pass. 836 + 837 + **Step 5: Commit** 838 + 839 + ```bash 840 + git add apps/web/src/routes/boards.tsx \ 841 + apps/web/src/routes/__tests__/boards.test.tsx 842 + git commit -m "feat(web): success banner on board page after new topic (ATB-31)" 843 + ``` 844 + 845 + --- 846 + 847 + ## Task 5: Reply form and POST handler in topic view 848 + 849 + Replace the `#reply-form-slot` placeholder in `topics.tsx` with a real reply form, and add a `POST /topics/:id/reply` handler that proxies to AppView `POST /api/posts`. 850 + 851 + **Files:** 852 + - Modify: `apps/web/src/routes/topics.tsx` 853 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 854 + 855 + --- 856 + 857 + **Step 1: Add failing reply form tests to `topics.test.tsx`** 858 + 859 + Find the existing test helpers in `topics.test.tsx` and add at the bottom of the `describe` block: 860 + 861 + ```typescript 862 + // ─── Reply form rendering ─────────────────────────────────────────────────── 863 + 864 + it("shows reply form with correct hidden fields when authenticated", async () => { 865 + mockFetch.mockResolvedValueOnce(authSession); // session 866 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); // topic (topicId="1") 867 + // board + category fetches may follow (non-fatal); don't need to mock them 868 + const routes = await loadTopicsRoutes(); 869 + const res = await routes.request("/topics/1", { 870 + headers: { cookie: "atbb_session=token" }, 871 + }); 872 + expect(res.status).toBe(200); 873 + const html = await res.text(); 874 + expect(html).toContain('name="rootPostId"'); 875 + expect(html).toContain('value="1"'); 876 + expect(html).toContain('name="parentPostId"'); 877 + expect(html).toContain('name="text"'); 878 + }); 879 + 880 + it("shows 'Log in to reply' instead of form when unauthenticated", async () => { 881 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); 882 + const routes = await loadTopicsRoutes(); 883 + const res = await routes.request("/topics/1"); 884 + const html = await res.text(); 885 + expect(html).toContain("Log in"); 886 + expect(html).toContain("to reply"); 887 + expect(html).not.toContain('name="rootPostId"'); 888 + }); 889 + 890 + it("shows locked message instead of form when topic is locked", async () => { 891 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ locked: true })); 892 + const routes = await loadTopicsRoutes(); 893 + const res = await routes.request("/topics/1"); 894 + const html = await res.text(); 895 + expect(html).toContain("locked"); 896 + expect(html).not.toContain('name="rootPostId"'); 897 + }); 898 + 899 + it("uses hx-post for reply form submission", async () => { 900 + mockFetch.mockResolvedValueOnce(authSession); 901 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); 902 + const routes = await loadTopicsRoutes(); 903 + const res = await routes.request("/topics/1", { 904 + headers: { cookie: "atbb_session=token" }, 905 + }); 906 + const html = await res.text(); 907 + expect(html).toContain("hx-post"); 908 + expect(html).toContain("/topics/1/reply"); 909 + }); 910 + 911 + // ─── POST /topics/:id/reply ────────────────────────────────────────────────── 912 + 913 + it("returns HX-Redirect to topic on successful reply", async () => { 914 + mockFetch.mockResolvedValueOnce({ 915 + ok: true, 916 + status: 201, 917 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid2" }), 918 + }); 919 + const routes = await loadTopicsRoutes(); 920 + const res = await routes.request("/topics/1/reply", { 921 + method: "POST", 922 + headers: { 923 + "Content-Type": "application/x-www-form-urlencoded", 924 + cookie: "atbb_session=token", 925 + }, 926 + body: new URLSearchParams({ text: "Great post!" }).toString(), 927 + }); 928 + expect(res.status).toBe(200); 929 + expect(res.headers.get("HX-Redirect")).toBe("/topics/1"); 930 + }); 931 + 932 + it("returns error fragment when text is missing", async () => { 933 + const routes = await loadTopicsRoutes(); 934 + const res = await routes.request("/topics/1/reply", { 935 + method: "POST", 936 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 937 + body: new URLSearchParams({ text: "" }).toString(), 938 + }); 939 + expect(res.status).toBe(200); 940 + const html = await res.text(); 941 + expect(html).toContain("form-error"); 942 + expect(html).toContain("required"); 943 + expect(mockFetch).not.toHaveBeenCalled(); 944 + }); 945 + 946 + it("returns login error fragment when AppView returns 401", async () => { 947 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) }); 948 + const routes = await loadTopicsRoutes(); 949 + const res = await routes.request("/topics/1/reply", { 950 + method: "POST", 951 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 952 + body: new URLSearchParams({ text: "Hello" }).toString(), 953 + }); 954 + const html = await res.text(); 955 + expect(html).toContain("form-error"); 956 + expect(html).toContain("logged in"); 957 + }); 958 + 959 + it("returns locked error fragment when AppView returns 403 locked", async () => { 960 + mockFetch.mockResolvedValueOnce({ 961 + ok: false, 962 + status: 403, 963 + json: () => 964 + Promise.resolve({ error: "This topic is locked and not accepting new replies" }), 965 + }); 966 + const routes = await loadTopicsRoutes(); 967 + const res = await routes.request("/topics/1/reply", { 968 + method: "POST", 969 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 970 + body: new URLSearchParams({ text: "Hello" }).toString(), 971 + }); 972 + const html = await res.text(); 973 + expect(html).toContain("form-error"); 974 + expect(html).toContain("locked"); 975 + }); 976 + 977 + it("returns error fragment on AppView network error", async () => { 978 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 979 + const routes = await loadTopicsRoutes(); 980 + const res = await routes.request("/topics/1/reply", { 981 + method: "POST", 982 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 983 + body: new URLSearchParams({ text: "Hello" }).toString(), 984 + }); 985 + const html = await res.text(); 986 + expect(html).toContain("form-error"); 987 + expect(html).toContain("unavailable"); 988 + }); 989 + 990 + it("sends text, rootPostId, and parentPostId to AppView", async () => { 991 + mockFetch.mockResolvedValueOnce({ 992 + ok: true, 993 + status: 201, 994 + json: () => Promise.resolve({ uri: "at://...", cid: "baf...", rkey: "tid2" }), 995 + }); 996 + const routes = await loadTopicsRoutes(); 997 + await routes.request("/topics/99/reply", { 998 + method: "POST", 999 + headers: { 1000 + "Content-Type": "application/x-www-form-urlencoded", 1001 + cookie: "atbb_session=token", 1002 + }, 1003 + body: new URLSearchParams({ text: "My reply" }).toString(), 1004 + }); 1005 + const [, fetchOptions] = mockFetch.mock.calls[0]; 1006 + const sentBody = JSON.parse(fetchOptions.body); 1007 + expect(sentBody.text).toBe("My reply"); 1008 + expect(sentBody.rootPostId).toBe("99"); 1009 + expect(sentBody.parentPostId).toBe("99"); 1010 + }); 1011 + ``` 1012 + 1013 + You'll also need `authSession` and a `loadTopicsRoutes` helper. Check if they already exist in `topics.test.tsx`. If not, add: 1014 + 1015 + ```typescript 1016 + const authSession = { 1017 + ok: true, 1018 + json: () => 1019 + Promise.resolve({ 1020 + authenticated: true, 1021 + did: "did:plc:abc", 1022 + handle: "alice.bsky.social", 1023 + }), 1024 + }; 1025 + 1026 + async function loadTopicsRoutes() { 1027 + const { createTopicsRoutes } = await import("../topics.js"); 1028 + return createTopicsRoutes("http://localhost:3000"); 1029 + } 1030 + ``` 1031 + 1032 + **Step 2: Run tests to confirm they fail** 1033 + 1034 + ```bash 1035 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/topics.test.tsx 1036 + ``` 1037 + Expected: All new tests FAIL. 1038 + 1039 + **Step 3: Add reply form and POST handler to `topics.tsx`** 1040 + 1041 + **3a.** Add `REPLY_CHAR_COUNTER_SCRIPT` constant near the top of `topics.tsx`: 1042 + 1043 + ```typescript 1044 + const REPLY_CHAR_COUNTER_SCRIPT = ` 1045 + function updateReplyCharCount(el) { 1046 + var seg = new Intl.Segmenter(); 1047 + var n = Array.from(seg.segment(el.value)).length; 1048 + var counter = document.getElementById("reply-char-count"); 1049 + counter.textContent = (300 - n) + " left"; 1050 + counter.dataset.over = n > 300 ? "true" : "false"; 1051 + } 1052 + `; 1053 + ``` 1054 + 1055 + **3b.** Replace the `#reply-form-slot` div in the full-page GET render: 1056 + 1057 + ```tsx 1058 + <div id="reply-form-slot"> 1059 + {topicData.locked ? ( 1060 + <p>This topic is locked. Replies are disabled.</p> 1061 + ) : auth?.authenticated ? ( 1062 + <> 1063 + <form 1064 + hx-post={`/topics/${topicId}/reply`} 1065 + hx-target="#reply-form-error" 1066 + hx-swap="innerHTML" 1067 + hx-indicator="#reply-spinner" 1068 + hx-disabled-elt="[type=submit]" 1069 + > 1070 + <input type="hidden" name="rootPostId" value={topicId} /> 1071 + <input type="hidden" name="parentPostId" value={topicId} /> 1072 + 1073 + <div class="form-group"> 1074 + <label for="reply-text">Your reply</label> 1075 + <textarea 1076 + id="reply-text" 1077 + name="text" 1078 + rows={5} 1079 + placeholder="Write a reply…" 1080 + oninput="updateReplyCharCount(this)" 1081 + /> 1082 + <div id="reply-char-count" class="char-count">300 left</div> 1083 + </div> 1084 + 1085 + <div id="reply-form-error" /> 1086 + 1087 + <div class="form-actions"> 1088 + <button type="submit" class="btn btn-primary"> 1089 + Post Reply 1090 + <span id="reply-spinner" class="htmx-indicator"> …</span> 1091 + </button> 1092 + </div> 1093 + </form> 1094 + <script dangerouslySetInnerHTML={{ __html: REPLY_CHAR_COUNTER_SCRIPT }} /> 1095 + </> 1096 + ) : ( 1097 + <p> 1098 + <a href="/login">Log in</a> to reply. 1099 + </p> 1100 + )} 1101 + </div> 1102 + ``` 1103 + 1104 + **3c.** Chain the POST handler onto the `createTopicsRoutes` return: 1105 + 1106 + ```typescript 1107 + export function createTopicsRoutes(appviewUrl: string) { 1108 + return new Hono() 1109 + .get("/topics/:id", async (c) => { 1110 + // ... existing GET handler (unchanged) ... 1111 + }) 1112 + .post("/topics/:id/reply", async (c) => { 1113 + const topicId = c.req.param("id"); 1114 + 1115 + if (!/^\d+$/.test(topicId)) { 1116 + return c.html(<p class="form-error">Invalid topic ID.</p>); 1117 + } 1118 + 1119 + const cookieHeader = c.req.header("cookie") ?? ""; 1120 + 1121 + let body: Record<string, string | File>; 1122 + try { 1123 + body = await c.req.parseBody(); 1124 + } catch { 1125 + return c.html(<p class="form-error">Invalid form submission.</p>); 1126 + } 1127 + 1128 + const { text } = body; 1129 + 1130 + if (typeof text !== "string" || !text.trim()) { 1131 + return c.html(<p class="form-error">Reply text is required.</p>); 1132 + } 1133 + 1134 + let appviewRes: Response; 1135 + try { 1136 + appviewRes = await fetch(`${appviewUrl}/api/posts`, { 1137 + method: "POST", 1138 + headers: { 1139 + "Content-Type": "application/json", 1140 + "Cookie": cookieHeader, 1141 + }, 1142 + body: JSON.stringify({ 1143 + text, 1144 + rootPostId: topicId, 1145 + parentPostId: topicId, 1146 + }), 1147 + }); 1148 + } catch (error) { 1149 + if (isProgrammingError(error)) throw error; 1150 + console.error("Failed to proxy reply to AppView", { 1151 + operation: `POST /topics/${topicId}/reply`, 1152 + topicId, 1153 + error: error instanceof Error ? error.message : String(error), 1154 + }); 1155 + return c.html( 1156 + <p class="form-error">Forum temporarily unavailable. Please try again.</p> 1157 + ); 1158 + } 1159 + 1160 + if (appviewRes.status === 201) { 1161 + const headers = new Headers(); 1162 + headers.set("HX-Redirect", `/topics/${topicId}`); 1163 + return new Response(null, { status: 200, headers }); 1164 + } 1165 + 1166 + let errorMessage = "Something went wrong. Please try again."; 1167 + 1168 + if (appviewRes.status === 401) { 1169 + errorMessage = "You must be logged in to reply."; 1170 + } else if (appviewRes.status === 403) { 1171 + try { 1172 + const errBody = (await appviewRes.json()) as { error?: string }; 1173 + const msg = errBody.error ?? ""; 1174 + if (msg.toLowerCase().includes("locked")) { 1175 + errorMessage = "This topic is locked."; 1176 + } else if (msg.toLowerCase().includes("banned")) { 1177 + errorMessage = "You are banned from this forum."; 1178 + } else { 1179 + errorMessage = msg || "You are not allowed to reply."; 1180 + } 1181 + } catch { 1182 + errorMessage = "You are not allowed to reply."; 1183 + } 1184 + } else if (appviewRes.status === 400) { 1185 + try { 1186 + const errBody = (await appviewRes.json()) as { error?: string }; 1187 + errorMessage = errBody.error ?? errorMessage; 1188 + } catch { 1189 + // keep default 1190 + } 1191 + } else if (appviewRes.status >= 500) { 1192 + console.error("AppView returned server error for reply", { 1193 + operation: `POST /topics/${topicId}/reply`, 1194 + status: appviewRes.status, 1195 + }); 1196 + } 1197 + 1198 + return c.html(<p class="form-error">{errorMessage}</p>); 1199 + }); 1200 + } 1201 + ``` 1202 + 1203 + Make sure `isProgrammingError` is already imported at the top of `topics.tsx` (it is — check the import at line 6). 1204 + 1205 + **Step 4: Run topic tests** 1206 + 1207 + ```bash 1208 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test src/routes/__tests__/topics.test.tsx 1209 + ``` 1210 + Expected: All tests pass. 1211 + 1212 + **Step 5: Run full test suite** 1213 + 1214 + ```bash 1215 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 1216 + ``` 1217 + Expected: All web tests pass. 1218 + 1219 + **Step 6: Commit** 1220 + 1221 + ```bash 1222 + git add apps/web/src/routes/topics.tsx \ 1223 + apps/web/src/routes/__tests__/topics.test.tsx 1224 + git commit -m "feat(web): reply form and POST /topics/:id/reply handler (ATB-31)" 1225 + ``` 1226 + 1227 + --- 1228 + 1229 + ## Final Verification 1230 + 1231 + **Step 1: Run all tests across the monorepo** 1232 + 1233 + ```bash 1234 + PATH=.devenv/profile/bin:$PATH pnpm test 1235 + ``` 1236 + Expected: All tests pass across `@atbb/appview`, `@atbb/web`, `@atbb/db`, `@atbb/lexicon`. 1237 + 1238 + **Step 2: Update Linear issue status** 1239 + 1240 + Mark ATB-31 as Done in Linear. Add a comment referencing the commits. 1241 + 1242 + **Step 3: Update plan doc** 1243 + 1244 + Mark ATB-31 complete in `docs/atproto-forum-plan.md` under Phase 4.
+152
docs/plans/2026-02-19-compose-forms-design.md
··· 1 + # Compose Forms Design — ATB-31 2 + 3 + **Date:** 2026-02-19 4 + **Issue:** [ATB-31](https://linear.app/atbb/issue/ATB-31) 5 + **Status:** Approved 6 + 7 + ## Summary 8 + 9 + Add HTMX-powered compose forms for new topic creation and replies. The web server acts as a proxy between the browser and the AppView JSON API, forwarding session cookies and returning either redirect headers (success) or HTML error fragments (failure). 10 + 11 + ## Architecture 12 + 13 + The web server (`apps/web`) proxies all write operations to the AppView, the same pattern established by the logout handler (`auth.ts`). HTMX forms post to web server endpoints; the web server calls the AppView JSON API with the forwarded session cookie and returns an HTMX-appropriate response. 14 + 15 + ``` 16 + HTMX form → web server POST handler → AppView JSON API (with Cookie) 17 + ↓ success ↓ 201 18 + HX-Redirect header { uri, cid, rkey } 19 + ↓ error 20 + error HTML fragment (swapped into #form-error) 21 + ``` 22 + 23 + The AppView remains a pure JSON API. The web server owns HTML rendering. 24 + 25 + ## Files Changed 26 + 27 + | File | Change | 28 + |---|---| 29 + | `apps/web/src/routes/new-topic.tsx` | Replace stub: add form rendering (GET) + proxy handler (POST) | 30 + | `apps/web/src/routes/topics.tsx` | Replace `#reply-form-slot` placeholder; add `POST /topics/:id/reply` handler | 31 + | `apps/web/src/routes/boards.tsx` | Add flash banner when `?posted=1` query param present | 32 + 33 + No new files. `routes/index.ts` needs no changes (both route files already registered). 34 + 35 + ## New Topic Form 36 + 37 + ### GET /new-topic?boardId=X 38 + 39 + 1. Validate `boardId` query param (must be numeric). Missing or invalid → redirect to `/`. 40 + 2. Fetch board data from AppView (`GET /api/boards/:id`) to obtain the AT URI (`boardUri`). 41 + 3. Board not found → 404 error page. 42 + 4. Render full-page form with: 43 + - Textarea (name=`text`, required, 1–300 graphemes) 44 + - Hidden input (name=`boardUri`, value=board's AT URI) 45 + - Character counter (see below) 46 + - Submit button with loading state 47 + 48 + ### POST /new-topic 49 + 50 + Reads `text` and `boardUri` from the URL-encoded form body. Forwards to AppView `POST /api/topics` as JSON with the session cookie. 51 + 52 + | AppView response | Web server action | 53 + |---|---| 54 + | 201 | `HX-Redirect: /boards/:boardId?posted=1` | 55 + | 400 | Error HTML: validation message | 56 + | 401 | Error HTML: "You must be logged in to post." | 57 + | 403 | Error HTML: "You are banned from this forum." | 58 + | 503 | Error HTML: "Forum temporarily unavailable. Please try again." | 59 + | 500 / other | Error HTML: "Something went wrong. Please try again." | 60 + 61 + The `boardId` for the success redirect is extracted from the boardUri (the numeric ID is a query param from the originating board page, passed through the form as a second hidden field). 62 + 63 + ### Board Flash Banner 64 + 65 + `GET /boards/:id` detects `?posted=1` and renders a dismissible success notice above the topic list: "Your topic has been posted. It will appear shortly." 66 + 67 + ## Reply Form 68 + 69 + ### Placement 70 + 71 + The existing `#reply-form-slot` div in `topics.tsx` receives the actual form. Conditional rendering: 72 + 73 + - Topic locked → "This topic is locked. Replies are disabled." 74 + - Unauthenticated → "Log in to reply." link 75 + - Authenticated → reply form 76 + 77 + ### Form Fields 78 + 79 + - Textarea (name=`text`, required) 80 + - Hidden input (name=`rootPostId`, value=topicId) 81 + - Hidden input (name=`parentPostId`, value=topicId — flat replies default to root) 82 + 83 + ### POST /topics/:id/reply 84 + 85 + Handler added to `createTopicsRoutes`. Reads `text` from form body; derives `rootPostId` and `parentPostId` from URL param `:id`. Forwards to AppView `POST /api/posts` as JSON with the session cookie. 86 + 87 + | AppView response | Web server action | 88 + |---|---| 89 + | 201 | `HX-Redirect: /topics/:id` (page reloads; reply appears once indexed) | 90 + | 400 | Error HTML: validation message | 91 + | 401 | Error HTML: login prompt | 92 + | 403 (banned) | Error HTML: "You are banned." | 93 + | 403 (locked) | Error HTML: "This topic is locked." | 94 + | 503 | Error HTML: "Forum temporarily unavailable. Please try again." | 95 + | 500 / other | Error HTML: "Something went wrong. Please try again." | 96 + 97 + ## HTMX Integration 98 + 99 + ```html 100 + <form 101 + hx-post="/new-topic" 102 + hx-target="#form-error" 103 + hx-swap="innerHTML" 104 + hx-indicator="#spinner" 105 + hx-disabled-elt="[type=submit]" 106 + > 107 + ... 108 + <div id="form-error"></div> 109 + <button type="submit">Post Topic</button> 110 + <span id="spinner" class="htmx-indicator">Posting…</span> 111 + </form> 112 + ``` 113 + 114 + - `hx-target="#form-error"` + `hx-swap="innerHTML"` — error HTML swaps into the error div 115 + - `hx-disabled-elt="[type=submit]"` — disables submit during inflight to prevent double-submission 116 + - `hx-indicator="#spinner"` — shows spinner during request 117 + - Success: web server responds with `HX-Redirect` header; HTMX does full browser navigation 118 + 119 + ## Character Counter 120 + 121 + Inlined `<script>` on each form page using `Intl.Segmenter` for grapheme-accurate counting (matches AppView server-side validation): 122 + 123 + ```js 124 + function updateCharCount(el) { 125 + const seg = new Intl.Segmenter(); 126 + const n = [...seg.segment(el.value)].length; 127 + const counter = document.getElementById("char-count"); 128 + counter.textContent = (300 - n) + " left"; 129 + counter.dataset.over = n > 300 ? "true" : "false"; 130 + } 131 + ``` 132 + 133 + Server-side validation is the authority. The counter is UX-only. 134 + 135 + ## Testing 136 + 137 + - New topic form renders with correct hidden `boardUri` field 138 + - New topic form renders login prompt when unauthenticated 139 + - POST /new-topic proxies correctly and returns redirect on success 140 + - POST /new-topic returns error fragment on AppView 400/401/403/5xx 141 + - POST /new-topic returns 400 for missing fields 142 + - Board page shows flash banner when `?posted=1` present 143 + - Reply form renders with correct `rootPostId` and `parentPostId` hidden fields 144 + - Reply form hidden when topic locked 145 + - Reply form shows login prompt when unauthenticated 146 + - POST /topics/:id/reply proxies correctly and returns redirect on success 147 + - POST /topics/:id/reply returns error fragment on AppView error responses 148 + 149 + ## Open Questions / Deferred 150 + 151 + - **boardId in flash redirect:** The boardId must be passed through the form (as a second hidden field alongside boardUri) to construct the success redirect URL. This is a minor implementation detail. 152 + - **Fire-and-forget latency:** New replies/topics may not appear immediately after redirect. No spinner or polling — the firehose is fast enough for MVP. A future improvement (ATB-33) could add real-time updates.