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/Thread: fix links to replies and add quote links

+126 -10
+10 -4
web/src/components/ReplyCard.tsx
··· 1 - import { formatFullDate, relativeDate } from "../lib/util"; 1 + import { formatFullDate, parseAtUri, relativeDate } from "../lib/util"; 2 2 import PostBody from "./PostBody.tsx"; 3 3 4 4 export interface Reply { ··· 19 19 sysopDid: string; 20 20 quoted?: Reply; 21 21 onQuote: () => void; 22 + onQuoteClick?: () => void; 22 23 onDelete: () => void; 23 24 onBan: () => void; 24 25 onHide: () => void; ··· 30 31 sysopDid, 31 32 quoted, 32 33 onQuote, 34 + onQuoteClick, 33 35 onDelete, 34 36 onBan, 35 37 onHide, ··· 38 40 const isSysop = userDid === sysopDid; 39 41 40 42 return ( 41 - <div className="reply-card border border-neutral-800/50 rounded p-4"> 43 + <div id={`reply-${reply.rkey}`} className="reply-card border border-neutral-800/50 rounded p-4"> 42 44 <div className="flex items-baseline justify-between mb-2"> 43 45 <div className="flex items-baseline gap-2"> 44 46 <span className="text-neutral-300">{reply.handle}</span> ··· 87 89 </div> 88 90 89 91 {quoted && ( 90 - <div className="border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-sm text-neutral-500"> 92 + <button 93 + type="button" 94 + onClick={onQuoteClick} 95 + className="block w-full text-left border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-sm text-neutral-500 hover:border-neutral-500 cursor-pointer" 96 + > 91 97 <span className="text-neutral-400">{quoted.handle}:</span>{" "} 92 98 <PostBody> 93 99 {quoted.body.substring(0, 200) + 94 100 (quoted.body.length > 200 ? "..." : "")} 95 101 </PostBody> 96 - </div> 102 + </button> 97 103 )} 98 104 99 105 <PostBody>{reply.body}</PostBody>
+110 -2
web/src/hooks/useThreadReplies.ts
··· 35 35 return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null; 36 36 } 37 37 38 + 39 + function rkeyFromHash(): string | null { 40 + const h = typeof window !== "undefined" ? window.location.hash : ""; 41 + return h.startsWith("#reply-") ? h.slice(7) : null; 42 + } 43 + 44 + function pageForRkey(refs: BacklinkRef[], rkey: string | null): number | null { 45 + if (!rkey) return null; 46 + const index = refs.findIndex((r) => r.rkey === rkey); 47 + return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null; 48 + } 49 + 38 50 function clampPage(page: number, totalRefs: number): number { 39 51 const totalPages = Math.max(1, Math.ceil(totalRefs / REPLIES_PER_PAGE)); 40 52 return Math.max(1, Math.min(page, totalPages)); ··· 92 104 93 105 // --- Pagination --- 94 106 107 + // Determine initial scroll target from ?reply= or #reply- 108 + const initialReplyParam = params.get("reply"); 109 + const initialHashRkey = rkeyFromHash(); 110 + const initialScrollRkey = initialReplyParam 111 + ? parseAtUri(initialReplyParam).rkey 112 + : initialHashRkey; 113 + 95 114 const [page, setPage] = useState<number>(() => { 96 115 const fromUrl = parseInt(params.get("page") ?? "1", 10); 97 - const fromFocus = pageForReply(allRefs, params.get("reply")); 98 - return clampPage(fromFocus ?? fromUrl, allRefs.length); 116 + const fromReply = pageForReply(allRefs, initialReplyParam); 117 + const fromHash = pageForRkey(allRefs, initialHashRkey); 118 + return clampPage(fromHash ?? fromReply ?? fromUrl, allRefs.length); 99 119 }); 120 + 121 + const [initialScrollDone, setInitialScrollDone] = useState(!initialScrollRkey); 100 122 101 123 // Keep the URL in sync when the user changes page (e.g. via PageNav). 102 124 useEffect(() => { ··· 124 146 const [replies, setReplies] = useState<Reply[]>([]); 125 147 const [loading, setLoading] = useState(true); 126 148 149 + // All replies we've ever seen — accumulates across page changes so quotes 150 + // and scroll targets always resolve, even for off-page replies. 151 + const [replyCache, setReplyCache] = useState<Record<string, Reply>>({}); 152 + 153 + // Pending scroll target — set when navigating to a reply on another page. 154 + // Cleared once the scroll completes. 155 + const [pendingScrollRkey, setPendingScrollRkey] = useState<string | null>(null); 156 + 127 157 const fetchVisiblePage = useCallback( 128 158 async (currentRefs: BacklinkRef[], currentPage: number) => { 129 159 setLoading(true); ··· 183 213 items.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); 184 214 setReplies(items); 185 215 setLoading(false); 216 + 217 + // Add current page replies to the cache 218 + const newCache: Record<string, Reply> = {}; 219 + for (const item of items) newCache[item.uri] = item; 220 + 221 + // Fetch any quoted replies not already known 222 + const missingQuotes = items 223 + .filter((i) => i.quote && !newCache[i.quote!]) 224 + .map((i) => i.quote!) 225 + .filter((uri) => !replyCache[uri]); 226 + if (missingQuotes.length) { 227 + const quoteRefs = [...new Set(missingQuotes)].map((uri) => parseAtUri(uri)); 228 + const quoteRecords = await getRecordsBatch(quoteRefs); 229 + const quoteDids = quoteRecords.map((r) => parseAtUri(r.uri).did); 230 + const quoteAuthors = await resolveIdentitiesBatch(quoteDids); 231 + for (const r of quoteRecords) { 232 + const { did, rkey } = parseAtUri(r.uri); 233 + if (!(did in quoteAuthors)) continue; 234 + const v = r.value as unknown as XyzAtboardsReply.Main; 235 + newCache[r.uri] = { 236 + uri: r.uri, 237 + did, 238 + rkey, 239 + handle: quoteAuthors[did].handle, 240 + pds: quoteAuthors[did].pds ?? "", 241 + body: v.body, 242 + createdAt: v.createdAt, 243 + quote: v.quote ?? null, 244 + attachments: (v.attachments ?? []) as Reply["attachments"], 245 + }; 246 + } 247 + } 248 + 249 + setReplyCache((prev) => ({ ...prev, ...newCache })); 186 250 }, 187 251 // eslint-disable-next-line react-hooks/exhaustive-deps -- pendingAdds 188 252 // is included so the merge step always sees the latest optimistic set ··· 197 261 // stable scalars, not the refs array reference or callback identity 198 262 }, [refsLength, page, loaderFingerprint]); 199 263 264 + // Scroll to a reply after a cross-page navigation completes. 265 + useEffect(() => { 266 + if (!pendingScrollRkey) return; 267 + const id = `reply-${pendingScrollRkey}`; 268 + const el = document.getElementById(id); 269 + if (el) { 270 + el.scrollIntoView({ behavior: "smooth" }); 271 + setPendingScrollRkey(null); 272 + } 273 + }, [pendingScrollRkey, replies]); 274 + 275 + // Scroll to the initial target after the first load. 276 + useEffect(() => { 277 + if (initialScrollDone || loading || !initialScrollRkey) return; 278 + setInitialScrollDone(true); 279 + const el = document.getElementById(`reply-${initialScrollRkey}`); 280 + if (el) { 281 + el.scrollIntoView({ behavior: "instant" }); 282 + } 283 + // eslint-disable-next-line react-hooks/exhaustive-deps 284 + }, [loading, replies]); 285 + 200 286 // --- Public actions --- 201 287 202 288 const addOptimisticReply = useCallback( ··· 228 314 setReplies((prev) => prev.filter((r) => r.uri !== uri)); 229 315 }, []); 230 316 317 + const scrollToReply = useCallback( 318 + (uri: string) => { 319 + const { rkey } = parseAtUri(uri); 320 + // If already on screen, just scroll 321 + const el = document.getElementById(`reply-${rkey}`); 322 + if (el) { 323 + el.scrollIntoView({ behavior: "smooth" }); 324 + return; 325 + } 326 + // Find the page and navigate — the effect will scroll once loaded 327 + const idx = refs.findIndex((r) => refToUri(r) === uri); 328 + if (idx >= 0) { 329 + const targetPage = Math.floor(idx / REPLIES_PER_PAGE) + 1; 330 + setPendingScrollRkey(rkey); 331 + setPage(targetPage); 332 + } 333 + }, 334 + [refs], 335 + ); 336 + 231 337 return { 232 338 page, 233 339 setPage, ··· 235 341 replies, 236 342 loading, 237 343 refs, 344 + replyCache, 345 + scrollToReply, 238 346 addOptimisticReply, 239 347 removeReply, 240 348 };
+2 -1
web/src/pages/Account.tsx
··· 175 175 <div> 176 176 {items.slice(0, shown).map((m) => { 177 177 const { did: tDid, rkey: tRkey } = parseAtUri(m.threadUri); 178 - const url = `/bbs/${userHandle}/thread/${tDid}/${tRkey}?reply=${encodeURIComponent(m.replyUri)}`; 178 + const { rkey: replyRkey } = parseAtUri(m.replyUri); 179 + const url = `/bbs/${userHandle}/thread/${tDid}/${tRkey}#reply-${replyRkey}`; 179 180 return ( 180 181 <Link 181 182 key={m.replyUri}
+4 -3
web/src/pages/Thread.tsx
··· 61 61 replies, 62 62 loading: loadingPage, 63 63 refs, 64 + replyCache, 65 + scrollToReply, 64 66 addOptimisticReply, 65 67 removeReply, 66 68 } = useThreadReplies(loaded); ··· 76 78 useBreadcrumb(buildBreadcrumb(bbs, thread, handle), [bbs, thread, handle]); 77 79 78 80 const isSysop = user && user.did === bbs.identity.did; 79 - const repliesByUri: Record<string, Reply> = {}; 80 - for (const r of replies) repliesByUri[r.uri] = r; 81 81 82 82 async function onReply(e: SyntheticEvent) { 83 83 e.preventDefault(); ··· 180 180 reply={reply} 181 181 userDid={user?.did ?? ""} 182 182 sysopDid={bbs.identity.did} 183 - quoted={reply.quote ? repliesByUri[reply.quote] : undefined} 183 + quoted={reply.quote ? replyCache[reply.quote] : undefined} 184 184 onQuote={() => setQuote({ uri: reply.uri, handle: reply.handle })} 185 + onQuoteClick={reply.quote ? () => scrollToReply(reply.quote!) : undefined} 185 186 onDelete={() => onDeleteReply(reply)} 186 187 onBan={() => onBan(reply.did)} 187 188 onHide={() => onHide(reply.uri)}