// 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 = 600 const TITLE_MAX_CHARS = 300 /** Lexicon caps on `body`. Empty is allowed — a title can be the whole report. */ const BODY_MAX_BYTES = 20_000 const BODY_MAX_CHARS = 10_000 /** Lexicon caps on `tags`: at most 8, each at most 64 characters. */ const TAGS_MAX = 8 const 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].length const 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, input: FeedbackInput, ): Record { 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 { const { pds } = await resolveDid(BOARD_DID) const { uri, cid, value } = await getRecord( 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), } }