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/lib: add inbox.ts

+96 -85
+93
web/src/lib/inbox.ts
··· 1 + /** Inbox data fetching — replies to your threads + quotes of your replies. */ 2 + 3 + import { fetchAndHydrate, listRecords } from "./atproto"; 4 + import { THREAD, REPLY } from "./lexicon"; 5 + import { is } from "@atcute/lexicons/validations"; 6 + import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread"; 7 + import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; 8 + import type { XyzAtboardsThread, XyzAtboardsReply } from "../lexicons"; 9 + 10 + export interface InboxItem { 11 + type: "reply" | "quote"; 12 + threadTitle: string; 13 + threadUri: string; 14 + replyUri: string; 15 + handle: string; 16 + body: string; 17 + createdAt: string; 18 + } 19 + 20 + async function fetchBacklinkItems( 21 + sourceUri: string, 22 + backlinkSource: string, 23 + excludeDid: string, 24 + type: InboxItem["type"], 25 + threadTitle: string, 26 + threadUri: string, 27 + ): Promise<InboxItem[]> { 28 + try { 29 + const { records } = await fetchAndHydrate(sourceUri, backlinkSource, { 30 + limit: 50, 31 + excludeDid, 32 + }); 33 + return records.map((r) => ({ 34 + type, 35 + threadTitle, 36 + threadUri, 37 + replyUri: r.uri, 38 + handle: r.handle, 39 + body: ((r.value.body as string) ?? "").substring(0, 200), 40 + createdAt: (r.value.createdAt as string) ?? "", 41 + })); 42 + } catch { 43 + return []; 44 + } 45 + } 46 + 47 + export async function fetchInbox( 48 + did: string, 49 + pdsUrl: string, 50 + ): Promise<InboxItem[]> { 51 + const SCAN_LIMIT = 50; 52 + const [allThreads, allReplies] = await Promise.all([ 53 + listRecords(pdsUrl, did, THREAD, SCAN_LIMIT), 54 + listRecords(pdsUrl, did, REPLY, SCAN_LIMIT), 55 + ]); 56 + const threads = allThreads.filter((r) => is(threadSchema, r.value)); 57 + const replies = allReplies.filter((r) => is(replySchema, r.value)); 58 + 59 + const results = await Promise.all([ 60 + ...threads.map((tr) => { 61 + const v = tr.value as unknown as XyzAtboardsThread.Main; 62 + return fetchBacklinkItems( 63 + tr.uri, 64 + `${REPLY}:subject`, 65 + did, 66 + "reply", 67 + v.title ?? "", 68 + tr.uri, 69 + ); 70 + }), 71 + ...replies.map((rr) => { 72 + const v = rr.value as unknown as XyzAtboardsReply.Main; 73 + return fetchBacklinkItems( 74 + rr.uri, 75 + `${REPLY}:quote`, 76 + did, 77 + "quote", 78 + "", 79 + v.subject ?? "", 80 + ); 81 + }), 82 + ]); 83 + 84 + // Deduplicate — prefer "quote" type when the same reply appears as both. 85 + const seen = new Map<string, InboxItem>(); 86 + for (const item of results.flat()) { 87 + const key = item.handle + item.body + item.createdAt; 88 + if (!seen.has(key) || item.type === "quote") seen.set(key, item); 89 + } 90 + return [...seen.values()].sort((a, b) => 91 + b.createdAt.localeCompare(a.createdAt), 92 + ); 93 + }
+3 -85
web/src/router/loaders.ts
··· 3 3 import { redirect, type LoaderFunctionArgs } from "react-router-dom"; 4 4 import { ensureAuthReady, getCurrentUser } from "../lib/auth"; 5 5 import { resolveBBS, type BBS } from "../lib/bbs"; 6 + import { fetchInbox } from "../lib/inbox"; 6 7 import { 7 8 getRecord, 8 9 getRecordByUri, ··· 11 12 listRecords, 12 13 resolveIdentitiesBatch, 13 14 resolveIdentity, 14 - fetchAndHydrate, 15 15 type ATRecord, 16 16 type BacklinkRef, 17 17 } from "../lib/atproto"; ··· 19 19 import { makeAtUri, parseAtUri } from "../lib/util"; 20 20 import { is } from "@atcute/lexicons/validations"; 21 21 import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread"; 22 - import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; 23 22 import { mainSchema as banSchema } from "../lexicons/types/xyz/atboards/ban"; 24 23 import { mainSchema as hideSchema } from "../lexicons/types/xyz/atboards/hide"; 25 24 import type { 26 25 XyzAtboardsThread, 27 - XyzAtboardsReply, 28 26 XyzAtboardsBan, 29 27 XyzAtboardsHide, 30 28 } from "../lexicons"; ··· 158 156 return { handle, bbs, thread, allRefs }; 159 157 } 160 158 161 - // --- Account / inbox --- 159 + // --- Account --- 162 160 163 - export interface InboxItem { 164 - type: "reply" | "quote"; 165 - threadTitle: string; 166 - threadUri: string; 167 - replyUri: string; 168 - handle: string; 169 - body: string; 170 - createdAt: string; 171 - } 161 + export type { InboxItem } from "../lib/inbox"; 172 162 173 163 /** Collect all reply refs, paginating Constellation in chunks of 100. */ 174 164 async function collectAllReplyRefs(threadUri: string): Promise<BacklinkRef[]> { ··· 202 192 // immediately and items stream in via <Await>. (v7 auto-defers promises.) 203 193 const itemsPromise = fetchInbox(user.did, user.pdsUrl); 204 194 return { user, hasBBS, bbsName, items: itemsPromise }; 205 - } 206 - 207 - async function fetchBacklinkItems( 208 - sourceUri: string, 209 - backlinkSource: string, 210 - excludeDid: string, 211 - type: InboxItem["type"], 212 - threadTitle: string, 213 - threadUri: string, 214 - ): Promise<InboxItem[]> { 215 - try { 216 - const { records } = await fetchAndHydrate(sourceUri, backlinkSource, { 217 - limit: 50, 218 - excludeDid, 219 - }); 220 - return records.map((r) => ({ 221 - type, 222 - threadTitle, 223 - threadUri, 224 - replyUri: r.uri, 225 - handle: r.handle, 226 - body: ((r.value.body as string) ?? "").substring(0, 200), 227 - createdAt: (r.value.createdAt as string) ?? "", 228 - })); 229 - } catch { 230 - return []; 231 - } 232 - } 233 - 234 - async function fetchInbox(did: string, pdsUrl: string): Promise<InboxItem[]> { 235 - const SCAN_LIMIT = 50; 236 - const [allThreads, allReplies] = await Promise.all([ 237 - listRecords(pdsUrl, did, THREAD, SCAN_LIMIT), 238 - listRecords(pdsUrl, did, REPLY, SCAN_LIMIT), 239 - ]); 240 - const threads = allThreads.filter((r) => is(threadSchema, r.value)); 241 - const replies = allReplies.filter((r) => is(replySchema, r.value)); 242 - 243 - const results = await Promise.all([ 244 - ...threads.map((tr) => { 245 - const v = tr.value as unknown as XyzAtboardsThread.Main; 246 - return fetchBacklinkItems( 247 - tr.uri, 248 - `${REPLY}:subject`, 249 - did, 250 - "reply", 251 - v.title ?? "", 252 - tr.uri, 253 - ); 254 - }), 255 - ...replies.map((rr) => { 256 - const v = rr.value as unknown as XyzAtboardsReply.Main; 257 - return fetchBacklinkItems( 258 - rr.uri, 259 - `${REPLY}:quote`, 260 - did, 261 - "quote", 262 - "", 263 - v.subject ?? "", 264 - ); 265 - }), 266 - ]); 267 - 268 - // Deduplicate — prefer "quote" type when the same reply appears as both. 269 - const seen = new Map<string, InboxItem>(); 270 - for (const item of results.flat()) { 271 - const key = item.handle + item.body + item.createdAt; 272 - if (!seen.has(key) || item.type === "quote") seen.set(key, item); 273 - } 274 - return [...seen.values()].sort((a, b) => 275 - b.createdAt.localeCompare(a.createdAt), 276 - ); 277 195 } 278 196 279 197 // --- Sysop ---