Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
1import { useState, type SyntheticEvent } from "react";
2import { useNavigate, useParams } from "react-router-dom";
3import { useSuspenseQuery, useMutation } from "@tanstack/react-query";
4import { useAuth } from "../lib/auth";
5import { useBreadcrumb } from "../hooks/useBreadcrumb";
6import { usePageTitle } from "../hooks/usePageTitle";
7import { useThreadReplies } from "../hooks/useThreadReplies";
8import { BOARD, POST } from "../lib/lexicon";
9import { makeAtUri, nowIso, parseAtUri } from "../lib/util";
10import * as limits from "../lib/limits";
11import {
12 createPost,
13 deleteRecord,
14 uploadAttachments,
15} from "../lib/writes";
16import { useModerationMutations } from "../hooks/useModerationMutations";
17import {
18 bbsModerationQuery,
19 bbsQuery,
20 myThreadsQuery,
21 threadRootQuery,
22} from "../lib/queries";
23import { queryClient } from "../lib/queryClient";
24import { bbsUrl, boardUrl } from "../lib/routes";
25import { threadUriFor } from "../lib/thread";
26import { REPLIES_PER_PAGE } from "../lib/replies";
27import {
28 appendRefAndReply,
29 cancelRefsRefetch,
30 getRefs,
31 removeRefAndReply,
32 setRefs,
33} from "../lib/threadCache";
34import { alertOnError } from "../lib/alerts";
35import type { BacklinkRef } from "../lib/atproto";
36import type { BBS } from "../lib/bbs";
37import PageNav from "../components/nav/PageNav";
38import ReplyCard, { type Reply } from "../components/post/ReplyCard";
39import ComposeForm from "../components/form/ComposeForm";
40import ThreadCard from "../components/post/ThreadCard";
41
42export default function ThreadPage() {
43 const { handle, did, tid } = useParams();
44 const threadUri = threadUriFor(did!, tid!);
45 const { user, agent } = useAuth();
46 const navigate = useNavigate();
47
48 const { data: bbs } = useSuspenseQuery(bbsQuery(handle!));
49 const { data: thread } = useSuspenseQuery(threadRootQuery(did!, tid!));
50 const { data: moderation } = useSuspenseQuery(
51 bbsModerationQuery(bbs.identity.pds ?? "", bbs.identity.did),
52 );
53 const {
54 page,
55 setPage,
56 totalPages,
57 refs,
58 replies,
59 parentReplies,
60 scrollToReply,
61 } = useThreadReplies(threadUri);
62
63 const isSysop = !!(user && user.did === bbs.identity.did);
64 const threadHidden =
65 !isSysop &&
66 (!!moderation.banRkeys[thread.did] ||
67 !!moderation.hideRkeys[thread.uri]);
68 const visibleReplies = isSysop
69 ? replies
70 : replies.filter(
71 (reply) =>
72 !moderation.banRkeys[reply.did] &&
73 !moderation.hideRkeys[reply.uri],
74 );
75
76 const [body, setBody] = useState("");
77 const [files, setFiles] = useState<File[]>([]);
78 const [replyingTo, setReplyingTo] = useState<{
79 uri: string;
80 handle: string;
81 } | null>(null);
82
83 usePageTitle(`${thread.title} — ${bbs.site.name}`);
84 useBreadcrumb(buildBreadcrumb(bbs, thread.title, thread.boardSlug, handle!), [
85 bbs,
86 thread,
87 handle,
88 ]);
89
90 // --- Mutations ---
91
92 const createReplyMutation = useMutation({
93 mutationFn: async (input: {
94 body: string;
95 parent: string | null;
96 files: File[];
97 }) => {
98 if (!agent || !user) throw new Error("Not signed in");
99 const boardUri = makeAtUri(bbs.identity.did, BOARD, thread.boardSlug);
100 const attachments = await uploadAttachments(agent, input.files);
101 const resp = await createPost(agent, boardUri, input.body, {
102 root: threadUri,
103 parent: input.parent ?? undefined,
104 attachments,
105 });
106 return { resp, input, attachments };
107 },
108 onSuccess: ({ resp, input, attachments }) => {
109 if (!user) return;
110 const { did: newDid, rkey: newRkey } = parseAtUri(resp.data.uri);
111 const newRef: BacklinkRef = {
112 did: newDid,
113 collection: POST,
114 rkey: newRkey,
115 };
116 const newReply: Reply = {
117 uri: resp.data.uri,
118 did: newDid,
119 rkey: newRkey,
120 handle: user.handle,
121 pds: user.pdsUrl,
122 body: input.body,
123 createdAt: nowIso(),
124 parent: input.parent,
125 attachments: attachments as Reply["attachments"],
126 };
127
128 const updatedRefs = appendRefAndReply(threadUri, newRef, newReply);
129
130 setBody("");
131 setFiles([]);
132 setReplyingTo(null);
133
134 const newLastPage = Math.max(
135 1,
136 Math.ceil(updatedRefs.length / REPLIES_PER_PAGE),
137 );
138 if (page !== newLastPage) setPage(newLastPage);
139 },
140 onError: alertOnError("post reply"),
141 });
142
143 const deleteReplyMutation = useMutation({
144 mutationFn: async (reply: Reply) => {
145 if (!agent) throw new Error("Not signed in");
146 await deleteRecord(agent, POST, reply.rkey);
147 return reply;
148 },
149 onMutate: async (reply) => {
150 await cancelRefsRefetch(threadUri);
151 const previousRefs = getRefs(threadUri);
152 removeRefAndReply(threadUri, reply.uri, page);
153 return { previousRefs };
154 },
155 onError: (err, _reply, context) => {
156 if (context) setRefs(threadUri, context.previousRefs);
157 alertOnError("delete")(err);
158 },
159 });
160
161 const deleteThreadMutation = useMutation({
162 mutationFn: async () => {
163 if (!agent) throw new Error("Not signed in");
164 await deleteRecord(agent, POST, thread.rkey);
165 },
166 onSuccess: () => {
167 if (user) {
168 queryClient.invalidateQueries(myThreadsQuery(user.pdsUrl, user.did));
169 }
170 navigate(bbsUrl(handle!));
171 },
172 onError: alertOnError("delete"),
173 });
174
175 const { ban, unban, hide, unhide } = useModerationMutations();
176
177 // --- Handlers ---
178
179 function onReply(event: SyntheticEvent) {
180 event.preventDefault();
181 if (createReplyMutation.isPending) return;
182 createReplyMutation.mutate({
183 body: body.trim(),
184 parent: replyingTo?.uri ?? null,
185 files,
186 });
187 }
188
189 function onDeleteThread() {
190 if (!confirm("Delete this thread?")) return;
191 deleteThreadMutation.mutate();
192 }
193
194 function onDeleteReply(reply: Reply) {
195 if (!confirm("Delete this reply?")) return;
196 deleteReplyMutation.mutate(reply);
197 }
198
199 function onBan(banDid: string) {
200 if (!confirm("Ban this user from your community?")) return;
201 ban.mutate(banDid);
202 }
203
204 function onUnban(rkey: string) {
205 if (!confirm("Unban this user?")) return;
206 unban.mutate(rkey);
207 }
208
209 function onHide(uri: string) {
210 if (!confirm("Hide this post?")) return;
211 hide.mutate(uri);
212 }
213
214 function onUnhide(rkey: string) {
215 if (!confirm("Unhide this post?")) return;
216 unhide.mutate(rkey);
217 }
218
219 if (threadHidden) {
220 return (
221 <p className="text-neutral-400 py-16 text-center">
222 This thread has been hidden by the sysop.
223 </p>
224 );
225 }
226
227 return (
228 <>
229 <ThreadCard
230 thread={thread}
231 userDid={user?.did}
232 sysopDid={bbs.identity.did}
233 banRkey={moderation.banRkeys[thread.did] ?? null}
234 hideRkey={moderation.hideRkeys[thread.uri] ?? null}
235 onDelete={onDeleteThread}
236 onBan={() => onBan(thread.did)}
237 onUnban={onUnban}
238 onHide={() => onHide(thread.uri)}
239 onUnhide={onUnhide}
240 />
241
242 {totalPages > 1 && (
243 <PageNav current={page} total={totalPages} onGo={setPage} />
244 )}
245
246 <div className="space-y-2 mt-4">
247 {visibleReplies.length === 0 && !user ? (
248 <p className="text-neutral-400">No replies yet.</p>
249 ) : (
250 visibleReplies.map((reply) => {
251 const parentReply = reply.parent
252 ? parentReplies[reply.parent]
253 : null;
254 const parentHidden =
255 !!parentReply &&
256 !isSysop &&
257 (!!moderation.banRkeys[parentReply.did] ||
258 !!moderation.hideRkeys[parentReply.uri]);
259 return (
260 <ReplyCard
261 key={reply.uri}
262 reply={reply}
263 userDid={user?.did ?? ""}
264 sysopDid={bbs.identity.did}
265 parentPost={
266 parentHidden ? undefined : (parentReply ?? undefined)
267 }
268 banRkey={moderation.banRkeys[reply.did] ?? null}
269 hideRkey={moderation.hideRkeys[reply.uri] ?? null}
270 onReplyTo={() =>
271 setReplyingTo({ uri: reply.uri, handle: reply.handle })
272 }
273 onParentClick={
274 reply.parent ? () => scrollToReply(reply.parent!) : undefined
275 }
276 onDelete={() => onDeleteReply(reply)}
277 onBan={() => onBan(reply.did)}
278 onUnban={onUnban}
279 onHide={() => onHide(reply.uri)}
280 onUnhide={onUnhide}
281 />
282 );
283 })
284 )}
285 </div>
286
287 {totalPages > 1 && (
288 <div className="mt-6">
289 <PageNav current={page} total={totalPages} onGo={setPage} />
290 </div>
291 )}
292
293 {user && (
294 <ComposeForm
295 className="mt-6 border border-neutral-800 rounded p-4"
296 onSubmit={onReply}
297 body={body}
298 onBodyChange={setBody}
299 bodyPlaceholder="Write a reply..."
300 bodyRows={3}
301 bodyMaxLength={limits.POST_BODY}
302 files={files}
303 onFilesChange={setFiles}
304 replyingTo={replyingTo}
305 onClearReplyTo={() => setReplyingTo(null)}
306 submitLabel="reply"
307 posting={createReplyMutation.isPending}
308 />
309 )}
310 </>
311 );
312}
313
314function buildBreadcrumb(
315 bbs: BBS,
316 threadTitle: string,
317 boardSlug: string,
318 handle: string,
319) {
320 const board = bbs.site.boards.find((b) => b.slug === boardSlug);
321 return [
322 { label: bbs.site.name, to: bbsUrl(handle) },
323 ...(board ? [{ label: board.name, to: boardUrl(handle, board.slug) }] : []),
324 { label: threadTitle },
325 ];
326}