Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
7.1 kB · 201 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202// Feedback to the substandard board on userinput.app.//// A report is an `app.userinput.discussion` record written to the reader's own// repository, carrying a strongRef to our `app.userinput.space`. userinput.app// finds it by indexing backlinks to that space, so this extension still has no// backend and nothing of ours receives the text: the person who wrote it owns// the record, and deleting it there removes the post.//// Every limit below is the published lexicon's// (at://did:plc:uyixj57k6nmxrdj7pjs2ss5s/com.atproto.lexicon.schema/…), checked// here so a too-long title comes back as a sentence instead of a PDS error.
import { buildAtUri, getRecord, resolveDid } from './atproto'
/** The board: substandard.blog's own space record. */export const BOARD_DID = 'did:plc:jlle5fhgsrzlqybnpfysavg4'export const BOARD_RKEY = '3mst6ruen3l2n'
export const SPACE_COLLECTION = 'app.userinput.space'export const DISCUSSION_COLLECTION = 'app.userinput.discussion'
/** What a discussion's `space` points at. */export const SPACE_URI = buildAtUri(BOARD_DID, SPACE_COLLECTION, BOARD_RKEY)
/** The board as userinput.app serves it, for the panel's own link. */export const BOARD_URL = `https://userinput.app/s/${BOARD_DID}/${BOARD_RKEY}`
/** userinput.app's page for one posted report, by author and rkey. */export function discussionUrl(did: string, rkey: string): string { return `https://userinput.app/d/${did}/${rkey}`}
/** * The scope that lets a session write a discussion: userinput.app's own * published permission set (`app.userinput.authBasic` — discussions, replies, * votes, edits of your own posts; not the moderation collections in its * `authFull`). The consent screen resolves the set and shows its title and * detail, so the reader sees what it covers rather than five raw NSIDs. * * Also in `oauth/client-metadata.json`, which is what sign-in actually asks * for; this constant exists so the value is documented next to the writes it * enables. */export const FEEDBACK_SCOPE = 'include:app.userinput.authBasic'
/** Lexicon caps on `title`: 600 bytes, 300 graphemes. */const TITLE_MAX_BYTES = 600const TITLE_MAX_CHARS = 300
/** Lexicon caps on `body`. Empty is allowed — a title can be the whole report. */const BODY_MAX_BYTES = 20_000const BODY_MAX_CHARS = 10_000
/** Lexicon caps on `tags`: at most 8, each at most 64 characters. */const TAGS_MAX = 8const TAG_MAX_CHARS = 64
/** Lexicon cap on a space's own tag list. */const SPACE_TAGS_MAX = 24
/** One category a report may be filed under, as the space record states it. */export interface SpaceTag { /** Shown to the reader. */ label: string /** Stored on the discussion. */ value: string}
/** The board, as fetched. `cid` is what the discussion's strongRef must carry. */export interface BoardSpace { uri: string cid: string name: string tags: SpaceTag[]}
interface SpaceRecord { name?: unknown tags?: unknown}
/** * Counting code points over-counts graphemes (an emoji with a skin-tone * modifier is one grapheme and several code points), so holding code points to * the grapheme cap always stays inside it. The alternative is shipping a * segmenter's rules to save a handful of characters at the very end of a field * nobody fills to 300. */const chars = (s: string) => [...s].lengthconst bytes = (s: string) => new TextEncoder().encode(s).length
/** Trimmed title, or an Error whose message is what the panel shows. */export function cleanTitle(raw: string): string { const title = raw.trim() if (!title) throw new Error('Give the report a title.') if (bytes(title) > TITLE_MAX_BYTES || chars(title) > TITLE_MAX_CHARS) { throw new Error('That title is too long.') } return title}
/** * The reader's own words. Line breaks are what a description is made of, so * only control characters that are not whitespace are refused. */export function cleanBody(raw: string): string { const body = raw.trim() if (bytes(body) > BODY_MAX_BYTES || chars(body) > BODY_MAX_CHARS) { throw new Error('That description is too long.') } if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(body)) { throw new Error('That description contains characters that are not allowed.') } return body}
/** * What the reader ticked, held to the lexicon's shape. Duplicates are dropped * rather than refused: a checkbox cannot be ticked twice, so a repeat can only * come from a hand-made call, and one tag listed once is what it asked for. */export function cleanTags(raw: string[]): string[] { const tags: string[] = [] for (const entry of raw) { const tag = entry.trim() if (!tag) continue if (chars(tag) > TAG_MAX_CHARS) throw new Error('That tag is too long.') if (!tags.includes(tag)) tags.push(tag) } if (tags.length > TAGS_MAX) throw new Error('That is more tags than a report can carry.') return tags}
export interface FeedbackInput { title: string body: string tags: string[]}
/** * The record to write, validated. `space` is a strongRef, so it carries the * space record's CID as fetched — not one cached across popup opens, which * would go stale the moment the board is renamed. */export function buildDiscussion( space: Pick<BoardSpace, 'uri' | 'cid'>, input: FeedbackInput,): Record<string, unknown> { const title = cleanTitle(input.title) const body = cleanBody(input.body) const tags = cleanTags(input.tags) return { $type: DISCUSSION_COLLECTION, space: { uri: space.uri, cid: space.cid }, title, ...(body ? { body } : {}), ...(tags.length ? { tags } : {}), createdAt: new Date().toISOString(), }}
/** Shaped like a CID, so a PDS answering something else is caught here. */function looksLikeCid(cid: unknown): cid is string { return typeof cid === 'string' && /^ba[a-z2-7]{20,}$/.test(cid)}
/** The space's tag list, ignoring entries that are not a label/value pair. */function readTags(raw: unknown): SpaceTag[] { if (!Array.isArray(raw)) return [] const tags: SpaceTag[] = [] for (const entry of raw.slice(0, SPACE_TAGS_MAX)) { const tag = entry as { label?: unknown; value?: unknown } if (typeof tag?.label === 'string' && typeof tag?.value === 'string') { tags.push({ label: tag.label, value: tag.value }) } } return tags}
/** * The board record, read from its owner's PDS like any other public record. * Fetched because the strongRef needs its current CID; the tag list comes back * in the same response, so the panel's categories are the board's own rather * than a copy in this repo that can drift. */export async function fetchSpace(): Promise<BoardSpace> { const { pds } = await resolveDid(BOARD_DID) const { uri, cid, value } = await getRecord<SpaceRecord>( pds, BOARD_DID, SPACE_COLLECTION, BOARD_RKEY, ) if (!looksLikeCid(cid)) throw new Error('The feedback board could not be read.') return { uri, cid, name: typeof value.name === 'string' ? value.name : BOARD_DID, tags: readTags(value.tags), }}