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
1import { Hono } from "hono";
2import { BaseLayout } from "../layouts/base.js";
3import { PageHeader, EmptyState, ErrorDisplay } from "../components/index.js";
4import { fetchApi } from "../lib/api.js";
5import {
6 getSessionWithPermissions,
7 canLockTopics,
8 canModeratePosts,
9 canBanUsers,
10} from "../lib/session.js";
11import {
12 isProgrammingError,
13 isNetworkError,
14 isNotFoundError,
15} from "../lib/errors.js";
16import { timeAgo } from "../lib/time.js";
17import { logger } from "../lib/logger.js";
18import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js";
19
20// ─── API response types ───────────────────────────────────────────────────────
21
22interface AuthorResponse {
23 did: string;
24 handle: string | null;
25}
26
27interface PostResponse {
28 id: string;
29 did: string;
30 rkey: string;
31 title: string | null;
32 text: string;
33 forumUri: string | null;
34 boardUri: string | null;
35 boardId: string | null;
36 parentPostId: string | null;
37 createdAt: string | null;
38 author: AuthorResponse | null;
39}
40
41interface TopicDetailResponse {
42 topicId: string;
43 locked: boolean;
44 pinned: boolean;
45 post: PostResponse;
46 replies: PostResponse[];
47 total: number;
48 offset: number;
49 limit: number;
50}
51
52interface BoardResponse {
53 id: string;
54 did: string;
55 uri: string;
56 name: string;
57 description: string | null;
58 slug: string | null;
59 sortOrder: number | null;
60 categoryId: string;
61 categoryUri: string | null;
62 createdAt: string | null;
63 indexedAt: string | null;
64}
65
66interface CategoryResponse {
67 id: string;
68 did: string;
69 name: string;
70 description: string | null;
71 slug: string | null;
72 sortOrder: number | null;
73 forumId: string | null;
74 createdAt: string | null;
75 indexedAt: string | null;
76}
77
78// ─── Constants ────────────────────────────────────────────────────────────────
79
80const REPLIES_PER_PAGE = 25;
81
82const REPLY_CHAR_COUNTER_SCRIPT = `
83 function updateReplyCharCount(el) {
84 var seg = new Intl.Segmenter();
85 var n = Array.from(seg.segment(el.value)).length;
86 var counter = document.getElementById("reply-char-count");
87 counter.textContent = (300 - n) + " left";
88 counter.dataset.over = n > 300 ? "true" : "false";
89 }
90`;
91
92const MOD_DIALOG_SCRIPT = `
93 var MOD_TITLES = {
94 lock: 'Lock Topic', unlock: 'Unlock Topic',
95 hide: 'Hide Post', unhide: 'Unhide Post',
96 ban: 'Ban User', unban: 'Unban User'
97 };
98 function openModDialog(action, id) {
99 document.getElementById('mod-dialog-action').value = action;
100 document.getElementById('mod-dialog-id').value = id;
101 document.getElementById('mod-dialog-title').textContent = MOD_TITLES[action] || 'Confirm';
102 document.getElementById('mod-dialog-error').innerHTML = '';
103 document.getElementById('mod-reason').value = '';
104 document.getElementById('mod-dialog').showModal();
105 }
106`;
107
108// ─── Inline components ────────────────────────────────────────────────────────
109
110function PostCard({
111 post,
112 postNumber,
113 isOP = false,
114 modPerms = { canHide: false, canBan: false },
115}: {
116 post: PostResponse;
117 postNumber: number;
118 isOP?: boolean;
119 modPerms?: { canHide: boolean; canBan: boolean };
120}) {
121 const handle = post.author?.handle ?? post.author?.did ?? post.did;
122 const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown";
123 const cardClass = isOP ? "post-card post-card--op" : "post-card post-card--reply";
124 return (
125 <article class={cardClass} id={`post-${postNumber}`}>
126 <div class="post-card__header">
127 <span class="post-card__number">#{postNumber}</span>
128 <span class="post-card__author">{handle}</span>
129 <span class="post-card__date">{date}</span>
130 </div>
131 <div class="post-card__body" style="white-space: pre-wrap">
132 {post.text}
133 </div>
134 {(modPerms.canHide || modPerms.canBan) && (
135 <div class="post-card__mod-actions">
136 {modPerms.canHide && (
137 <button
138 class="mod-btn mod-btn--hide"
139 type="button"
140 onclick={`openModDialog('hide','${post.id}')`}
141 >
142 Hide
143 </button>
144 )}
145 {modPerms.canBan && post.author?.did && (
146 <button
147 class="mod-btn mod-btn--ban"
148 type="button"
149 onclick={`openModDialog('ban','${post.author.did}')`}
150 >
151 Ban user
152 </button>
153 )}
154 </div>
155 )}
156 </article>
157 );
158}
159
160function ModDialog() {
161 return (
162 <dialog id="mod-dialog" class="mod-dialog" aria-labelledby="mod-dialog-title">
163 <h2 id="mod-dialog-title" class="mod-dialog__title">Confirm Action</h2>
164 <form
165 hx-post="/mod/action"
166 hx-target="#mod-dialog-error"
167 hx-swap="innerHTML"
168 hx-disabled-elt="[type=submit]"
169 >
170 <input type="hidden" id="mod-dialog-action" name="action" value="" />
171 <input type="hidden" id="mod-dialog-id" name="id" value="" />
172 <div class="form-group">
173 <label for="mod-reason">Reason</label>
174 <textarea
175 id="mod-reason"
176 name="reason"
177 rows={3}
178 placeholder="Reason for this action…"
179 autofocus
180 />
181 </div>
182 <div id="mod-dialog-error" />
183 <div class="form-actions">
184 <button type="submit" class="btn btn-danger">
185 Confirm
186 </button>
187 <button
188 type="button"
189 class="btn btn-secondary"
190 onclick="document.getElementById('mod-dialog').close()"
191 >
192 Cancel
193 </button>
194 </div>
195 </form>
196 </dialog>
197 );
198}
199
200function LoadMoreButton({
201 topicId,
202 nextOffset,
203}: {
204 topicId: string;
205 nextOffset: number;
206}) {
207 const url = `/topics/${topicId}?offset=${nextOffset}`;
208 return (
209 <button
210 hx-get={url}
211 hx-swap="outerHTML"
212 hx-target="this"
213 hx-push-url={url}
214 hx-indicator="#loading-spinner"
215 >
216 Load More
217 </button>
218 );
219}
220
221function ReplyFragment({
222 topicId,
223 replies,
224 offset,
225 limit,
226 modPerms = { canHide: false, canBan: false },
227}: {
228 topicId: string;
229 replies: PostResponse[];
230 offset: number;
231 limit: number;
232 modPerms?: { canHide: boolean; canBan: boolean };
233}) {
234 const nextOffset = offset + replies.length;
235 // Use page-fullness as the signal: if the page was full, there are likely more replies.
236 // total is approximate (pre-in-memory-filter), so nextOffset < total can loop infinitely
237 // if in-memory filters (bans, hidden posts) reduce the visible count to zero.
238 const hasMore = replies.length >= limit;
239 return (
240 <>
241 {replies.map((reply, i) => (
242 <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} modPerms={modPerms} />
243 ))}
244 {hasMore && <LoadMoreButton topicId={topicId} nextOffset={nextOffset} />}
245 {offset > 0 && (
246 <div id="reply-status" class="sr-only" aria-live="polite" hx-swap-oob="true">
247 {replies.length} more {replies.length === 1 ? "reply" : "replies"} loaded
248 </div>
249 )}
250 </>
251 );
252}
253
254// ─── Route factory ────────────────────────────────────────────────────────────
255
256export function createTopicsRoutes(appviewUrl: string) {
257 return new Hono<WebAppEnv>().get("/topics/:id", async (c) => {
258 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
259 const idParam = c.req.param("id");
260 const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
261 const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw;
262
263 // ── Validate ID ──────────────────────────────────────────────────────────
264 if (!/^\d+$/.test(idParam)) {
265 if (c.req.header("HX-Request")) {
266 return c.html("", 200);
267 }
268 return c.html(
269 <BaseLayout title="Bad Request — atBB Forum" resolvedTheme={resolvedTheme}>
270 <ErrorDisplay message="Invalid topic ID." />
271 </BaseLayout>,
272 400
273 );
274 }
275
276 const topicId = idParam;
277
278 // ── HTMX partial mode ────────────────────────────────────────────────────
279 if (c.req.header("HX-Request")) {
280 const partialAuth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
281 const partialModPerms = {
282 canHide: canModeratePosts(partialAuth),
283 canBan: canBanUsers(partialAuth),
284 canLock: canLockTopics(partialAuth),
285 };
286 try {
287 const data = await fetchApi<TopicDetailResponse>(
288 `/topics/${topicId}?offset=${offset}&limit=${REPLIES_PER_PAGE}`
289 );
290 return c.html(
291 <ReplyFragment
292 topicId={topicId}
293 replies={data.replies}
294 offset={offset}
295 limit={data.limit}
296 modPerms={partialModPerms}
297 />,
298 200
299 );
300 } catch (error) {
301 if (isProgrammingError(error)) throw error;
302 logger.error("Failed to load replies for HTMX partial request", {
303 operation: "GET /topics/:id (HTMX partial)",
304 topicId,
305 offset,
306 error: error instanceof Error ? error.message : String(error),
307 });
308 return c.html(
309 <button
310 hx-get={`/topics/${topicId}?offset=${offset}`}
311 hx-swap="outerHTML"
312 hx-target="this"
313 >
314 Failed to load more replies. Try again
315 </button>,
316 200
317 );
318 }
319 }
320
321 // ── Full page mode ────────────────────────────────────────────────────────
322 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
323 const modPerms = {
324 canHide: canModeratePosts(auth),
325 canBan: canBanUsers(auth),
326 canLock: canLockTopics(auth),
327 };
328 const hasAnyModPerm = modPerms.canHide || modPerms.canBan || modPerms.canLock;
329
330 // Stage 1: fetch topic (fatal on failure)
331 let topicData: TopicDetailResponse;
332 try {
333 // For bookmark support: if offset > 0, show all replies from 0 to offset+page
334 // e.g. URL ?offset=25 → request limit=50 so replies 0..49 are shown
335 const displayLimit = offset + REPLIES_PER_PAGE;
336 topicData = await fetchApi<TopicDetailResponse>(
337 `/topics/${topicId}?offset=0&limit=${displayLimit}`
338 );
339 } catch (error) {
340 if (isProgrammingError(error)) throw error;
341
342 if (isNotFoundError(error)) {
343 return c.html(
344 <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
345 <ErrorDisplay message="This topic doesn't exist." />
346 </BaseLayout>,
347 404
348 );
349 }
350
351 logger.error("Failed to load topic page (stage 1: topic)", {
352 operation: "GET /topics/:id",
353 topicId,
354 error: error instanceof Error ? error.message : String(error),
355 });
356 const status = isNetworkError(error) ? 503 : 500;
357 const message =
358 status === 503
359 ? "The forum is temporarily unavailable. Please try again later."
360 : "Something went wrong loading this topic. Please try again later.";
361 return c.html(
362 <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
363 <ErrorDisplay message={message} />
364 </BaseLayout>,
365 status
366 );
367 }
368
369 // Stage 2: fetch board for breadcrumb (non-fatal)
370 let boardName: string | null = null;
371 let categoryId: string | null = null;
372 if (topicData.post.boardId) {
373 try {
374 const board = await fetchApi<BoardResponse>(`/boards/${topicData.post.boardId}`);
375 boardName = board.name;
376 categoryId = board.categoryId;
377 } catch (error) {
378 if (isProgrammingError(error)) throw error;
379 logger.error("Failed to load topic page (stage 2: board)", {
380 operation: "GET /topics/:id",
381 topicId,
382 boardId: topicData.post.boardId,
383 error: error instanceof Error ? error.message : String(error),
384 });
385 }
386 }
387
388 // Stage 3: fetch category for breadcrumb (non-fatal)
389 let categoryName: string | null = null;
390 if (categoryId) {
391 try {
392 const category = await fetchApi<CategoryResponse>(`/categories/${categoryId}`);
393 categoryName = category.name;
394 } catch (error) {
395 if (isProgrammingError(error)) throw error;
396 logger.error("Failed to load topic page (stage 3: category)", {
397 operation: "GET /topics/:id",
398 topicId,
399 categoryId,
400 error: error instanceof Error ? error.message : String(error),
401 });
402 }
403 }
404
405 // Replies already paginated by AppView (offset=0, limit=offset+REPLIES_PER_PAGE)
406 const initialReplies = topicData.replies;
407
408 const topicTitle = topicData.post.title ?? topicData.post.text.slice(0, 60);
409
410 return c.html(
411 <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}>
412 <nav class="breadcrumb" aria-label="Breadcrumb">
413 <ol>
414 <li><a href="/">Home</a></li>
415 {categoryName && categoryId && (
416 <li><a href="/">{categoryName}</a></li>
417 )}
418 {boardName && (
419 <li><a href={`/boards/${topicData.post.boardId}`}>{boardName}</a></li>
420 )}
421 <li><span>{topicTitle}</span></li>
422 </ol>
423 </nav>
424
425 <PageHeader title={topicTitle} />
426
427 {modPerms.canLock && (
428 <div class="topic-mod-controls">
429 <button
430 class={`mod-btn ${topicData.locked ? "mod-btn--unlock" : "mod-btn--lock"}`}
431 type="button"
432 onclick={`openModDialog('${topicData.locked ? "unlock" : "lock"}','${topicId}')`}
433 >
434 {topicData.locked ? "Unlock Topic" : "Lock Topic"}
435 </button>
436 </div>
437 )}
438
439 {topicData.locked && (
440 <div class="topic-locked-banner">
441 <span class="topic-locked-banner__badge">Locked</span>
442 This topic is locked.
443 </div>
444 )}
445
446 <PostCard post={topicData.post} postNumber={1} isOP={true} modPerms={modPerms} />
447
448 <div id="reply-list">
449 {initialReplies.length === 0 ? (
450 <EmptyState message="No replies to show." />
451 ) : (
452 <ReplyFragment
453 topicId={topicId}
454 replies={initialReplies}
455 offset={0}
456 limit={topicData.limit}
457 modPerms={modPerms}
458 />
459 )}
460 </div>
461 <div id="reply-status" class="sr-only" aria-live="polite"></div>
462
463 <div id="reply-form-slot">
464 {topicData.locked ? (
465 <p>This topic is locked. Replies are disabled.</p>
466 ) : auth?.authenticated ? (
467 <>
468 <form
469 hx-post={`/topics/${topicId}/reply`}
470 hx-target="#reply-form-error"
471 hx-swap="innerHTML"
472 hx-indicator="#reply-spinner"
473 hx-disabled-elt="[type=submit]"
474 >
475
476 <div class="form-group">
477 <label for="reply-text">Your reply</label>
478 <textarea
479 id="reply-text"
480 name="text"
481 rows={5}
482 placeholder="Write a reply…"
483 oninput="updateReplyCharCount(this)"
484 aria-required="true"
485 aria-describedby="reply-char-count"
486 />
487 <div id="reply-char-count" class="char-count" aria-live="polite">300 left</div>
488 </div>
489
490 <div id="reply-form-error" role="alert" />
491
492 <div class="form-actions">
493 <button type="submit" class="btn btn-primary">
494 Post Reply
495 <span id="reply-spinner" class="htmx-indicator">Posting…</span>
496 </button>
497 </div>
498 </form>
499 <script dangerouslySetInnerHTML={{ __html: REPLY_CHAR_COUNTER_SCRIPT }} />
500 </>
501 ) : (
502 <p>
503 <a href="/login">Log in</a> to reply.
504 </p>
505 )}
506 </div>
507
508 {hasAnyModPerm && (
509 <>
510 <ModDialog />
511 <script dangerouslySetInnerHTML={{ __html: MOD_DIALOG_SCRIPT }} />
512 </>
513 )}
514 </BaseLayout>
515 );
516 })
517 .post("/topics/:id/reply", async (c) => {
518 const topicId = c.req.param("id");
519
520 if (!/^\d+$/.test(topicId)) {
521 return c.html(<p class="form-error">Invalid topic ID.</p>);
522 }
523
524 const cookieHeader = c.req.header("cookie") ?? "";
525
526 let body: Record<string, string | File>;
527 try {
528 body = await c.req.parseBody();
529 } catch (error) {
530 logger.error("Failed to parse request body for POST /topics/:id/reply", {
531 operation: `POST /topics/${topicId}/reply`,
532 topicId,
533 error: error instanceof Error ? error.message : String(error),
534 });
535 return c.html(<p class="form-error">Invalid form submission.</p>);
536 }
537
538 const { text } = body;
539
540 if (typeof text !== "string" || !text.trim()) {
541 return c.html(<p class="form-error">Reply text is required.</p>);
542 }
543
544 let appviewRes: Response;
545 try {
546 appviewRes = await fetch(`${appviewUrl}/api/posts`, {
547 method: "POST",
548 headers: {
549 "Content-Type": "application/json",
550 "Cookie": cookieHeader,
551 },
552 body: JSON.stringify({
553 text,
554 rootPostId: topicId,
555 parentPostId: topicId,
556 }),
557 });
558 } catch (error) {
559 if (isProgrammingError(error)) throw error;
560 logger.error("Failed to proxy reply to AppView", {
561 operation: `POST /topics/${topicId}/reply`,
562 topicId,
563 error: error instanceof Error ? error.message : String(error),
564 });
565 return c.html(
566 <p class="form-error">Forum temporarily unavailable. Please try again.</p>
567 );
568 }
569
570 if (appviewRes.status === 201) {
571 const headers = new Headers();
572 headers.set("HX-Redirect", `/topics/${topicId}`);
573 return new Response(null, { status: 200, headers });
574 }
575
576 let errorMessage = "Something went wrong. Please try again.";
577
578 if (appviewRes.status === 401) {
579 errorMessage = "You must be logged in to reply.";
580 } else if (appviewRes.status === 403) {
581 try {
582 const errBody = (await appviewRes.json()) as { error?: string };
583 const msg = errBody.error ?? "";
584 if (msg.toLowerCase().includes("locked")) {
585 errorMessage = "This topic is locked.";
586 } else if (msg.toLowerCase().includes("banned")) {
587 errorMessage = "You are banned from this forum.";
588 } else {
589 errorMessage = msg || "You are not allowed to reply.";
590 }
591 } catch {
592 logger.error("Failed to parse AppView 403 response body for reply", {
593 operation: `POST /topics/${topicId}/reply`,
594 });
595 errorMessage = "You are not allowed to reply.";
596 }
597 } else if (appviewRes.status === 400) {
598 try {
599 const errBody = (await appviewRes.json()) as { error?: string };
600 errorMessage = errBody.error ?? errorMessage;
601 } catch {
602 logger.error("Failed to parse AppView 400 response body for reply", {
603 operation: `POST /topics/${topicId}/reply`,
604 });
605 }
606 } else if (appviewRes.status >= 400 && appviewRes.status < 500) {
607 logger.error("AppView returned unexpected client error for reply", {
608 operation: `POST /topics/${topicId}/reply`,
609 status: appviewRes.status,
610 });
611 } else if (appviewRes.status >= 500) {
612 logger.error("AppView returned server error for reply", {
613 operation: `POST /topics/${topicId}/reply`,
614 status: appviewRes.status,
615 });
616 }
617
618 return c.html(<p class="form-error">{errorMessage}</p>);
619 });
620}