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 307 lines 8.0 kB view raw
1/** Authenticated PDS write helpers using an atcute Client from useAuth().agent. */ 2 3import type { Client } from "@atcute/client"; 4import { SITE, BOARD, POST, BAN, HIDE, PIN, PROFILE } from "./lexicon"; 5import { invalidateAllBBSCaches } from "./bbs"; 6import { queryClient } from "./queryClient"; 7import type { ATRecord } from "./atproto"; 8import { nowIso, parseAtUri } from "./util"; 9import { getCurrentUser } from "./auth"; 10import type { 11 XyzAtbbsPost, 12 XyzAtbbsSite, 13 XyzAtbbsBoard, 14 XyzAtbbsBan, 15 XyzAtbbsHide, 16 XyzAtbbsPin, 17 XyzAtbbsProfile, 18} from "../lexicons"; 19 20// --- Lexicon value types --- 21 22// Strip $type so a single Attachment value works for posts. 23type Attachment = Omit<XyzAtbbsPost.Attachment, "$type">; 24 25type PostValue = Omit<XyzAtbbsPost.Main, "$type">; 26type SiteValue = Omit<XyzAtbbsSite.Main, "$type">; 27type BoardValue = Omit<XyzAtbbsBoard.Main, "$type">; 28type BanValue = Omit<XyzAtbbsBan.Main, "$type">; 29type HideValue = Omit<XyzAtbbsHide.Main, "$type">; 30type PinValue = Omit<XyzAtbbsPin.Main, "$type">; 31type ProfileValue = Omit<XyzAtbbsProfile.Main, "$type">; 32 33interface BlobRef { 34 $type: "blob"; 35 ref: { $link: string }; 36 mimeType: string; 37 size: number; 38} 39 40// --- Type assertions for atcute's strict template-string types --- 41 42type Did = `did:${string}:${string}`; 43type Nsid = `${string}.${string}.${string}`; 44 45const asDid = (value: string) => value as Did; 46const asNsid = (value: string) => value as Nsid; 47 48function currentDid(): Did { 49 const user = getCurrentUser(); 50 if (!user) throw new Error("Not signed in"); 51 return asDid(user.did); 52} 53 54// --- Generic record CRUD --- 55 56function assertOk( 57 resp: { ok: boolean; data: unknown }, 58 label: string, 59): asserts resp is { ok: true; data: unknown } { 60 if (!resp.ok) { 61 const message = (resp.data as { message?: string })?.message; 62 throw new Error(message ?? `${label} failed`); 63 } 64} 65 66// Sync the per-record cache so re-reads via getRecord return the new value. 67function syncRecordCache<V extends object>( 68 did: string, 69 collection: string, 70 rkey: string, 71 value: V, 72 uri: string, 73 cid: string, 74) { 75 queryClient.setQueryData<ATRecord>(["record", did, collection, rkey], { 76 uri, 77 cid, 78 value: { $type: collection, ...value }, 79 }); 80} 81 82async function createRecord<V extends object>( 83 rpc: Client, 84 collection: string, 85 value: V, 86 rkey?: string, 87) { 88 const did = currentDid(); 89 const resp = await rpc.post("com.atproto.repo.createRecord", { 90 input: { 91 repo: did, 92 collection: asNsid(collection), 93 ...(rkey ? { rkey } : {}), 94 record: { $type: collection, ...value }, 95 }, 96 }); 97 assertOk(resp, "createRecord"); 98 const createdRkey = parseAtUri(resp.data.uri).rkey; 99 syncRecordCache( 100 did, 101 collection, 102 createdRkey, 103 value, 104 resp.data.uri, 105 resp.data.cid, 106 ); 107 return resp; 108} 109 110async function putRecord<V extends object>( 111 rpc: Client, 112 collection: string, 113 rkey: string, 114 value: V, 115) { 116 const did = currentDid(); 117 const resp = await rpc.post("com.atproto.repo.putRecord", { 118 input: { 119 repo: did, 120 collection: asNsid(collection), 121 rkey, 122 record: { $type: collection, ...value }, 123 }, 124 }); 125 assertOk(resp, "putRecord"); 126 syncRecordCache(did, collection, rkey, value, resp.data.uri, resp.data.cid); 127 return resp; 128} 129 130export async function deleteRecord( 131 rpc: Client, 132 collection: string, 133 rkey: string, 134) { 135 const did = currentDid(); 136 const resp = await rpc.post("com.atproto.repo.deleteRecord", { 137 input: { 138 repo: did, 139 collection: asNsid(collection), 140 rkey, 141 }, 142 }); 143 assertOk(resp, "deleteRecord"); 144 queryClient.removeQueries({ 145 queryKey: ["record", did, collection, rkey], 146 exact: true, 147 }); 148 return resp; 149} 150 151// --- Blob upload --- 152 153async function stripImageMetadata(file: File): Promise<File> { 154 if (!file.type.startsWith("image/")) return file; 155 const bitmap = await createImageBitmap(file); 156 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); 157 canvas.getContext("2d")!.drawImage(bitmap, 0, 0); 158 const blob = await canvas.convertToBlob({ type: file.type }); 159 return new File([blob], file.name, { type: file.type }); 160} 161 162async function uploadBlob(rpc: Client, file: File): Promise<BlobRef> { 163 const cleanedFile = await stripImageMetadata(file); 164 const fileBytes = new Uint8Array(await cleanedFile.arrayBuffer()); 165 // atcute's typed upload signature is awkward for raw binary; cast at boundary. 166 // eslint-disable-next-line @typescript-eslint/no-explicit-any 167 const resp = await rpc.post("com.atproto.repo.uploadBlob", { 168 input: fileBytes, 169 headers: { 170 "content-type": cleanedFile.type || "application/octet-stream", 171 }, 172 } as any); 173 if (!resp.ok) { 174 const message = (resp.data as { message?: string })?.message; 175 throw new Error(message ?? "uploadBlob failed"); 176 } 177 return (resp.data as { blob: BlobRef }).blob; 178} 179 180export async function uploadAttachments( 181 rpc: Client, 182 files: File[], 183): Promise<Attachment[]> { 184 if (files.length === 0) return []; 185 const out: Attachment[] = []; 186 for (const file of files) { 187 if (file.size === 0) continue; 188 const blob = await uploadBlob(rpc, file); 189 out.push({ 190 file: blob as unknown as Attachment["file"], 191 name: file.name, 192 }); 193 } 194 return out; 195} 196 197// --- Posts (threads, replies, news) --- 198 199export async function createPost( 200 rpc: Client, 201 scope: string, 202 body: string, 203 opts?: { 204 title?: string; 205 root?: string; 206 parent?: string; 207 attachments?: Attachment[]; 208 }, 209) { 210 const value: PostValue = { 211 scope: scope as PostValue["scope"], 212 body, 213 createdAt: nowIso(), 214 ...(opts?.title ? { title: opts.title } : {}), 215 ...(opts?.root ? { root: opts.root as PostValue["root"] } : {}), 216 ...(opts?.parent ? { parent: opts.parent as PostValue["parent"] } : {}), 217 ...(opts?.attachments?.length ? { attachments: opts.attachments } : {}), 218 }; 219 return createRecord(rpc, POST, value); 220} 221 222// --- Sysop: site, board --- 223 224export async function putSite(rpc: Client, site: SiteValue) { 225 const resp = await putRecord(rpc, SITE, "self", site); 226 invalidateAllBBSCaches(); 227 return resp; 228} 229 230export async function putBoard( 231 rpc: Client, 232 slug: string, 233 name: string, 234 description: string, 235 createdAt: string, 236) { 237 const value: BoardValue = { 238 name, 239 description, 240 createdAt: createdAt as BoardValue["createdAt"], 241 }; 242 const resp = await putRecord(rpc, BOARD, slug, value); 243 invalidateAllBBSCaches(); 244 return resp; 245} 246 247// --- Sysop: bans & hides --- 248 249export async function createBan(rpc: Client, did: string) { 250 const value: BanValue = { 251 did: did as BanValue["did"], 252 createdAt: nowIso(), 253 }; 254 const resp = await createRecord(rpc, BAN, value); 255 invalidateAllBBSCaches(); 256 return resp; 257} 258 259export async function createHide(rpc: Client, uri: string) { 260 const value: HideValue = { 261 uri: uri as HideValue["uri"], 262 createdAt: nowIso(), 263 }; 264 const resp = await createRecord(rpc, HIDE, value); 265 invalidateAllBBSCaches(); 266 return resp; 267} 268 269export async function deleteBan(rpc: Client, rkey: string) { 270 const resp = await deleteRecord(rpc, BAN, rkey); 271 invalidateAllBBSCaches(); 272 return resp; 273} 274 275export async function deleteHide(rpc: Client, rkey: string) { 276 const resp = await deleteRecord(rpc, HIDE, rkey); 277 invalidateAllBBSCaches(); 278 return resp; 279} 280 281// --- Pins --- 282 283export async function createPin(rpc: Client, did: string) { 284 const value: PinValue = { 285 did: did as PinValue["did"], 286 createdAt: nowIso(), 287 }; 288 // Use DID as rkey for idempotent pins 289 return createRecord(rpc, PIN, value, did); 290} 291 292// --- Profiles --- 293 294export async function putProfile( 295 rpc: Client, 296 name?: string, 297 pronouns?: string, 298 bio?: string, 299) { 300 const value: ProfileValue = { 301 ...(name ? { name } : {}), 302 ...(pronouns ? { pronouns } : {}), 303 ...(bio ? { bio } : {}), 304 createdAt: nowIso() as ProfileValue["createdAt"], 305 }; 306 return putRecord(rpc, PROFILE, "self", value); 307}