import type { Catalogue } from '../../notifications/catalogue'; import type { Notification } from './types'; // Rendering a code into words, SERVER-SIDE. // // This is not what iOS and Android see. They get the code and render it themselves from their synced copy // of the catalogue — that is the entire point of the design. This exists for the two cases where the // server is the last stop: // // - Discord, which has no app to render anything. // - The APNs/FCM fallback string, shown only when the device's own rendering does not run. // // Replaced text.ts, which held a hardcoded switch over a five-value enum. That enum was the rigidity the // catalogue exists to remove: adding a notification meant editing a server file AND shipping an app. /** The generic shown when a device cannot render for itself. Never a decoy sentence — see the doc. */ export const FALLBACK_TITLE = 'Officer'; /* `{n}` and `{id}` are the numbers a producer sends. `{title}` and `{body}` are its own words, and they interpolate for the same reason the others do: so the catalogue decides the SHAPE and the producer only supplies values. That distinction is the whole answer to "where is the structure defined". A commander sends its name and a sentence; the catalogue is what turns those into `Commander Voice Memos`. Without this the producer's title reached the lock screen untouched, and "it should always say Commander" was left to whether the commander remembered — which is not a structure, it is a hope. */ const interpolate = (template: string, n: Notification): string => template .replace(/\{(n|id|title|body)\}/g, (_, key: 'n' | 'id' | 'title' | 'body') => { const value = n[key]; return value === undefined ? '' : String(value); }) // A placeholder that resolved to nothing leaves the punctuation that introduced it — `Commander: {title}` // with no title renders `Commander: `. Trimming a dangling separator is what makes a template safe to // write with one, rather than every template having to assume its values are always present. .replace(/[\s:\u2014-]+$/, ''); export type Rendered = { title: string; body?: string; thread: string }; /** * Render one notification against the catalogue. * * An unknown code renders as the generic rather than throwing or being dropped: the event is real even * when the mapping has not caught up, and the device has its own defined fallback for the same case. */ export function renderNotification(n: Notification, catalogue: Catalogue): Rendered { const entry = catalogue.entries[n.code]; // A code the catalogue does not know, but with words of its own: send them as they came. There is no // shape to impose and dropping a real event over a missing mapping would be worse. if (!entry) { if (n.title) return { title: n.title, ...(n.body ? { body: n.body } : {}), thread: n.code }; return { title: FALLBACK_TITLE, thread: n.code }; } const body = entry.body ? interpolate(entry.body, n) : undefined; return { title: interpolate(entry.title, n), ...(body ? { body } : {}), thread: interpolate(entry.thread ?? n.code, n), }; } /** The grouping key for a code, whether or not the catalogue knows it. */ export function threadFor(n: Notification, catalogue: Catalogue): string { // Interpolated, so `chat:{id}` groups per conversation rather than lumping every chat together. return interpolate(catalogue.entries[n.code]?.thread ?? n.code, n); }