Skip to content
151 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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 * - It is pushed only when no tab of theirs is in front of them: a tab
9 * says it has focus over its socket, and a tab that has said nothing for
10 * `STALE_MS` counts as gone (a laptop lid closed on it).
11 */
12import type { FeedNotification, NotificationKind, NotifyLevel, NotifyPreferences } from "@g1t/contracts";
13
14// The same as NOTIFY_LEVELS and DEFAULT_NOTIFY_PREFERENCES in @g1t/contracts, kept here so
15// Node runs the tests on this file without the contracts package.
16const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
17const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
18
19/** What `dms_mentions`, the default, lets through: what was said to you, or waits on you. */
20const DIRECT: ReadonlySet<NotificationKind> = new Set(["dm", "mention", "thread_reply", "agent_waiting", "approval"]);
21
22/** A tab whose last word is older than this is not in front of anyone. Pings come every 25 s. */
23export const STALE_MS = 70_000;
24
25/** The most workspaces with their own level. */
26const MAX_OVERRIDES = 200;
27
28export function isLevel(value: unknown): value is NotifyLevel {
29 return typeof value === "string" && (NOTIFY_LEVELS as readonly string[]).includes(value);
30}
31
32/** The level that applies in `workspace`. */
33export function levelFor(prefs: NotifyPreferences, workspace: string): NotifyLevel {
34 return prefs.workspaces[workspace.toLowerCase()] ?? prefs.level;
35}
36
37/** Whether `level` lets a notification of `kind` be shown. */
38export function wants(level: NotifyLevel, kind: NotificationKind): boolean {
39 if (level === "none") return false;
40 if (level === "all") return true;
41 return DIRECT.has(kind);
42}
43
44/**
45 * Preferences after a change: `change` as sent, checked, over `current`.
46 * A workspace set to `null` (or to the general level) goes back to it.
47 */
48export function mergePreferences(current: NotifyPreferences, change: unknown): NotifyPreferences {
49 const c = (change && typeof change === "object" ? change : {}) as Record<string, unknown>;
50 const level = isLevel(c.level) ? c.level : current.level;
51 const workspaces: Record<string, NotifyLevel> = { ...current.workspaces };
52 if (c.workspaces && typeof c.workspaces === "object") {
53 for (const [slug, value] of Object.entries(c.workspaces as Record<string, unknown>)) {
54 const key = slug.toLowerCase().slice(0, 100);
55 if (!key) continue;
56 if (isLevel(value)) workspaces[key] = value;
57 else if (value === null) delete workspaces[key];
58 }
59 }
60 for (const [slug, value] of Object.entries(workspaces)) if (value === level) delete workspaces[slug];
61 const kept = Object.fromEntries(Object.entries(workspaces).slice(0, MAX_OVERRIDES));
62 return { level, workspaces: kept };
63}
64
65/** Preferences as kept, or the default for anything that is not them. */
66export function readPreferences(json: string | null | undefined): NotifyPreferences {
67 if (!json) return { ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} };
68 try {
69 return mergePreferences({ ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} }, JSON.parse(json));
70 } catch {
71 return { ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} };
72 }
73}
74
75/** A tab's state as its socket last told it. `seen_at` is when it last said anything, a ping included. */
76export type TabState = { focused: boolean; seen_at: number };
77
78/** Whether any tab is in front of the person now. */
79export function anyFocused(tabs: TabState[], now: number): boolean {
80 return tabs.some((tab) => tab.focused && now - tab.seen_at < STALE_MS);
81}
82
83export type Decision = { toast: boolean; push: boolean };
84
85/**
86 * What to do with one notification: toast it in open tabs, push it to
87 * browsers, both or neither. A test is shown and pushed whatever the
88 * preferences and focus say.
89 */
90export function decide(input: {
91 prefs: NotifyPreferences;
92 notification: Pick<FeedNotification, "kind" | "workspace">;
93 tabs: TabState[];
94 subscriptions: number;
95 now: number;
96 test?: boolean;
97}): Decision {
98 if (input.test) return { toast: true, push: input.subscriptions > 0 };
99 const shown = wants(levelFor(input.prefs, input.notification.workspace), input.notification.kind);
100 return { toast: shown, push: shown && input.subscriptions > 0 && !anyFocused(input.tabs, input.now) };
101}
102
103/** A notification as sent, checked and trimmed; null when it is not one. */
104export function cleanNotification(value: unknown): FeedNotification | null {
105 if (!value || typeof value !== "object") return null;
106 const n = value as Record<string, unknown>;
107 const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
108 const kinds: NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval"];
109 const kind = kinds.find((k) => k === n.kind);
110 const id = text(n.id, 200);
111 const workspace = text(n.workspace, 100).toLowerCase();
112 const title = text(n.title, 200);
113 if (!kind || !id || !title) return null;
114 const href = text(n.href, 2000);
115 const a = (n.actor && typeof n.actor === "object" ? n.actor : {}) as Record<string, unknown>;
116 const actorKind = a.kind === "user" || a.kind === "agent" ? a.kind : "system";
117 return {
118 id,
119 kind,
120 workspace,
121 title,
122 body: text(n.body, 300),
123 // Relative to the site only: a notification never links somewhere else.
124 href: href.startsWith("/") && !href.startsWith("//") ? href : "/",
125 actor: {
126 kind: actorKind,
127 id: text(a.id, 100) || "g1t",
128 name: text(a.name, 100) || "g1t",
129 avatar: text(a.avatar, 100) || null,
130 avatar_seed: text(a.avatar_seed, 100) || null,
131 },
132 channel_id: text(n.channel_id, 100) || null,
133 thread_root: text(n.thread_root, 100) || null,
134 created_at: text(n.created_at, 40) || new Date().toISOString(),
135 };
136}
137
138/**
139 * What a push carries: little, under the 4 KB a push may hold. `tag` makes
140 * the notifications of one conversation replace each other.
141 */
142export function pushPayload(n: FeedNotification): { title: string; body: string; href: string; tag: string; kind: NotificationKind; urgent: boolean } {
143 return {
144 title: n.title,
145 body: n.body.slice(0, 240),
146 href: n.href,
147 tag: n.channel_id ? `chat:${n.channel_id}` : `${n.kind}:${n.id}`,
148 kind: n.kind,
149 urgent: DIRECT.has(n.kind),
150 };
151}

This file's history is long; its oldest lines are credited to the oldest commit read.