model
tcmts.examples.userinput.model · #2t2oqkcn · ts file
// tcm: namespace userinput
// What userinput.app's records are (its lexicons, app.userinput.*, published by did:plc:uyixj57k6nmxrdj7pjs2ss5s),
// and how they're named. Every record lives in its author's own repo: a space in its owner's, a discussion in its
// author's, a vote in the voter's. Nothing is stored anywhere else; the app reads them through an index of which
// records point at which (Constellation) and a cache of records (Slingshot).
import assert from "node:assert/strict";
/** A record's address: `at://<did>/<collection>/<rkey>`, and the version of it pointed at (its CID). */
export interface StrongRef {
uri: string;
cid: string;
}
/** A blob (an image) as records hold it. */
export interface BlobRef {
$type: "blob";
ref: { $link: string };
mimeType: string;
size: number;
}
/** A category feedback in a space can be filed under: `value` is stored on discussions, `label` is shown. */
export interface SpaceTag {
label: string;
value: string;
}
/** A feedback space, made by an organization: `app.userinput.space`, in its owner's repo. */
export interface SpaceRecord {
$type?: "app.userinput.space";
name: string;
description?: string;
icon?: BlobRef;
tags?: SpaceTag[];
createdAt: string;
}
/** An image attached to a discussion. */
export interface DiscussionImage {
image: BlobRef;
alt?: string;
}
/** A piece of feedback posted into a space: `app.userinput.discussion`, in its author's repo. */
export interface DiscussionRecord {
$type?: "app.userinput.discussion";
space: StrongRef;
title: string;
body?: string;
tags?: string[];
images?: DiscussionImage[];
createdAt: string;
}
/** A reply on a discussion, or (with `parent`) on another reply: `app.userinput.reply`. */
export interface ReplyRecord {
$type?: "app.userinput.reply";
subject: StrongRef;
parent?: StrongRef;
body: string;
createdAt: string;
}
/** An up or down vote, at the record key of what it's on: one of each per person per subject. */
export interface VoteRecord {
$type?: "app.userinput.upvote" | "app.userinput.downvote";
subject: StrongRef;
createdAt: string;
}
/** The states a discussion's official status can be in. */
export const STATES = ["open", "under-review", "backlog", "planned", "in-progress", "implemented", "declined", "duplicate", "closed"] as const;
export type State = (typeof STATES)[number];
/** A discussion's official status, set by the space's owner or a moderator (the latest wins). */
export interface StatusRecord {
$type?: "app.userinput.status";
subject: StrongRef;
state: State;
note?: string;
duplicateOf?: StrongRef;
createdAt: string;
}
/** A revision of a discussion or reply, beside it (the original never changes), by its author. */
export interface EditRecord {
$type?: "app.userinput.edit";
subject: StrongRef;
title?: string;
body?: string;
tags?: string[];
createdAt: string;
}
/** A role on a space, granted by its owner. */
export type Role = "moderator" | "admin";
export interface MemberRecord {
$type?: "app.userinput.member";
space: StrongRef;
subject: string;
role: Role;
createdAt: string;
}
/** Moderation, by the space's owner or a moderator: a pin, or a hidden discussion or reply. */
export interface ModerationRecord {
$type?: "app.userinput.pin" | "app.userinput.hide";
space: StrongRef;
subject: StrongRef;
createdAt: string;
}
/** A ban from a space: the person's content is hidden, and they can't post. */
export interface BanRecord {
$type?: "app.userinput.ban";
space: StrongRef;
subject: string;
createdAt: string;
}
/** A locked discussion: replies after `lockedAt` aren't shown, and no more can be added. */
export interface LockRecord {
$type?: "app.userinput.lock";
subject: StrongRef;
lockedAt: string;
createdAt: string;
}
/** A space the person has been to, at the space's record key, in their own repo. */
export interface VisitedRecord {
$type?: "app.userinput.visited";
space: StrongRef;
firstVisited: string;
lastVisited: string;
}
/** When the person last looked at their notifications (record key `self`). */
export interface CheckedRecord {
$type?: "app.userinput.checkedNotifications";
checkedAt: string;
}
/** The collections, by what they hold. */
export const NSID = {
space: "app.userinput.space",
discussion: "app.userinput.discussion",
reply: "app.userinput.reply",
upvote: "app.userinput.upvote",
downvote: "app.userinput.downvote",
status: "app.userinput.status",
edit: "app.userinput.edit",
member: "app.userinput.member",
pin: "app.userinput.pin",
hide: "app.userinput.hide",
ban: "app.userinput.ban",
lock: "app.userinput.lock",
visited: "app.userinput.visited",
checked: "app.userinput.checkedNotifications",
} as const;
/** What the app asks to be allowed when you sign in: userinput.app's permission set, and uploading images. */
export const SCOPE = "atproto include:app.userinput.authFull blob:*/*";
/** A record as read: where it is, which version, and what it says. */
export interface Stored<T> {
uri: string;
cid: string;
value: T;
}
/** The parts of an `at://` URI. */
export interface AtUri {
did: string;
collection: string;
rkey: string;
}
/**
* The parts of an `at://did/collection/rkey` URI, or undefined if it isn't one.
*
* ```ts
* parseAtUri("at://did:plc:abc/app.userinput.space/3kx")
* ```
*/
export function parseAtUri(uri: string): AtUri | undefined {
const m = /^at:\/\/(did:[a-z]+:[A-Za-z0-9._:%-]+)\/([a-zA-Z0-9.]+)\/([A-Za-z0-9._~:-]+)$/.exec(uri);
return m ? { did: m[1]!, collection: m[2]!, rkey: m[3]! } : undefined;
}
/** The `at://` URI of a record. */
export const atUri = (did: string, collection: string, rkey: string): string => `at://${did}/${collection}/${rkey}`;
/** A strong reference to a record as read. */
export const refOf = (r: { uri: string; cid: string }): StrongRef => ({ uri: r.uri, cid: r.cid });
/** Where the app reads and writes: overridable (`localStorage["userinput.services"]`, JSON), so tests run offline. */
export interface Services {
/** which records point at which: `blue.microcosm.links.*` */
constellation: string;
/** records, cached: `com.atproto.repo.getRecord`, `blue.microcosm.identity.resolveMiniDoc` */
slingshot: string;
/** profiles and handle search */
appview: string;
/** handle typeahead */
typeahead: string;
/** DID documents */
plc: string;
/** images, resized: `/blob/<did>/<cid>/<options>` */
images: string;
/**
* userinput.app's own index of boards and threads (its app view): each with everything it shows, in one request
* (`/api/board/<did>/<rkey>`, `/api/thread/<did>/<rkey>`); "" for none
*/
index: string;
}
export const DEFAULT_SERVICES: Services = {
constellation: "https://constellation.microcosm.blue",
slingshot: "https://slingshot.microcosm.blue",
appview: "https://public.api.bsky.app",
typeahead: "https://typeahead.waow.tech",
plc: "https://plc.directory",
images: "https://img.infra.coop",
index: "https://userinput.app",
};
/** The services in use: the defaults, with any overrides set in this browser. */
export function services(): Services {
try {
const set = typeof localStorage === "undefined" ? null : localStorage.getItem("userinput.services");
return { ...DEFAULT_SERVICES, ...(set ? (JSON.parse(set) as Partial<Services>) : {}) };
} catch {
return DEFAULT_SERVICES;
}
}
export const testAtUris = () => {
assert.deepEqual(parseAtUri("at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/app.userinput.space/3mnovpobnnm2o"), {
did: "did:plc:uyixj57k6nmxrdj7pjs2ss5s",
collection: "app.userinput.space",
rkey: "3mnovpobnnm2o",
});
assert.deepEqual(parseAtUri("at://did:web:example.com/app.userinput.reply/abc"), { did: "did:web:example.com", collection: "app.userinput.reply", rkey: "abc" });
assert.equal(parseAtUri("https://example.com"), undefined);
assert.equal(atUri("did:plc:x", NSID.space, "k"), "at://did:plc:x/app.userinput.space/k");
};
used by