Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
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}