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.

at master 326 lines 9.7 kB view raw
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}