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(appview,web): server-side offset/limit pagination for GET /api/topics/:id (ATB-33) (#57)

* test(appview): add failing pagination tests for server-side topic pagination (ATB-33)

Add describe.sequential block to topics.test.ts covering GET /api/topics/:id
server-side pagination behavior that does not exist yet. Tests verify total,
offset, limit fields in response, default/explicit pagination, offset/limit
clamping, and empty result sets. All 8 tests fail as expected — implementation
pending.

* test(appview): clarify total semantics in pagination test name (ATB-33)

* feat(appview): add offset/limit pagination to GET /api/topics/:id (ATB-33)

- Parse offset/limit query params (default 25, max 100)
- Run COUNT + paginated SELECT in parallel (matching boards pattern)
- Return total, offset, limit in response alongside paginated replies
- Removes 1000-reply defensive limit in favour of server-controlled pagination

* test(appview): document approximate total semantic and add companion test (ATB-33)

* feat(web): use server-side pagination for topic replies (ATB-33)

- Pass offset/limit to AppView instead of slicing locally
- HTMX partial: forwards ?offset=N&limit=25 to AppView
- Full page: requests ?offset=0&limit=(offset+25) for bookmark support
- Removes TODO(ATB-33) comment

* fix(web): remove duplicate total property in makeTopicResponse test helper (ATB-33)

* chore(web): add clarifying comments for pagination edge cases (ATB-33)

* fix(web): remove invalid JSX comment between attributes (ATB-33)

* docs(bruno): update Get Topic collection with pagination params (ATB-33)

* fix(appview,web): address code review feedback on ATB-33 pagination

Critical fixes:
- Split single try-catch into two: topic query and reply query now have
distinct error messages ("Failed to retrieve topic" vs "Failed to
retrieve replies for topic") per CLAUDE.md try-block granularity rule
- HTMX partial error now returns a retry fragment instead of silently
replacing the Load More button with empty content
- Fix hasMore infinite loop: use `replies.length >= limit` (page-fullness
heuristic) instead of `nextOffset < total`; total is pre-filter and can
cause an infinite loop when in-memory filters zero out all SQL results
- Raise AppView limit cap from 100 to 250 so bookmark displayLimit
(offset + REPLIES_PER_PAGE) no longer gets silently clamped for deep links
- Fix Bruno docs: total is filtered for bannedByMod=false at SQL level,
not "unfiltered"; update description to match inline code comment

Important fixes:
- Remove total from ReplyFragment props (no longer used after hasMore fix)
- Change `total === 0` guard to `initialReplies.length === 0` so EmptyState
renders when all page-1 replies are filtered in-memory; update message to
"No replies to show."
- Add test: bannedByMod=true directly reduces total (proves COUNT query
applies the SQL-level filter)
- Add test: non-numeric offset/limit params default to 0/25
- Strengthen clamps limit=0 test to assert replies are returned, not just
metadata; rename limit cap test to reflect new max of 250
- Add AppView URL assertions to bookmark and HTMX partial web tests
- Update HTMX error test to assert retry fragment content

* fix(atb-33): clean up HTMX retry element and stale pagination comment

- Replace <p>/<button> retry fragment with bare <button> so hx-swap="outerHTML"
replaces the entire error element on retry success (no orphan text node)
- Update stale comment: topics cap is 250 (not 100 like boards) to support bookmarks

authored by

Malpercio and committed by
GitHub
df9f1b81 027dfa38

+399 -78
+234
apps/appview/src/routes/__tests__/topics.test.ts
··· 1408 1408 getActiveBansSpy.mockRestore(); 1409 1409 }); 1410 1410 }); 1411 + 1412 + describe.sequential("GET /api/topics/:id - server-side pagination", () => { 1413 + let ctx: TestContext; 1414 + let app: Hono; 1415 + 1416 + beforeEach(async () => { 1417 + ctx = await createTestContext(); 1418 + app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 1419 + 1420 + await ctx.db.insert(users).values({ 1421 + did: "did:plc:pagination-test-user", 1422 + handle: "paginationuser.test", 1423 + indexedAt: new Date(), 1424 + }).onConflictDoNothing(); 1425 + }); 1426 + 1427 + afterEach(async () => { 1428 + await ctx.cleanup(); 1429 + }); 1430 + 1431 + // Helper: insert a topic + N replies, return topicId 1432 + async function insertTopicWithReplies( 1433 + ctx: TestContext, 1434 + replyCount: number 1435 + ): Promise<bigint> { 1436 + const topicRkey = TID.nextStr(); 1437 + const [topic] = await ctx.db.insert(posts).values({ 1438 + did: "did:plc:pagination-test-user", 1439 + rkey: topicRkey, 1440 + cid: `bafy${topicRkey}`, 1441 + text: "Pagination test topic", 1442 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1443 + createdAt: new Date(), 1444 + indexedAt: new Date(), 1445 + }).returning(); 1446 + 1447 + for (let i = 0; i < replyCount; i++) { 1448 + const rkey = TID.nextStr(); 1449 + await ctx.db.insert(posts).values({ 1450 + did: "did:plc:pagination-test-user", 1451 + rkey, 1452 + cid: `bafy${rkey}`, 1453 + text: `Reply ${i + 1}`, 1454 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1455 + rootPostId: topic.id, 1456 + parentPostId: topic.id, 1457 + createdAt: new Date(Date.now() + i * 1000), // ensure ordering 1458 + indexedAt: new Date(), 1459 + }); 1460 + } 1461 + 1462 + return topic.id; 1463 + } 1464 + 1465 + it("returns total, offset, and limit fields in response", async () => { 1466 + const topicId = await insertTopicWithReplies(ctx, 5); 1467 + 1468 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1469 + 1470 + expect(res.status).toBe(200); 1471 + const data = await res.json(); 1472 + expect(data.total).toBeDefined(); 1473 + expect(typeof data.total).toBe("number"); 1474 + expect(data.offset).toBe(0); 1475 + expect(data.limit).toBeDefined(); 1476 + expect(typeof data.limit).toBe("number"); 1477 + }); 1478 + 1479 + it("total equals reply count when no replies are banned or hidden", async () => { 1480 + const topicId = await insertTopicWithReplies(ctx, 5); 1481 + 1482 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1483 + 1484 + expect(res.status).toBe(200); 1485 + const data = await res.json(); 1486 + expect(data.total).toBe(5); 1487 + }); 1488 + 1489 + it("default limit is 25 — returns first 25 of 30 replies", async () => { 1490 + const topicId = await insertTopicWithReplies(ctx, 30); 1491 + 1492 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1493 + 1494 + expect(res.status).toBe(200); 1495 + const data = await res.json(); 1496 + expect(data.replies).toHaveLength(25); 1497 + expect(data.total).toBe(30); 1498 + expect(data.offset).toBe(0); 1499 + expect(data.limit).toBe(25); 1500 + }); 1501 + 1502 + it("respects explicit offset and limit", async () => { 1503 + const topicId = await insertTopicWithReplies(ctx, 30); 1504 + 1505 + const res = await app.request( 1506 + `/api/topics/${topicId.toString()}?offset=20&limit=5` 1507 + ); 1508 + 1509 + expect(res.status).toBe(200); 1510 + const data = await res.json(); 1511 + expect(data.replies).toHaveLength(5); 1512 + expect(data.total).toBe(30); 1513 + expect(data.offset).toBe(20); 1514 + expect(data.limit).toBe(5); 1515 + // Verify these are replies 21-25 (text "Reply 21" through "Reply 25") 1516 + expect(data.replies[0].text).toBe("Reply 21"); 1517 + expect(data.replies[4].text).toBe("Reply 25"); 1518 + }); 1519 + 1520 + it("returns empty replies array when offset exceeds total", async () => { 1521 + const topicId = await insertTopicWithReplies(ctx, 5); 1522 + 1523 + const res = await app.request( 1524 + `/api/topics/${topicId.toString()}?offset=100&limit=25` 1525 + ); 1526 + 1527 + expect(res.status).toBe(200); 1528 + const data = await res.json(); 1529 + expect(data.replies).toHaveLength(0); 1530 + expect(data.total).toBe(5); 1531 + }); 1532 + 1533 + it("clamps negative offset to 0", async () => { 1534 + const topicId = await insertTopicWithReplies(ctx, 3); 1535 + 1536 + const res = await app.request( 1537 + `/api/topics/${topicId.toString()}?offset=-10` 1538 + ); 1539 + 1540 + expect(res.status).toBe(200); 1541 + const data = await res.json(); 1542 + expect(data.offset).toBe(0); 1543 + expect(data.replies).toHaveLength(3); 1544 + }); 1545 + 1546 + it("clamps limit > 250 to 250", async () => { 1547 + const topicId = await insertTopicWithReplies(ctx, 5); 1548 + 1549 + const res = await app.request( 1550 + `/api/topics/${topicId.toString()}?limit=500` 1551 + ); 1552 + 1553 + expect(res.status).toBe(200); 1554 + const data = await res.json(); 1555 + expect(data.limit).toBe(250); 1556 + }); 1557 + 1558 + it("clamps limit=0 to 25 (default)", async () => { 1559 + const topicId = await insertTopicWithReplies(ctx, 3); 1560 + 1561 + const res = await app.request( 1562 + `/api/topics/${topicId.toString()}?limit=0` 1563 + ); 1564 + 1565 + expect(res.status).toBe(200); 1566 + const data = await res.json(); 1567 + expect(data.limit).toBe(25); 1568 + // Verify the default limit actually returns replies, not just correct metadata 1569 + expect(data.replies).toHaveLength(3); 1570 + }); 1571 + 1572 + it("treats non-numeric offset/limit as defaults (offset=0, limit=25)", async () => { 1573 + const topicId = await insertTopicWithReplies(ctx, 3); 1574 + 1575 + const res = await app.request( 1576 + `/api/topics/${topicId.toString()}?offset=abc&limit=xyz` 1577 + ); 1578 + 1579 + expect(res.status).toBe(200); 1580 + const data = await res.json(); 1581 + expect(data.offset).toBe(0); 1582 + expect(data.limit).toBe(25); 1583 + expect(data.replies).toHaveLength(3); 1584 + }); 1585 + 1586 + it("total excludes replies where bannedByMod=true (SQL-level filter)", async () => { 1587 + // bannedByMod=true is applied in the COUNT query — this test proves total reflects it 1588 + const topicId = await insertTopicWithReplies(ctx, 3); 1589 + 1590 + // Mark the first reply as banned at the SQL level (bannedByMod flag, not a modAction) 1591 + const [firstReply] = await ctx.db 1592 + .select() 1593 + .from(posts) 1594 + .where(eq(posts.rootPostId, topicId)); 1595 + 1596 + await ctx.db 1597 + .update(posts) 1598 + .set({ bannedByMod: true }) 1599 + .where(eq(posts.id, firstReply.id)); 1600 + 1601 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1602 + 1603 + expect(res.status).toBe(200); 1604 + const data = await res.json(); 1605 + // SQL-level bannedByMod=true reduces total (unlike in-memory hide which does not) 1606 + expect(data.total).toBe(2); 1607 + expect(data.replies).toHaveLength(2); 1608 + }); 1609 + 1610 + it("total reflects pre-filter count when replies are hidden by mod action", async () => { 1611 + // 2 replies; 1 hidden via mod action → total=2 (SQL count), replies.length=1 (post-filter) 1612 + // This is by design — total is approximate; clients should treat it as a hint for pagination 1613 + const topicId = await insertTopicWithReplies(ctx, 2); 1614 + 1615 + // Look up the post IDs of the two replies so we can hide one 1616 + const allReplies = await ctx.db 1617 + .select() 1618 + .from(posts) 1619 + .where(eq(posts.rootPostId, topicId)); 1620 + 1621 + // Hide the first reply by inserting a delete modAction targeting its URI 1622 + const replyToHide = allReplies[0]; 1623 + const hideRkey = TID.nextStr(); 1624 + await ctx.db.insert(modActions).values({ 1625 + did: ctx.config.forumDid, 1626 + rkey: hideRkey, 1627 + cid: `bafy${hideRkey}`, 1628 + action: "space.atbb.modAction.delete", 1629 + subjectPostUri: `at://did:plc:pagination-test-user/space.atbb.post/${replyToHide.rkey}`, 1630 + createdBy: "did:plc:admin", 1631 + createdAt: new Date(), 1632 + indexedAt: new Date(), 1633 + }); 1634 + 1635 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1636 + 1637 + expect(res.status).toBe(200); 1638 + const data = await res.json(); 1639 + // total is approximate (SQL count before in-memory hide filter) 1640 + expect(data.total).toBe(2); 1641 + // visible replies excludes the hidden one 1642 + expect(data.replies).toHaveLength(1); 1643 + }); 1644 + });
+46 -14
apps/appview/src/routes/topics.ts
··· 2 2 import type { AppContext } from "../lib/app-context.js"; 3 3 import type { Variables } from "../types.js"; 4 4 import { posts, users } from "@atbb/db"; 5 - import { eq, and, asc } from "drizzle-orm"; 5 + import { eq, and, asc, count } from "drizzle-orm"; 6 6 import { TID } from "@atproto/common-web"; 7 7 import { requireAuth } from "../middleware/auth.js"; 8 8 import { requirePermission } from "../middleware/permissions.js"; ··· 35 35 return c.json({ error: "Invalid topic ID format" }, 400); 36 36 } 37 37 38 + // Parse pagination params (same pattern as boards/:id/topics, higher cap for bookmark support) 39 + const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 40 + const limitRaw = parseInt(c.req.query("limit") ?? "25", 10); 41 + const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; 42 + const limit = isNaN(limitRaw) || limitRaw < 1 ? 25 : Math.min(limitRaw, 250); 43 + 38 44 try { 39 45 // Query the thread starter post 40 46 const [topicResult] = await ctx.db ··· 51 57 return c.json({ error: "Topic not found" }, 404); 52 58 } 53 59 54 - // Query all replies (posts where rootPostId = topicId) 55 - // Ordered by creation time (chronological) 56 - const replyResults = await ctx.db 57 - .select({ 58 - post: posts, 59 - author: users, 60 - }) 61 - .from(posts) 62 - .leftJoin(users, eq(posts.did, users.did)) 63 - .where(and(eq(posts.rootPostId, topicId), eq(posts.bannedByMod, false))) 64 - .orderBy(asc(posts.createdAt)) 65 - .limit(1000); // Defensive limit, consistent with categories 60 + // Query reply count + paginated replies in parallel — separate inner try so a 61 + // failure here logs "replies" context rather than "topic" (CLAUDE.md try-block granularity) 62 + const replyFilter = and(eq(posts.rootPostId, topicId), eq(posts.bannedByMod, false)); 63 + let countResult: { count: number }[]; 64 + let replyResults: { post: typeof posts.$inferSelect; author: typeof users.$inferSelect | null }[]; 65 + try { 66 + [countResult, replyResults] = await Promise.all([ 67 + ctx.db 68 + .select({ count: count() }) 69 + .from(posts) 70 + .where(replyFilter), 71 + ctx.db 72 + .select({ 73 + post: posts, 74 + author: users, 75 + }) 76 + .from(posts) 77 + .leftJoin(users, eq(posts.did, users.did)) 78 + .where(replyFilter) 79 + .orderBy(asc(posts.createdAt)) 80 + .limit(limit) 81 + .offset(offset), 82 + ]); 83 + } catch (error) { 84 + return handleReadError(c, error, "Failed to retrieve replies for topic", { 85 + operation: "GET /api/topics/:id - reply query", 86 + logger: ctx.logger, 87 + topicId: id, 88 + }); 89 + } 90 + 91 + // total counts replies passing the SQL bannedByMod filter only. 92 + // In-memory filters (getActiveBans, getHiddenPosts) may reduce the 93 + // visible reply count further — clients should treat total as approximate. 94 + const total = Number(countResult[0]?.count ?? 0); 66 95 67 96 // Get banned users - fail open (show content if ban lookup fails) 68 97 const allUserDids = [ ··· 123 152 replies: filteredReplies.map(({ post, author }) => 124 153 serializePost(post, author) 125 154 ), 155 + total, 156 + offset, 157 + limit, 126 158 }); 127 159 } catch (error) { 128 160 return handleReadError(c, error, "Failed to retrieve topic", { 129 - operation: "GET /api/topics/:id", 161 + operation: "GET /api/topics/:id - topic query", 130 162 logger: ctx.logger, 131 163 topicId: id, 132 164 });
+57 -39
apps/web/src/routes/__tests__/topics.test.tsx
··· 46 46 } 47 47 48 48 function makeTopicResponse(overrides: Record<string, unknown> = {}) { 49 + const replies = (overrides.replies as unknown[]) ?? []; 50 + const total = overrides.total !== undefined ? overrides.total : replies.length; 51 + // Strip total from overrides so it doesn't override the computed total in the final spread 52 + const { total: _unusedTotal, ...restOverrides } = overrides; 53 + const base = { 54 + topicId: "1", 55 + locked: false, 56 + pinned: false, 57 + post: { 58 + id: "1", 59 + did: "did:plc:author", 60 + rkey: "tid123", 61 + text: "This is the original post", 62 + forumUri: null, 63 + boardUri: null, 64 + boardId: "42", 65 + parentPostId: null, 66 + createdAt: "2025-01-01T00:00:00.000Z", 67 + author: { did: "did:plc:author", handle: "alice.bsky.social" }, 68 + }, 69 + replies: [] as unknown[], 70 + offset: 0, 71 + limit: 25, 72 + }; 49 73 return { 50 74 ok: true, 51 - json: () => 52 - Promise.resolve({ 53 - topicId: "1", 54 - locked: false, 55 - pinned: false, 56 - post: { 57 - id: "1", 58 - did: "did:plc:author", 59 - rkey: "tid123", 60 - text: "This is the original post", 61 - forumUri: null, 62 - boardUri: null, 63 - boardId: "42", 64 - parentPostId: null, 65 - createdAt: "2025-01-01T00:00:00.000Z", 66 - author: { did: "did:plc:author", handle: "alice.bsky.social" }, 67 - }, 68 - replies: [], 69 - ...overrides, 70 - }), 75 + json: () => Promise.resolve({ ...base, ...restOverrides, total }), 71 76 }; 72 77 } 73 78 ··· 263 268 const routes = await loadTopicsRoutes(); 264 269 const res = await routes.request("/topics/1"); 265 270 const html = await res.text(); 266 - expect(html).toContain("No replies yet"); 271 + expect(html).toContain("No replies to show"); 267 272 }); 268 273 269 274 // ─── Breadcrumb ─────────────────────────────────────────────────────────── ··· 495 500 } 496 501 497 502 it("shows Load More button when more replies remain", async () => { 498 - setupSuccessfulFetch({ replies: makeReplies(30) }); // 30 replies, page size 25 503 + // AppView returns 25 replies (limit=25) but total=30 → Load More should appear 504 + setupSuccessfulFetch({ replies: makeReplies(25), total: 30 }); 499 505 const routes = await loadTopicsRoutes(); 500 506 const res = await routes.request("/topics/1"); 501 507 const html = await res.text(); ··· 504 510 }); 505 511 506 512 it("hides Load More button when all replies fit on one page", async () => { 507 - setupSuccessfulFetch({ replies: makeReplies(10) }); // 10 replies, fits in 25 513 + setupSuccessfulFetch({ replies: makeReplies(10), total: 10 }); // 10 replies, fits in 25 508 514 const routes = await loadTopicsRoutes(); 509 515 const res = await routes.request("/topics/1"); 510 516 const html = await res.text(); ··· 512 518 }); 513 519 514 520 it("Load More button URL contains correct next offset", async () => { 515 - setupSuccessfulFetch({ replies: makeReplies(30) }); 521 + // AppView returns 25 replies, total=30 → next offset is 25 522 + setupSuccessfulFetch({ replies: makeReplies(25), total: 30 }); 516 523 const routes = await loadTopicsRoutes(); 517 524 const res = await routes.request("/topics/1"); 518 525 const html = await res.text(); ··· 521 528 }); 522 529 523 530 it("Load More button has hx-push-url for bookmarkable URL", async () => { 524 - setupSuccessfulFetch({ replies: makeReplies(30) }); 531 + setupSuccessfulFetch({ replies: makeReplies(25), total: 30 }); 525 532 const routes = await loadTopicsRoutes(); 526 533 const res = await routes.request("/topics/1"); 527 534 const html = await res.text(); ··· 532 539 // ─── Bookmark support ───────────────────────────────────────────────────── 533 540 534 541 it("renders OP + all replies up to offset+pageSize when offset is set (bookmark)", async () => { 535 - setupSuccessfulFetch({ replies: makeReplies(60) }); // 60 replies total 542 + // URL ?offset=25 → web requests ?offset=0&limit=50 → AppView returns 50 replies, total=60 543 + setupSuccessfulFetch({ replies: makeReplies(50), total: 60 }); 536 544 const routes = await loadTopicsRoutes(); 537 545 // Bookmark at offset=25 — should render replies 0..49 (50 replies) inline 538 546 const res = await routes.request("/topics/1?offset=25"); ··· 542 550 expect(html).toContain("Reply 50"); 543 551 // Reply 51 should not be present (beyond the bookmark range) 544 552 expect(html).not.toContain("Reply 51"); 553 + // Assert the AppView URL used the correct offset=0 and limit=50 554 + const topicCallUrl = mockFetch.mock.calls[0][0] as string; 555 + expect(topicCallUrl).toContain("offset=0"); 556 + expect(topicCallUrl).toContain("limit=50"); 545 557 }); 546 558 547 559 it("Load More after bookmark points to correct next offset", async () => { 548 - setupSuccessfulFetch({ replies: makeReplies(60) }); 560 + // URL ?offset=25 → web requests ?offset=0&limit=50 → AppView returns 50 replies, total=60 561 + setupSuccessfulFetch({ replies: makeReplies(50), total: 60 }); 549 562 const routes = await loadTopicsRoutes(); 550 563 const res = await routes.request("/topics/1?offset=25"); 551 564 const html = await res.text(); ··· 554 567 }); 555 568 556 569 it("ignores negative offset values (treats as 0)", async () => { 557 - setupSuccessfulFetch({ replies: makeReplies(30) }); 570 + setupSuccessfulFetch({ replies: makeReplies(25), total: 30 }); 558 571 const routes = await loadTopicsRoutes(); 559 572 const res = await routes.request("/topics/1?offset=-5"); 560 573 const html = await res.text(); ··· 566 579 // ─── HTMX partial mode ──────────────────────────────────────────────────── 567 580 568 581 it("HTMX partial returns reply fragment at given offset", async () => { 569 - // Provide 27 replies: positions 0-24 are "already shown", 25-26 are the next page 570 - const allReplies = [ 571 - ...Array.from({ length: 25 }, (_, i) => makeReply({ id: String(i + 2), text: `Prior reply ${i + 1}` })), 582 + // AppView is called with ?offset=25&limit=25 → returns only the next page of replies 583 + const pageReplies = [ 572 584 makeReply({ id: "27", text: "HTMX loaded reply" }), 573 585 makeReply({ id: "28", text: "Another HTMX reply" }), 574 586 ]; 575 - // HTMX partial: only 1 fetch (GET /api/topics/:id) 576 - mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: allReplies })); 587 + // HTMX partial: only 1 fetch (GET /api/topics/:id?offset=25&limit=25) 588 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: pageReplies, total: 27 })); 577 589 const routes = await loadTopicsRoutes(); 578 590 const res = await routes.request("/topics/1?offset=25", { 579 591 headers: { "HX-Request": "true" }, ··· 582 594 const html = await res.text(); 583 595 expect(html).toContain("HTMX loaded reply"); 584 596 expect(html).toContain("Another HTMX reply"); 597 + // Assert the AppView URL used the correct offset and limit 598 + const topicCallUrl = mockFetch.mock.calls[0][0] as string; 599 + expect(topicCallUrl).toContain("offset=25"); 600 + expect(topicCallUrl).toContain("limit=25"); 585 601 }); 586 602 587 603 it("HTMX partial includes hx-push-url on Load More button", async () => { 588 - // 30 replies total, requesting offset=0 → should show 0..24 and Load More at 25 589 - mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: makeReplies(30) })); 604 + // AppView called with ?offset=0&limit=25 → returns 25 replies, total=30 → Load More at offset=25 605 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: makeReplies(25), total: 30 })); 590 606 const routes = await loadTopicsRoutes(); 591 607 const res = await routes.request("/topics/1?offset=0", { 592 608 headers: { "HX-Request": "true" }, ··· 596 612 expect(html).toContain("/topics/1?offset=25"); 597 613 }); 598 614 599 - it("HTMX partial returns empty fragment on error", async () => { 615 + it("HTMX partial returns retry fragment on error", async () => { 600 616 mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 601 617 const routes = await loadTopicsRoutes(); 602 618 const res = await routes.request("/topics/1?offset=25", { ··· 604 620 }); 605 621 expect(res.status).toBe(200); 606 622 const html = await res.text(); 607 - expect(html.trim()).toBe(""); 623 + expect(html).toContain("Failed to load more replies"); 624 + expect(html).toContain("Try again"); 625 + expect(html).toContain("hx-get"); 608 626 }); 609 627 610 628 it("HTMX partial re-throws TypeError (programming error)", async () => { ··· 990 1008 }); 991 1009 992 1010 it("HTMX partial renders mod buttons for authenticated moderator", async () => { 993 - const allReplies = [makeReply({ id: "27", text: "HTMX partial reply" })]; 1011 + const pageReplies = [makeReply({ id: "27", text: "HTMX partial reply" })]; 994 1012 // getSessionWithPermissions makes 2 fetches (session + members/me), then 1 for topic 995 1013 mockFetch.mockResolvedValueOnce(authSession); 996 1014 mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.moderatePosts"])); 997 - mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: allReplies })); 1015 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: pageReplies, total: 1 })); 998 1016 const routes = await loadTopicsRoutes(); 999 1017 const res = await routes.request("/topics/1?offset=0", { 1000 1018 headers: { "HX-Request": "true", cookie: "atbb_session=token" },
+35 -19
apps/web/src/routes/topics.tsx
··· 43 43 pinned: boolean; 44 44 post: PostResponse; 45 45 replies: PostResponse[]; 46 + total: number; 47 + offset: number; 48 + limit: number; 46 49 } 47 50 48 51 interface BoardResponse { ··· 217 220 function ReplyFragment({ 218 221 topicId, 219 222 replies, 220 - total, 221 223 offset, 224 + limit, 222 225 modPerms = { canHide: false, canBan: false }, 223 226 }: { 224 227 topicId: string; 225 228 replies: PostResponse[]; 226 - total: number; 227 229 offset: number; 230 + limit: number; 228 231 modPerms?: { canHide: boolean; canBan: boolean }; 229 232 }) { 230 233 const nextOffset = offset + replies.length; 231 - const hasMore = nextOffset < total; 234 + // Use page-fullness as the signal: if the page was full, there are likely more replies. 235 + // total is approximate (pre-in-memory-filter), so nextOffset < total can loop infinitely 236 + // if in-memory filters (bans, hidden posts) reduce the visible count to zero. 237 + const hasMore = replies.length >= limit; 232 238 return ( 233 239 <> 234 240 {replies.map((reply, i) => ( ··· 276 282 canLock: canLockTopics(partialAuth), 277 283 }; 278 284 try { 279 - const data = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`); 280 - // TODO(ATB-33): switch to server-side offset/limit pagination like boards.tsx 281 - // to avoid fetching all replies on every Load More click 282 - const pageReplies = data.replies.slice(offset, offset + REPLIES_PER_PAGE); 285 + const data = await fetchApi<TopicDetailResponse>( 286 + `/topics/${topicId}?offset=${offset}&limit=${REPLIES_PER_PAGE}` 287 + ); 283 288 return c.html( 284 289 <ReplyFragment 285 290 topicId={topicId} 286 - replies={pageReplies} 287 - total={data.replies.length} 291 + replies={data.replies} 288 292 offset={offset} 293 + limit={data.limit} 289 294 modPerms={partialModPerms} 290 295 />, 291 296 200 ··· 298 303 offset, 299 304 error: error instanceof Error ? error.message : String(error), 300 305 }); 301 - return c.html("", 200); 306 + return c.html( 307 + <button 308 + hx-get={`/topics/${topicId}?offset=${offset}`} 309 + hx-swap="outerHTML" 310 + hx-target="this" 311 + > 312 + Failed to load more replies. Try again 313 + </button>, 314 + 200 315 + ); 302 316 } 303 317 } 304 318 ··· 314 328 // Stage 1: fetch topic (fatal on failure) 315 329 let topicData: TopicDetailResponse; 316 330 try { 317 - topicData = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`); 331 + // For bookmark support: if offset > 0, show all replies from 0 to offset+page 332 + // e.g. URL ?offset=25 → request limit=50 so replies 0..49 are shown 333 + const displayLimit = offset + REPLIES_PER_PAGE; 334 + topicData = await fetchApi<TopicDetailResponse>( 335 + `/topics/${topicId}?offset=0&limit=${displayLimit}` 336 + ); 318 337 } catch (error) { 319 338 if (isProgrammingError(error)) throw error; 320 339 ··· 381 400 } 382 401 } 383 402 384 - // Pagination: show replies from 0 to offset+REPLIES_PER_PAGE (bookmark support) 385 - const allReplies = topicData.replies; 386 - const displayCount = offset + REPLIES_PER_PAGE; 387 - const initialReplies = allReplies.slice(0, displayCount); 388 - const total = allReplies.length; 403 + // Replies already paginated by AppView (offset=0, limit=offset+REPLIES_PER_PAGE) 404 + const initialReplies = topicData.replies; 389 405 390 406 const topicTitle = topicData.post.title ?? topicData.post.text.slice(0, 60); 391 407 ··· 428 444 <PostCard post={topicData.post} postNumber={1} isOP={true} modPerms={modPerms} /> 429 445 430 446 <div id="reply-list"> 431 - {allReplies.length === 0 ? ( 432 - <EmptyState message="No replies yet." /> 447 + {initialReplies.length === 0 ? ( 448 + <EmptyState message="No replies to show." /> 433 449 ) : ( 434 450 <ReplyFragment 435 451 topicId={topicId} 436 452 replies={initialReplies} 437 - total={total} 438 453 offset={0} 454 + limit={topicData.limit} 439 455 modPerms={modPerms} 440 456 /> 441 457 )}
+27 -6
bruno/AppView API/Topics/Get Topic.bru
··· 12 12 topic_id: 1 13 13 } 14 14 15 + params:query { 16 + ~offset: 0 17 + ~limit: 25 18 + } 19 + 15 20 assert { 16 21 res.status: eq 200 17 22 res.body.topicId: isDefined ··· 19 24 res.body.replies: isArray 20 25 res.body.locked: isDefined 21 26 res.body.pinned: isDefined 27 + res.body.total: isDefined 28 + res.body.offset: isDefined 29 + res.body.limit: isDefined 22 30 } 23 31 24 32 docs { 25 - Get a topic (thread starter post) with all replies. 33 + Get a topic (thread starter post) with paginated replies. 26 34 27 35 Path params: 28 36 - id: Topic post ID (bigint as string) 29 37 38 + Query params: 39 + - offset: Number of replies to skip (optional, default 0, clamped to >= 0) 40 + - limit: Max replies to return (optional, default 25, max 250) 41 + 30 42 Returns: 31 43 { 32 44 "topicId": "1", 33 45 "locked": false, 34 46 "pinned": false, 47 + "total": 42, 48 + "offset": 0, 49 + "limit": 25, 35 50 "post": { 36 51 "id": "1", 37 52 "did": "did:plc:...", ··· 62 77 ] 63 78 } 64 79 80 + Pagination notes: 81 + - total is the reply count after SQL-level moderation (bannedByMod=false), but before 82 + in-memory filters (active user bans, hidden posts). Treat as an upper bound. 83 + - To page through replies: increment offset by limit on each Load More click 84 + - For bookmark/deep-link support: request offset=0 and limit=(desired_offset + page_size) 85 + 65 86 Moderation enforcement (fail-open: errors in mod lookups show all content): 66 - - Replies from banned users are excluded 87 + - Replies from banned users are excluded from the current page 67 88 - Replies hidden by mod "delete" action are excluded (restored by "undelete") 68 89 - locked/pinned reflect most recent lock/pin action per topic 69 90 70 - Returns 400 if ID is invalid. 71 - Returns 404 if topic not found. 72 - Returns 500 if topic query fails. 73 - Maximum 1000 replies returned (defensive limit). 91 + Error codes: 92 + - 400: Invalid topic ID format 93 + - 404: Topic not found 94 + - 500: Server error 74 95 }