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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at root/atb-56-theme-caching-layer 620 lines 21 kB view raw
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}