Skip to content
240 linesCodeBlameRaw
1/**
2 * Who hears of what, and how: preferences, and the toast-or-push decision.
3 * Pure, so the rules are tested apart from the feed.
4 *
5 * - Counts always move, whatever the preferences.
6 * - A notification is shown (toasted in open tabs, pushed to browsers) when
7 * the person's level for its workspace wants its kind.
8 * - Under Do Not Disturb nothing is toasted or pushed.
9 * - It is pushed only when no tab of theirs is in front of them: a tab
10 * says it has focus over its socket, and a tab that has said nothing for
11 * `STALE_MS` counts as gone (a laptop lid closed on it).
12 */
13import type { CardAction, FeedNotification, NotificationCard, NotificationKind, NotifyLevel, NotifyPreferences } from "@g1t/contracts";
14
15import { readLook } from "../../../packages/contracts/src/agent-look.ts";
16
17// The same as NOTIFY_LEVELS and DEFAULT_NOTIFY_PREFERENCES in @g1t/contracts, kept here so
18// Node runs the tests on this file without the contracts package.
19const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
20const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
21
22/** What `dms_mentions`, the default, lets through: what was said to you, or waits on you. */
23const DIRECT: ReadonlySet<NotificationKind> = new Set(["dm", "mention", "thread_reply", "agent_waiting", "approval"]);
24
25/** A tab whose last word is older than this is not in front of anyone. Pings come every 25 s. */
26export const STALE_MS = 70_000;
27
28/** The most workspaces with their own level. */
29const MAX_OVERRIDES = 200;
30
31export function isLevel(value: unknown): value is NotifyLevel {
32 return typeof value === "string" && (NOTIFY_LEVELS as readonly string[]).includes(value);
33}
34
35/** The level that applies in `workspace`. */
36export function levelFor(prefs: NotifyPreferences, workspace: string): NotifyLevel {
37 return prefs.workspaces[workspace.toLowerCase()] ?? prefs.level;
38}
39
40/** Whether `level` lets a notification of `kind` be shown. */
41export function wants(level: NotifyLevel, kind: NotificationKind): boolean {
42 if (level === "none") return false;
43 if (level === "all") return true;
44 return DIRECT.has(kind);
45}
46
47/**
48 * Preferences after a change: `change` as sent, checked, over `current`.
49 * A workspace set to `null` (or to the general level) goes back to it.
50 */
51export function mergePreferences(current: NotifyPreferences, change: unknown): NotifyPreferences {
52 const c = (change && typeof change === "object" ? change : {}) as Record<string, unknown>;
53 const level = isLevel(c.level) ? c.level : current.level;
54 const workspaces: Record<string, NotifyLevel> = { ...current.workspaces };
55 if (c.workspaces && typeof c.workspaces === "object") {
56 for (const [slug, value] of Object.entries(c.workspaces as Record<string, unknown>)) {
57 const key = slug.toLowerCase().slice(0, 100);
58 if (!key) continue;
59 if (isLevel(value)) workspaces[key] = value;
60 else if (value === null) delete workspaces[key];
61 }
62 }
63 for (const [slug, value] of Object.entries(workspaces)) if (value === level) delete workspaces[slug];
64 const kept = Object.fromEntries(Object.entries(workspaces).slice(0, MAX_OVERRIDES));
65 return { level, workspaces: kept };
66}
67
68/** Preferences as kept, or the default for anything that is not them. */
69export function readPreferences(json: string | null | undefined): NotifyPreferences {
70 if (!json) return { ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} };
71 try {
72 return mergePreferences({ ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} }, JSON.parse(json));
73 } catch {
74 return { ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} };
75 }
76}
77
78/** A tab's state as its socket last told it. `seen_at` is when it last said anything, a ping included. */
79export type TabState = { focused: boolean; seen_at: number };
80
81/** Whether any tab is in front of the person now. */
82export function anyFocused(tabs: TabState[], now: number): boolean {
83 return tabs.some((tab) => tab.focused && now - tab.seen_at < STALE_MS);
84}
85
86export type Decision = { toast: boolean; push: boolean };
87
88/**
89 * What to do with one notification: toast it in open tabs, push it to
90 * browsers, both or neither. A test is shown and pushed whatever the
91 * preferences and focus say.
92 */
93export function decide(input: {
94 prefs: NotifyPreferences;
95 notification: Pick<FeedNotification, "kind" | "workspace">;
96 tabs: TabState[];
97 subscriptions: number;
98 now: number;
99 test?: boolean;
100 /** Do Not Disturb holds: nothing is toasted or pushed (a test still is). */
101 dnd?: boolean;
102}): Decision {
103 if (input.test) return { toast: true, push: input.subscriptions > 0 };
104 if (input.dnd) return { toast: false, push: false };
105 const shown = wants(levelFor(input.prefs, input.notification.workspace), input.notification.kind);
106 return { toast: shown, push: shown && input.subscriptions > 0 && !anyFocused(input.tabs, input.now) };
107}
108
109/** A notification as sent, checked and trimmed; null when it is not one. */
110export function cleanNotification(value: unknown): FeedNotification | null {
111 if (!value || typeof value !== "object") return null;
112 const n = value as Record<string, unknown>;
113 const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
114 const kinds: NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval"];
115 const kind = kinds.find((k) => k === n.kind);
116 const id = text(n.id, 200);
117 const workspace = text(n.workspace, 100).toLowerCase();
118 const title = text(n.title, 200);
119 if (!kind || !id || !title) return null;
120 const href = text(n.href, 2000);
121 const a = (n.actor && typeof n.actor === "object" ? n.actor : {}) as Record<string, unknown>;
122 const actorKind = a.kind === "user" || a.kind === "agent" ? a.kind : "system";
123 const card = cleanCard(n.card);
124 return {
125 id,
126 kind,
127 workspace,
128 title,
129 body: text(n.body, 300),
130 // Relative to the site only: a notification never links somewhere else.
131 href: href.startsWith("/") && !href.startsWith("//") ? href : "/",
132 actor: {
133 kind: actorKind,
134 id: text(a.id, 100) || "g1t",
135 name: text(a.name, 100) || "g1t",
136 avatar: text(a.avatar, 100) || null,
137 avatar_seed: text(a.avatar_seed, 100) || null,
138 look: readLook(a.look),
139 },
140 channel_id: text(n.channel_id, 100) || null,
141 thread_root: text(n.thread_root, 100) || null,
142 ...(card ? { card } : {}),
143 created_at: text(n.created_at, 40) || new Date().toISOString(),
144 };
145}
146
147/** The most of a card's actions a notification carries, and a push shows (browsers show two). */
148const CARD_ACTIONS = 4;
149const PUSH_ACTIONS = 2;
150
151/** A site path, or null: a notification never links somewhere else. */
152function sitePath(value: unknown): string | null {
153 const href = typeof value === "string" ? value.trim().slice(0, 2000) : "";
154 return href.startsWith("/") && !href.startsWith("//") ? href : null;
155}
156
157/**
158 * The card a notification is about, checked: where it is and its actions
159 * as the card offers them. Null when it is not one, or has nothing to press.
160 */
161export function cleanCard(value: unknown): NotificationCard | null {
162 if (!value || typeof value !== "object") return null;
163 const c = value as Record<string, unknown>;
164 const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
165 const channel_id = text(c.channel_id, 100);
166 const message_id = text(c.message_id, 100);
167 if (!channel_id || !message_id || !Array.isArray(c.actions)) return null;
168 const actions: CardAction[] = [];
169 for (const raw of c.actions.slice(0, CARD_ACTIONS)) {
170 if (!raw || typeof raw !== "object") continue;
171 const a = raw as Record<string, unknown>;
172 const id = text(a.id, 40);
173 const label = text(a.label, 60);
174 if (!id || !label) continue;
175 const style = a.style === "primary" || a.style === "danger" ? a.style : "default";
176 const input = a.input && typeof a.input === "object" ? (a.input as Record<string, unknown>) : null;
177 actions.push({
178 id,
179 label,
180 style,
181 confirm: text(a.confirm, 200) || null,
182 input:
183 input && (input.kind === "money" || input.kind === "text")
184 ? { kind: input.kind, label: text(input.label, 100), placeholder: text(input.placeholder, 100) || null, initial: text(input.initial, 40) || null }
185 : null,
186 href: sitePath(a.href),
187 });
188 }
189 return actions.length ? { channel_id, message_id, actions } : null;
190}
191
192/**
193 * A push's buttons: a card's actions that need nothing typed (a push has no
194 * field) and nothing confirmed (a push can't ask first, so Stop waits for
195 * the app), links and all.
196 */
197export type PushAction = { id: string; label: string; href: string | null };
198
199export function pushActions(card: NotificationCard | null | undefined): PushAction[] {
200 if (!card) return [];
201 return card.actions
202 .filter((a) => !a.input && !a.confirm && a.style !== "danger")
203 .slice(0, PUSH_ACTIONS)
204 .map((a) => ({ id: a.id, label: a.label, href: a.href ?? null }));
205}
206
207/**
208 * What a push carries: little, under the 4 KB a push may hold. `tag` makes
209 * the notifications of one conversation replace each other.
210 */
211export type PushPayload = {
212 title: string;
213 body: string;
214 href: string;
215 tag: string;
216 kind: NotificationKind;
217 urgent: boolean;
218 /** The workspace's slug, for a card action's request. */
219 workspace: string;
220 /** Buttons on the notification: a card's actions that need nothing typed. */
221 actions: PushAction[];
222 /** Where the card is, for those that run (public/sw.js posts `card_action`). */
223 card: { channel_id: string; message_id: string } | null;
224};
225
226export function pushPayload(n: FeedNotification): PushPayload {
227 const actions = pushActions(n.card);
228 return {
229 title: n.title,
230 body: n.body.slice(0, 240),
231 href: n.href,
232 // A card's notification is its own, so a newer message does not replace it.
233 tag: n.channel_id && !actions.length ? `chat:${n.channel_id}` : `${n.kind}:${n.id}`,
234 kind: n.kind,
235 urgent: DIRECT.has(n.kind),
236 workspace: n.workspace,
237 actions,
238 card: actions.length && n.card ? { channel_id: n.card.channel_id, message_id: n.card.message_id } : null,
239 };
240}