Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

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

web: cleanup silly stuff

+129 -119
+5 -5
web/src/components/form/BoardRowEditor.tsx
··· 4 4 export interface BoardRow { 5 5 slug: string; 6 6 name: string; 7 - desc: string; 7 + description: string; 8 8 } 9 9 10 10 interface BoardRowEditorProps { ··· 27 27 <div> 28 28 <label className="block text-neutral-400 mb-1">Boards</label> 29 29 <p className="text-neutral-400 text-xs mb-2"> 30 - One board per row: slug, name, description 30 + One board per row: slug, name, descriptionription 31 31 </p> 32 32 <div className="space-y-2"> 33 33 {boards.map((board, i) => ( ··· 46 46 className="w-1/3!" 47 47 /> 48 48 <Input 49 - value={board.desc} 50 - onChange={(e) => updateBoard(i, "desc", e.target.value)} 49 + value={board.description} 50 + onChange={(e) => updateBoard(i, "description", e.target.value)} 51 51 placeholder="Description" 52 52 maxLength={limits.BOARD_DESCRIPTION} 53 53 className="flex-1!" ··· 57 57 </div> 58 58 <button 59 59 type="button" 60 - onClick={() => onChange([...boards, { slug: "", name: "", desc: "" }])} 60 + onClick={() => onChange([...boards, { slug: "", name: "", description: "" }])} 61 61 className="mt-2 text-neutral-400 hover:text-neutral-300 text-xs" 62 62 > 63 63 + add board
+6
web/src/lib/alerts.ts
··· 1 + export function alertOnError(operation: string) { 2 + return (err: unknown) => 3 + alert( 4 + `Could not ${operation}: ${err instanceof Error ? err.message : err}`, 5 + ); 6 + }
+79
web/src/lib/threadCache.ts
··· 1 + import { queryClient } from "./queryClient"; 2 + import { threadPageQuery, threadRefsQuery } from "./queries"; 3 + import { REPLIES_PER_PAGE, refToUri } from "./replies"; 4 + import type { BacklinkRef } from "./atproto"; 5 + import type { ReplyPage } from "./thread"; 6 + import type { Reply } from "../components/post/ReplyCard"; 7 + 8 + export async function cancelRefsRefetch(threadUri: string) { 9 + await queryClient.cancelQueries({ 10 + queryKey: threadRefsQuery(threadUri).queryKey, 11 + }); 12 + } 13 + 14 + export function getRefs(threadUri: string): BacklinkRef[] { 15 + const key = threadRefsQuery(threadUri).queryKey; 16 + return queryClient.getQueryData<BacklinkRef[]>(key) ?? []; 17 + } 18 + 19 + export function setRefs(threadUri: string, refs: BacklinkRef[]) { 20 + queryClient.setQueryData(threadRefsQuery(threadUri).queryKey, refs); 21 + } 22 + 23 + function pageSlice(refs: BacklinkRef[], page: number): BacklinkRef[] { 24 + const start = (page - 1) * REPLIES_PER_PAGE; 25 + return refs.slice(start, start + REPLIES_PER_PAGE); 26 + } 27 + 28 + // threadPageQuery's key is fingerprinted by reply rkeys, so adding or removing 29 + // a reply changes the cache key. We read the pre-change page data from the old 30 + // key and seed the new key explicitly. 31 + export function appendRefAndReply( 32 + threadUri: string, 33 + newRef: BacklinkRef, 34 + newReply: Reply, 35 + ): BacklinkRef[] { 36 + const previousRefs = getRefs(threadUri); 37 + const updatedRefs = [...previousRefs, newRef]; 38 + 39 + const newLastPage = Math.max( 40 + 1, 41 + Math.ceil(updatedRefs.length / REPLIES_PER_PAGE), 42 + ); 43 + const oldPageRefs = pageSlice(previousRefs, newLastPage); 44 + const oldKey = threadPageQuery(threadUri, newLastPage, oldPageRefs).queryKey; 45 + const oldData = queryClient.getQueryData<ReplyPage>(oldKey); 46 + 47 + setRefs(threadUri, updatedRefs); 48 + 49 + const pageRefs = pageSlice(updatedRefs, newLastPage); 50 + const newKey = threadPageQuery(threadUri, newLastPage, pageRefs).queryKey; 51 + queryClient.setQueryData<ReplyPage>(newKey, { 52 + replies: [...(oldData?.replies ?? []), newReply], 53 + parentReplies: oldData?.parentReplies ?? {}, 54 + }); 55 + 56 + return updatedRefs; 57 + } 58 + 59 + export function removeRefAndReply( 60 + threadUri: string, 61 + replyUri: string, 62 + currentPage: number, 63 + ) { 64 + const previousRefs = getRefs(threadUri); 65 + const oldPageRefs = pageSlice(previousRefs, currentPage); 66 + const oldKey = threadPageQuery(threadUri, currentPage, oldPageRefs).queryKey; 67 + const oldData = queryClient.getQueryData<ReplyPage>(oldKey); 68 + 69 + const updatedRefs = previousRefs.filter((ref) => refToUri(ref) !== replyUri); 70 + setRefs(threadUri, updatedRefs); 71 + 72 + if (!oldData) return; 73 + const pageRefs = pageSlice(updatedRefs, currentPage); 74 + const newKey = threadPageQuery(threadUri, currentPage, pageRefs).queryKey; 75 + queryClient.setQueryData<ReplyPage>(newKey, { 76 + ...oldData, 77 + replies: oldData.replies.filter((r) => r.uri !== replyUri), 78 + }); 79 + }
+2 -5
web/src/pages/BBS.tsx
··· 20 20 import * as limits from "../lib/limits"; 21 21 import { bbsQuery, newsQuery, pinsQuery } from "../lib/queries"; 22 22 import { queryClient } from "../lib/queryClient"; 23 + import { alertOnError } from "../lib/alerts"; 23 24 import type { NewsPost } from "../lib/bbs"; 24 25 import ComposeForm from "../components/form/ComposeForm"; 25 26 import Localtime from "../components/Localtime"; ··· 89 90 setNewsBody(""); 90 91 setNewsFiles([]); 91 92 }, 92 - onError: (error) => { 93 - alert( 94 - `Could not post: ${error instanceof Error ? error.message : error}`, 95 - ); 96 - }, 93 + onError: alertOnError("post"), 97 94 }); 98 95 99 96 function onPostNews(event: SyntheticEvent) {
+3 -6
web/src/pages/Board.tsx
··· 22 22 myThreadsQuery, 23 23 } from "../lib/queries"; 24 24 import { queryClient } from "../lib/queryClient"; 25 + import { alertOnError } from "../lib/alerts"; 25 26 import type { ThreadItem, ThreadPageResult } from "../lib/boardThreads"; 26 27 import ThreadLink, { ThreadListHeader } from "../components/nav/ThreadLink"; 27 28 import ComposeForm from "../components/form/ComposeForm"; ··· 143 144 }; 144 145 }, 145 146 ); 146 - refetchUntilIndexed(boardKey, resp.data.uri); 147 + void refetchUntilIndexed(boardKey, resp.data.uri); 147 148 queryClient.invalidateQueries(myThreadsQuery(user.pdsUrl, user.did)); 148 149 setTitle(""); 149 150 setBody(""); 150 151 setFiles([]); 151 152 navigate(`/bbs/${handle}/thread/${did}/${rkey}`); 152 153 }, 153 - onError: (error) => { 154 - alert( 155 - `Could not post: ${error instanceof Error ? error.message : error}`, 156 - ); 157 - }, 154 + onError: alertOnError("post"), 158 155 }); 159 156 160 157 function onCreate(event: SyntheticEvent) {
+2 -5
web/src/pages/News.tsx
··· 7 7 import { deleteRecord } from "../lib/writes"; 8 8 import { bbsQuery, newsQuery } from "../lib/queries"; 9 9 import { queryClient } from "../lib/queryClient"; 10 + import { alertOnError } from "../lib/alerts"; 10 11 import type { NewsPost } from "../lib/bbs"; 11 12 import NewsCard from "../components/post/NewsCard"; 12 13 ··· 44 45 ); 45 46 navigate(`/bbs/${handle}`); 46 47 }, 47 - onError: (error) => { 48 - alert( 49 - `Could not delete: ${error instanceof Error ? error.message : error}`, 50 - ); 51 - }, 48 + onError: alertOnError("delete"), 52 49 }); 53 50 54 51 if (!item) {
+3 -3
web/src/pages/SysopCreate.tsx
··· 23 23 { 24 24 slug: DEFAULT_BOARD.slug, 25 25 name: DEFAULT_BOARD.name, 26 - desc: DEFAULT_BOARD.description, 26 + description: DEFAULT_BOARD.description, 27 27 }, 28 28 ]); 29 29 const [error, setError] = useState<string | null>(null); ··· 37 37 .map((board) => ({ 38 38 slug: board.slug.trim(), 39 39 name: board.name.trim(), 40 - desc: board.desc.trim(), 40 + description: board.description.trim(), 41 41 })) 42 42 .filter((board) => board.slug); 43 43 if (!name.trim() || !cleanBoards.length) { ··· 51 51 agent, 52 52 board.slug, 53 53 board.name || board.slug, 54 - board.desc, 54 + board.description, 55 55 now, 56 56 ); 57 57 }
+3 -3
web/src/pages/SysopEdit.tsx
··· 28 28 bbs.site.boards.map((board) => ({ 29 29 slug: board.slug, 30 30 name: board.name, 31 - desc: board.description, 31 + description: board.description, 32 32 })), 33 33 ); 34 34 const [error, setError] = useState<string | null>(null); ··· 42 42 .map((board) => ({ 43 43 slug: board.slug.trim(), 44 44 name: board.name.trim(), 45 - desc: board.desc.trim(), 45 + description: board.description.trim(), 46 46 })) 47 47 .filter((board) => board.slug); 48 48 const now = nowIso(); ··· 52 52 agent, 53 53 board.slug, 54 54 board.name || board.slug, 55 - board.desc, 55 + board.description, 56 56 now, 57 57 ); 58 58 }
+13 -13
web/src/pages/SysopModerate.tsx
··· 10 10 import { Button } from "../components/form/Form"; 11 11 import { usePageTitle } from "../hooks/usePageTitle"; 12 12 import { createBan, createHide, deleteRecord } from "../lib/writes"; 13 + import { alertOnError } from "../lib/alerts"; 13 14 14 15 export default function SysopModerate() { 15 16 const { user, agent } = useAuth(); ··· 42 43 setIdentifier(""); 43 44 refreshModeration(); 44 45 }, 45 - onError: (err) => 46 - alert(`Could not ban: ${err instanceof Error ? err.message : err}`), 46 + onError: alertOnError("ban"), 47 47 }); 48 48 49 49 const unbanMutation = useMutation({ ··· 85 85 } 86 86 87 87 function hide() { 88 - const u = hideUri.trim(); 89 - if (!u.startsWith("at://")) { 88 + const uri = hideUri.trim(); 89 + if (!uri.startsWith("at://")) { 90 90 alert("Enter a valid AT-URI."); 91 91 return; 92 92 } 93 - hideMutation.mutate(u); 93 + hideMutation.mutate(uri); 94 94 } 95 95 96 96 function unhide(rkey: string) { ··· 149 149 <div> 150 150 <label className="block text-neutral-400 mb-3">Hidden Posts</label> 151 151 <div className="space-y-1 mb-3"> 152 - {hidden.map((p) => ( 152 + {hidden.map((post) => ( 153 153 <div 154 - key={p.uri} 155 - title={p.uri} 154 + key={post.uri} 155 + title={post.uri} 156 156 className="flex items-center justify-between gap-3 px-3 py-2 -mx-3 rounded hover:bg-neutral-800" 157 157 > 158 158 <a 159 - href={`https://pdsls.dev/${p.uri}`} 159 + href={`https://pdsls.dev/${post.uri}`} 160 160 target="_blank" 161 161 rel="noreferrer" 162 - aria-label={`${p.handle} — ${p.title || p.body} (opens in new tab)`} 162 + aria-label={`${post.handle} — ${post.title || post.body} (opens in new tab)`} 163 163 className="truncate text-neutral-300 hover:text-neutral-200" 164 164 > 165 - {p.handle} — {p.title || p.body} 165 + {post.handle} — {post.title || post.body} 166 166 </a> 167 - {hideRkeys[p.uri] && ( 167 + {hideRkeys[post.uri] && ( 168 168 <button 169 - onClick={() => unhide(hideRkeys[p.uri])} 169 + onClick={() => unhide(hideRkeys[post.uri])} 170 170 className="text-xs text-neutral-400 hover:text-red-400 shrink-0" 171 171 > 172 172 unhide
+13 -79
web/src/pages/Thread.tsx
··· 19 19 bbsModerationQuery, 20 20 bbsQuery, 21 21 myThreadsQuery, 22 - threadPageQuery, 23 - threadRefsQuery, 24 22 threadRootQuery, 25 23 } from "../lib/queries"; 26 24 import { queryClient } from "../lib/queryClient"; 27 25 import { threadUriFor } from "../lib/thread"; 28 - import { REPLIES_PER_PAGE, refToUri } from "../lib/replies"; 26 + import { REPLIES_PER_PAGE } from "../lib/replies"; 27 + import { 28 + appendRefAndReply, 29 + cancelRefsRefetch, 30 + getRefs, 31 + removeRefAndReply, 32 + setRefs, 33 + } from "../lib/threadCache"; 29 34 import { invalidateAllBBSCaches } from "../lib/bbs"; 35 + import { alertOnError } from "../lib/alerts"; 30 36 import type { BacklinkRef } from "../lib/atproto"; 31 - import type { ReplyPage } from "../lib/thread"; 32 37 import type { BBS } from "../lib/bbs"; 33 38 import PageNav from "../components/nav/PageNav"; 34 39 import ReplyCard, { type Reply } from "../components/post/ReplyCard"; ··· 133 138 ); 134 139 if (page !== newLastPage) setPage(newLastPage); 135 140 }, 136 - onError: (err) => 137 - alert( 138 - `Could not post reply: ${err instanceof Error ? err.message : err}`, 139 - ), 141 + onError: alertOnError("post reply"), 140 142 }); 141 143 142 144 const deleteReplyMutation = useMutation({ ··· 146 148 return reply; 147 149 }, 148 150 onMutate: async (reply) => { 149 - const refsKey = threadRefsQuery(threadUri).queryKey; 150 - await queryClient.cancelQueries({ queryKey: refsKey }); 151 + await cancelRefsRefetch(threadUri); 151 152 const previousRefs = getRefs(threadUri); 152 153 removeRefAndReply(threadUri, reply.uri, page); 153 154 return { previousRefs }; 154 155 }, 155 156 onError: (err, _reply, context) => { 156 157 if (context) setRefs(threadUri, context.previousRefs); 157 - alert(`Could not delete: ${err instanceof Error ? err.message : err}`); 158 + alertOnError("delete")(err); 158 159 }, 159 160 }); 160 161 ··· 169 170 } 170 171 navigate(`/bbs/${handle}`); 171 172 }, 172 - onError: (err) => 173 - alert(`Could not delete: ${err instanceof Error ? err.message : err}`), 173 + onError: alertOnError("delete"), 174 174 }); 175 175 176 176 const moderationMutationDefaults = { onSuccess: invalidateAllBBSCaches }; ··· 342 342 )} 343 343 </> 344 344 ); 345 - } 346 - 347 - // --- Cache-update helpers --- 348 - 349 - function getRefs(threadUri: string): BacklinkRef[] { 350 - const key = threadRefsQuery(threadUri).queryKey; 351 - return queryClient.getQueryData<BacklinkRef[]>(key) ?? []; 352 - } 353 - 354 - function setRefs(threadUri: string, refs: BacklinkRef[]) { 355 - queryClient.setQueryData(threadRefsQuery(threadUri).queryKey, refs); 356 - } 357 - 358 - function pageSlice(refs: BacklinkRef[], page: number): BacklinkRef[] { 359 - const start = (page - 1) * REPLIES_PER_PAGE; 360 - return refs.slice(start, start + REPLIES_PER_PAGE); 361 - } 362 - 363 - function appendRefAndReply( 364 - threadUri: string, 365 - newRef: BacklinkRef, 366 - newReply: Reply, 367 - ): BacklinkRef[] { 368 - const previousRefs = getRefs(threadUri); 369 - const updatedRefs = [...previousRefs, newRef]; 370 - 371 - const newLastPage = Math.max( 372 - 1, 373 - Math.ceil(updatedRefs.length / REPLIES_PER_PAGE), 374 - ); 375 - const oldPageRefs = pageSlice(previousRefs, newLastPage); 376 - const oldKey = threadPageQuery(threadUri, newLastPage, oldPageRefs).queryKey; 377 - const oldData = queryClient.getQueryData<ReplyPage>(oldKey); 378 - 379 - setRefs(threadUri, updatedRefs); 380 - 381 - const pageRefs = pageSlice(updatedRefs, newLastPage); 382 - const newKey = threadPageQuery(threadUri, newLastPage, pageRefs).queryKey; 383 - queryClient.setQueryData<ReplyPage>(newKey, { 384 - replies: [...(oldData?.replies ?? []), newReply], 385 - parentReplies: oldData?.parentReplies ?? {}, 386 - }); 387 - 388 - return updatedRefs; 389 - } 390 - 391 - function removeRefAndReply( 392 - threadUri: string, 393 - replyUri: string, 394 - currentPage: number, 395 - ) { 396 - const previousRefs = getRefs(threadUri); 397 - const oldPageRefs = pageSlice(previousRefs, currentPage); 398 - const oldKey = threadPageQuery(threadUri, currentPage, oldPageRefs).queryKey; 399 - const oldData = queryClient.getQueryData<ReplyPage>(oldKey); 400 - 401 - const updatedRefs = previousRefs.filter((ref) => refToUri(ref) !== replyUri); 402 - setRefs(threadUri, updatedRefs); 403 - 404 - if (!oldData) return; 405 - const pageRefs = pageSlice(updatedRefs, currentPage); 406 - const newKey = threadPageQuery(threadUri, currentPage, pageRefs).queryKey; 407 - queryClient.setQueryData<ReplyPage>(newKey, { 408 - ...oldData, 409 - replies: oldData.replies.filter((r) => r.uri !== replyUri), 410 - }); 411 345 } 412 346 413 347 function buildBreadcrumb(