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