writes
tcmts.examples.userinput.writes · #bwagtn6o · ts file
// tcm: namespace userinput.account
// Writing: every change is a record written to (or deleted from) the signed-in person's own repo. A vote goes at the
// record key of what it's on, so there's one each way per person; a reply, an edit or a status is a record of its own
// pointing at what it's about. After a write, what was read about it is read again.
import { atUri, backlinks, forget, getRecord, NSID, parseAtUri } from "#codebase/userinput";
import type { BlobRef, DiscussionImage, Role, SpaceTag, State, StrongRef } from "#codebase/userinput";
import type { Session } from "#codebase/userinput/account";
/** A failed write: what the PDS said. */
export class WriteError extends Error {}
/** Call a procedure on the signed-in person's PDS. */
export async function procedure<T>(s: Session, method: string, input: unknown): Promise<T> {
const res = await s.fetch(`/xrpc/${method}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) });
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
throw new WriteError(e.message ?? e.error ?? `${method} failed (${res.status})`);
}
return (await res.json().catch(() => ({}))) as T;
}
/** Write a new record to your repo (at `rkey`, if it must be there): where it is. */
export async function createRecord(s: Session, collection: string, record: Record<string, unknown>, rkey?: string): Promise<StrongRef> {
const r = await procedure<StrongRef>(s, "com.atproto.repo.createRecord", { repo: s.did, collection, rkey, record: { $type: collection, ...record } });
forget(`list:${s.did}:${collection}`);
return r;
}
/** Write a record at a key, replacing what's there. */
export async function putRecord(s: Session, collection: string, rkey: string, record: Record<string, unknown>): Promise<StrongRef> {
const r = await procedure<StrongRef>(s, "com.atproto.repo.putRecord", { repo: s.did, collection, rkey, record: { $type: collection, ...record } });
forget(`record:${atUri(s.did, collection, rkey)}`, `list:${s.did}:${collection}`);
return r;
}
/** Delete one of your records. */
export async function deleteRecord(s: Session, collection: string, rkey: string): Promise<void> {
await procedure(s, "com.atproto.repo.deleteRecord", { repo: s.did, collection, rkey });
forget(`record:${atUri(s.did, collection, rkey)}`, `list:${s.did}:${collection}`);
}
/** Upload an image: the blob a record can hold. */
export async function uploadImage(s: Session, file: Blob): Promise<BlobRef> {
const res = await s.fetch("/xrpc/com.atproto.repo.uploadBlob", { method: "POST", headers: { "content-type": file.type || "application/octet-stream" }, body: file });
if (!res.ok) throw new WriteError(`couldn't upload the image (${res.status})`);
return ((await res.json()) as { blob: BlobRef }).blob;
}
/** Forget what was read about a subject (its votes, replies, statuses), so it's read again. */
export const forgetAbout = (uri: string) => forget(`links:${uri}`, `count:${uri}`, `counts:${uri}`);
/** The time now, as records hold it. */
export const now = (): string => new Date().toISOString();
/** An image to post: the file, and what it shows. */
export interface ImageToPost {
file: Blob;
alt: string;
}
/** Post a discussion into a space. */
export async function postDiscussion(s: Session, space: StrongRef, d: { title: string; body: string; tags: string[]; images: ImageToPost[] }): Promise<StrongRef> {
const images: DiscussionImage[] = [];
for (const i of d.images) images.push({ image: await uploadImage(s, i.file), alt: i.alt });
const r = await createRecord(s, NSID.discussion, {
space,
title: d.title.trim(),
...(d.body.trim() ? { body: d.body.trim() } : {}),
...(d.tags.length ? { tags: d.tags } : {}),
...(images.length ? { images } : {}),
createdAt: now(),
});
forgetAbout(space.uri);
return r;
}
/** Reply to a discussion, or (with `parent`) to a reply on it. */
export async function postReply(s: Session, subject: StrongRef, body: string, parent?: StrongRef): Promise<StrongRef> {
const r = await createRecord(s, NSID.reply, { subject, ...(parent ? { parent } : {}), body: body.trim(), createdAt: now() });
forgetAbout(subject.uri);
if (parent) forgetAbout(parent.uri);
return r;
}
/**
* Vote on a discussion or reply: `up`, `down`, or neither. A vote is at the subject's record key, so changing it is
* deleting the other way's and writing this way's, in one go.
*/
export async function vote(s: Session, subject: StrongRef, was: "up" | "down" | undefined, to: "up" | "down" | undefined): Promise<void> {
const rkey = parseAtUri(subject.uri)!.rkey;
const collection = (v: "up" | "down") => (v === "up" ? NSID.upvote : NSID.downvote);
const writes: unknown[] = [];
if (was && was !== to) writes.push({ $type: "com.atproto.repo.applyWrites#delete", collection: collection(was), rkey });
if (to && to !== was) writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: collection(to), rkey, value: { $type: collection(to), subject, createdAt: now() } });
if (!writes.length) return;
await procedure(s, "com.atproto.repo.applyWrites", { repo: s.did, writes });
forget(`list:${s.did}:${NSID.upvote}`, `list:${s.did}:${NSID.downvote}`);
forgetAbout(subject.uri);
}
/** Revise your discussion or reply: a record beside it (it stays as it was). */
export async function editPost(s: Session, subject: StrongRef, changes: { title?: string; body?: string; tags?: string[] }): Promise<void> {
await createRecord(s, NSID.edit, { subject, ...changes, createdAt: now() });
forgetAbout(subject.uri);
}
/** Set a discussion's official status (it counts if you moderate its space). */
export async function setStatus(s: Session, subject: StrongRef, state: State, note: string, duplicateOf?: StrongRef): Promise<void> {
await createRecord(s, NSID.status, { subject, state, ...(note.trim() ? { note: note.trim() } : {}), ...(state === "duplicate" && duplicateOf ? { duplicateOf } : {}), createdAt: now() });
forgetAbout(subject.uri);
}
/** Your records of a kind pointing at a subject (to undo them: unpin, unhide, unlock). */
export async function myLinks(s: Session, subject: string, collection: string, path: string): Promise<string[]> {
const links = await backlinks(subject, collection, path, { dids: [s.did] });
return links.map((l) => l.rkey);
}
/** Pin a discussion to the top of its space, or unpin it. */
export async function setPinned(s: Session, space: StrongRef, subject: StrongRef, pinned: boolean): Promise<void> {
if (pinned) await createRecord(s, NSID.pin, { space, subject, createdAt: now() });
else for (const rkey of await myPinsOf(s, space.uri, subject.uri)) await deleteRecord(s, NSID.pin, rkey);
forgetAbout(space.uri);
}
/** Your pins in a space of one discussion. */
export async function myPinsOf(s: Session, space: string, subject: string): Promise<string[]> {
const links = await backlinks(space, NSID.pin, "space.uri", { dids: [s.did] });
const out: string[] = [];
for (const l of links) {
const r = await getRecord<{ subject: StrongRef }>(atUri(l.did, l.collection, l.rkey));
if (r?.value.subject.uri === subject) out.push(l.rkey);
}
return out;
}
/** Hide a discussion or reply in a space (it counts if you moderate it). */
export async function hidePost(s: Session, space: StrongRef, subject: StrongRef): Promise<void> {
await createRecord(s, NSID.hide, { space, subject, createdAt: now() });
forgetAbout(space.uri);
}
/** Lock a discussion: replies after now aren't shown, and it takes no more. Or unlock it (your locks). */
export async function setLocked(s: Session, subject: StrongRef, locked: boolean): Promise<void> {
if (locked) await createRecord(s, NSID.lock, { subject, lockedAt: now(), createdAt: now() });
else for (const rkey of await myLinks(s, subject.uri, NSID.lock, "subject.uri")) await deleteRecord(s, NSID.lock, rkey);
forgetAbout(subject.uri);
}
/** Ban someone from a space: their posts are hidden, and they can't post. */
export async function ban(s: Session, space: StrongRef, did: string): Promise<void> {
await createRecord(s, NSID.ban, { space, subject: did, createdAt: now() });
forgetAbout(space.uri);
}
/** Lift a ban (yours) from someone. */
export async function unban(s: Session, space: string, did: string): Promise<void> {
const links = await backlinks(space, NSID.ban, "space.uri", { dids: [s.did] });
for (const l of links) {
const r = await getRecord<{ subject: string }>(atUri(l.did, l.collection, l.rkey));
if (r?.value.subject === did) await deleteRecord(s, NSID.ban, l.rkey);
}
forgetAbout(space);
}
/** Give someone a role on your space. */
export async function addMember(s: Session, space: StrongRef, did: string, role: Role): Promise<void> {
await createRecord(s, NSID.member, { space, subject: did, role, createdAt: now() });
forgetAbout(space.uri);
}
/** Take someone's role away (the membership records you wrote for them). */
export async function removeMember(s: Session, space: string, did: string): Promise<void> {
const links = await backlinks(space, NSID.member, "space.uri", { dids: [s.did] });
for (const l of links) {
const r = await getRecord<{ subject: string }>(atUri(l.did, l.collection, l.rkey));
if (r?.value.subject === did) await deleteRecord(s, NSID.member, l.rkey);
}
forgetAbout(space);
}
/** What a space is, as you make or change it. */
export interface SpaceDraft {
name: string;
description: string;
tags: SpaceTag[];
icon?: Blob | BlobRef;
}
/** Make a space (or, with `rkey`, change yours). */
export async function saveSpace(s: Session, d: SpaceDraft, rkey?: string, createdAt?: string): Promise<StrongRef> {
const icon = d.icon instanceof Blob ? await uploadImage(s, d.icon) : d.icon;
const record = {
name: d.name.trim(),
...(d.description.trim() ? { description: d.description.trim() } : {}),
...(d.tags.length ? { tags: d.tags } : {}),
...(icon ? { icon } : {}),
createdAt: createdAt ?? now(),
};
return rkey ? putRecord(s, NSID.space, rkey, record) : createRecord(s, NSID.space, record);
}
/** Note a visit to a space (at its record key; the first visit's time is kept). */
export async function recordVisit(s: Session, space: StrongRef, firstVisited?: string): Promise<void> {
const rkey = parseAtUri(space.uri)!.rkey;
await putRecord(s, NSID.visited, rkey, { space, firstVisited: firstVisited ?? now(), lastVisited: now() });
}
/** Note that you've seen your notifications. */
export const markChecked = (s: Session): Promise<StrongRef> => putRecord(s, NSID.checked, "self", { checkedAt: now() });
used by