Files
platform/src/servers/sidecar/notify/render.ts
T
pastilhasandClaude Opus 5 2d5e9835f5 the catalogue shapes a commander's words, not the commander
"it should always say Commander: <name>" was left to whether the commander
remembered to write it. that is not a structure, it is a hope — and the
producer's title reached the lock screen untouched, because both transports
built the alert from n.title rather than from the renderer.

so the code owns the shape and the producer only supplies values.
officer/core:commander is `Commander: {title}` / `{body}`; the commander
sends "Voice Memos", never the prefix. interpolation gains {title} and
{body} alongside {n} and {id}, for the same reason the others exist.

apns-payload and fcm now send the RENDERED text. that was the actual
delivery bug: the catalogue could say whatever it liked and neither
transport read it.

a placeholder that resolves to nothing takes its punctuation with it, or
`Commander: {title}` with no title ships `Commander: ` and every template
has to assume its values are always present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 15:24:17 +00:00

71 lines
3.4 KiB
TypeScript

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);
}