Skip to content
327 linesCodeBlameRaw
1/**
2 * Notifications: the notify service (`services/notify`) keeps one feed per
3 * person, a Durable Object holding their open tabs' sockets, their latest
4 * notifications, live unread counts, their browser push subscriptions and
5 * the phones the g1t app pushes to.
6 *
7 * Chat tells it about every message a person should count or hear of, the
8 * events service about every inbox item, and the site forwards each tab's
9 * `/-/live` socket to it. Wire shapes are snake_case end to end.
10 */
11import type { AgentLook } from "./agent-look";
12import type { CardAction } from "./chat";
13import type { ServiceBinding } from "./clients";
14import type { User } from "./identity";
15
16/**
17 * What a notification is about; the preferences decide which ones toast and
18 * push. `security` is an urgent security alert (critical or high, a refused
19 * push, a vulnerability report), shown like a mention; the rest of the
20 * security items are ordinary `inbox` ones.
21 */
22export type NotificationKind = "dm" | "mention" | "thread_reply" | "inbox" | "agent_waiting" | "approval" | "security";
23
24export const NOTIFICATION_KINDS: readonly NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval", "security"];
25
26/** Who it is from: a person, an agent, or g1t itself. */
27export type NotificationActor = {
28 kind: "user" | "agent" | "system";
29 id: string;
30 /** How they show: a display name. */
31 name: string;
32 /** A person's uploaded avatar hash, or null for the letter avatar. */
33 avatar?: string | null;
34 /** What an agent's face is drawn from when it chose none. */
35 avatar_seed?: string | null;
36 /** An agent's chosen face (./agent-look.ts). */
37 look?: AgentLook | null;
38};
39
40export type FeedNotification = {
41 /** Unique per notification: the same id twice is told once. */
42 id: string;
43 kind: NotificationKind;
44 /** The workspace's slug. */
45 workspace: string;
46 title: string;
47 /** A short preview, a line or two. */
48 body: string;
49 /** Where clicking it goes, relative to the site. */
50 href: string;
51 actor: NotificationActor;
52 /** The conversation it is in, for a chat notification: toasts for the open one are skipped, and pushes collapse by it. */
53 channel_id?: string | null;
54 /** The thread it is in, for a reply: quick replies go there. */
55 thread_root?: string | null;
56 /**
57 * The chat card it is about, when that card has something to press: a
58 * session at its cap (Approve more, Stop, Open), a draft issue (File
59 * issue, Discard). The toast and the notifications panel show these
60 * actions, and pressing one goes to the site's `card_action`, exactly as
61 * on the card itself.
62 */
63 card?: NotificationCard | null;
64 created_at: string;
65};
66
67/** A card's place and its main actions, carried on a notification about it. */
68export type NotificationCard = {
69 /** The conversation the card is in. */
70 channel_id: string;
71 /** The message that is the card. */
72 message_id: string;
73 /** The card's actions as it offers them: ids, labels, inputs and links, the same as on the card. */
74 actions: CardAction[];
75};
76
77/** How much a person hears of. Counts always move; this governs toasts and pushes. */
78export type NotifyLevel = "all" | "dms_mentions" | "none";
79
80export const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
81
82export type NotifyPreferences = {
83 level: NotifyLevel;
84 /** A different level for a workspace, by slug. */
85 workspaces: Record<string, NotifyLevel>;
86};
87
88/** A change to them: a workspace set to null goes back to the general level. */
89export type NotifyPreferencesChange = { level?: NotifyLevel; workspaces?: Record<string, NotifyLevel | null> };
90
91export const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
92
93/** A browser's push subscription, as `PushSubscription.toJSON()` gives it. */
94export type PushSubscriptionJson = {
95 endpoint: string;
96 expiration_time?: number | null;
97 keys: { p256dh: string; auth: string };
98};
99
100/**
101 * A phone the g1t app runs on, as the app registers it: the push token
102 * Expo gave it (`ExponentPushToken[…]`), which platform, and a name to
103 * know it by (at most 100 characters).
104 */
105export type PushDevice = { token: string; platform: "android" | "ios"; name?: string | null };
106
107/** A conversation's unread counts for one person. */
108export type ChannelCounts = { channel_id: string; unread: number; mentions: number; muted: boolean };
109
110/**
111 * What a person has unread in a workspace. `chat_unread` leaves out muted
112 * conversations, as the rail's badge does; mentions count everywhere.
113 * `inbox_unread` is the person's whole inbox, which is not per workspace.
114 * `complete` says `per_channel` was read from chat itself (on connect), so
115 * a conversation left out of it has nothing unread.
116 */
117export type FeedCounts = {
118 workspace: string;
119 chat_unread: number;
120 chat_mentions: number;
121 inbox_unread: number;
122 per_channel: Omit<ChannelCounts, "muted">[];
123 complete: boolean;
124};
125
126// ── Presence and status ──────────────────────────────────────────────────
127//
128// Presence is worked out live by the person's feed from their open tabs:
129// `active` while a tab has been used in the last few minutes, `away` when
130// every tab has sat idle (or they set themselves away), `offline` once no
131// tab is open. Status is what they say about themselves: an emoji and a
132// few words, cleared at a time they chose. Do Not Disturb silences toasts
133// and pushes until a time; counts still move.
134//
135// Both are kept by the notify service (the feed, one per person) and told
136// to everyone who shares a workspace with them, over the same socket,
137// through one presence room per workspace. Nothing polls.
138
139export type Presence = "active" | "away" | "offline";
140
141/**
142 * Who set a status: the person, or something acting for them. Calendars
143 * and other integrations set `calendar` or `integration` (with their own
144 * `clear_at`); a status the person set by hand is never replaced by one.
145 */
146export type StatusSource = "manual" | "calendar" | "integration";
147
148export const STATUS_SOURCES: readonly StatusSource[] = ["manual", "calendar", "integration"];
149
150export type PersonStatus = {
151 /** One emoji, or a custom emoji's `:name:`; null for none. */
152 emoji: string | null;
153 /** A few words: "In a meeting". At most 100 characters. */
154 text: string;
155 /** When it clears itself (RFC 3339); null keeps it until changed. */
156 clear_at: string | null;
157 source: StatusSource;
158 /** When it was set (RFC 3339). */
159 set_at: string;
160};
161
162/** How a person shows to the people who share a workspace with them. */
163export type PresenceEntry = {
164 user_id: string;
165 username: string;
166 presence: Presence;
167 /** Do Not Disturb, until then (RFC 3339); null when off. */
168 dnd_until: string | null;
169 status: PersonStatus | null;
170 /** When this last changed, in ms: a later word wins. */
171 at: number;
172};
173
174/** The signed-in person's own: what everyone sees, and whether they set themselves away. */
175export type OwnPresence = PresenceEntry & { away_manual: boolean };
176
177/**
178 * A change to your own. `status: null` clears it; `away` sets (or ends)
179 * being away by hand; `dnd_until: null` resumes notifications.
180 */
181export type PresenceChange = {
182 status?: { emoji?: string | null; text: string; clear_at?: string | null; source?: StatusSource } | null;
183 away?: boolean;
184 dnd_until?: string | null;
185};
186
187/** What the feed socket sends a tab. */
188export type FeedEvent =
189 | { type: "hello"; notifications: FeedNotification[]; vapid_public_key: string | null; preferences: NotifyPreferences }
190 | { type: "notification"; notification: FeedNotification; toast: boolean }
191 | ({ type: "counts" } & FeedCounts)
192 | { type: "preferences"; preferences: NotifyPreferences }
193 /** The person's inbox count as it now is, after items arrive or are marked anywhere. */
194 | { type: "inbox"; unread: number }
195 /**
196 * People in a workspace: everyone known on connect (`full`), then each
197 * one as they change.
198 */
199 | { type: "presence"; workspace: string; people: PresenceEntry[]; full: boolean }
200 /** Your own presence, status and Do Not Disturb, on connect and after every change. */
201 | { type: "me"; me: OwnPresence };
202
203/**
204 * What a tab sends over the feed socket: plain `ping` every 25 s (answered
205 * without waking the feed), and a state frame whenever it gains or loses
206 * focus, moves to another page, or goes idle (no input for a while) or
207 * back, and the inbox count the page last read.
208 */
209export type FeedClientFrame =
210 | { type: "state"; focused: boolean; path: string; idle?: boolean }
211 | { type: "inbox"; unread: number };
212
213/**
214 * One delivery from chat: counts to move for one person in one
215 * conversation, and maybe a notification. `set` replaces the counts (after
216 * reading, or writing); otherwise they are added.
217 */
218export type FeedDelivery = {
219 user_id: string;
220 workspace: string;
221 counts?: {
222 channel_id: string;
223 unread: number;
224 mentions: number;
225 muted?: boolean | null;
226 set?: boolean;
227 } | null;
228 notification?: FeedNotification | null;
229};
230
231/** What the site hands the feed with a socket: who, and the counts read from chat and the inbox just now. */
232export type FeedSeed = {
233 workspace: string | null;
234 per_channel: ChannelCounts[] | null;
235 inbox_unread: number | null;
236 /** Every workspace the person belongs to, by slug: whose rooms hear of their presence. */
237 workspaces?: string[] | null;
238};
239
240/** Headers the site sets on a forwarded feed socket. */
241export const NOTIFY_VIEWER_HEADER = "x-g1t-notify-viewer";
242export const NOTIFY_SEED_HEADER = "x-g1t-notify-seed";
243
244export type NotifyStatus = {
245 preferences: NotifyPreferences;
246 /** How many browsers get pushes. */
247 subscriptions: number;
248 /** How many phones the g1t app pushes to (absent from a service older than them). */
249 devices?: number;
250 /** Whether this browser's endpoint is one of them, when it was asked about. */
251 subscribed: boolean;
252 /** The public key a browser subscribes with; null when push is not set up. */
253 vapid_public_key: string | null;
254};
255
256export type NotifyApi = {
257 /** Tells a person: their open tabs at once, and a push when none is in front of them. */
258 notify(target: { user_id?: string; username?: string }, notification: FeedNotification): Promise<{ ok: boolean }>;
259 /** Many at once, as chat sends them for a message. */
260 deliver(items: FeedDelivery[]): Promise<{ ok: boolean }>;
261 subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>;
262 unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>;
263 /** A phone the app runs on gets pushes too; the newest 10 are kept. `ok` is false for a token that is not Expo's. */
264 /** `off`: this g1t sends phones nothing (PHONE_PUSH=off). */
265 registerDevice(user: User, device: PushDevice): Promise<{ ok: boolean; off?: boolean }>;
266 /** That phone stops getting them: the app signed out. */
267 unregisterDevice(user: User, token: string): Promise<{ ok: boolean }>;
268 status(user: User, endpoint?: string | null): Promise<NotifyStatus>;
269 setPreferences(user: User, preferences: NotifyPreferencesChange): Promise<NotifyPreferences>;
270 /** Sends the person a test notification, toasted and pushed whatever their focus. */
271 test(user: User): Promise<{ ok: boolean; pushed: number }>;
272 /** Your own presence, status and Do Not Disturb. */
273 presence(user: User): Promise<OwnPresence>;
274 /**
275 * Changes your own, and tells everyone who shares a workspace with you.
276 * Integrations set a status for someone the same way, with their own
277 * `source`; one the person set by hand is kept over theirs.
278 */
279 setPresence(user: Pick<User, "id" | "username">, change: PresenceChange): Promise<OwnPresence>;
280 /**
281 * For services: how a workspace's people show now (`userIds` narrows it).
282 * Someone the room has never heard from is not listed: they are offline.
283 */
284 workspacePresence(workspace: string, userIds?: string[] | null): Promise<PresenceEntry[]>;
285 /**
286 * When the person was last on the workspace's Home page (RFC 3339), or
287 * null when they never were. Kept with them, so it is the same on every
288 * device.
289 */
290 lastVisit(user: Pick<User, "id">, workspace: string): Promise<{ seen_at: string | null }>;
291 /**
292 * Marks that visit at `at` (RFC 3339): Home sends the time the page
293 * loaded, once the person has looked at it. It only moves forward, never
294 * past now nor more than a day behind it.
295 */
296 markVisit(user: Pick<User, "id">, workspace: string, at: string): Promise<{ seen_at: string | null }>;
297};
298
299async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
300 const response = await service.fetch(`https://service/rpc/${method}`, {
301 method: "POST",
302 headers: { "content-type": "application/json" },
303 body: JSON.stringify(args),
304 });
305 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
306 return (await response.json()) as T;
307}
308
309export function notifyClient(service: ServiceBinding): NotifyApi {
310 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
311 return {
312 notify: (target, notification) => call("notify", { user_id: target.user_id ?? null, username: target.username ?? null, notification }),
313 deliver: (items) => call("deliver", { items }),
314 subscribe: (user, subscription, userAgent) => call("subscribe", { user_id: user.id, subscription, user_agent: userAgent ?? null }),
315 unsubscribe: (user, endpoint) => call("unsubscribe", { user_id: user.id, endpoint }),
316 registerDevice: (user, device) => call("register_device", { user_id: user.id, device }),
317 unregisterDevice: (user, token) => call("unregister_device", { user_id: user.id, token }),
318 status: (user, endpoint) => call("status", { user_id: user.id, endpoint: endpoint ?? null }),
319 setPreferences: (user, preferences) => call("set_preferences", { user_id: user.id, preferences }),
320 test: (user) => call("test", { user_id: user.id, username: user.username }),
321 presence: (user) => call("presence", { user_id: user.id, username: user.username }),
322 setPresence: (user, change) => call("set_presence", { user_id: user.id, username: user.username, change }),
323 workspacePresence: (workspace, userIds) => call("workspace_presence", { workspace, user_ids: userIds ?? null }),
324 lastVisit: (user, workspace) => call("last_visit", { user_id: user.id, workspace }),
325 markVisit: (user, workspace, at) => call("mark_visit", { user_id: user.id, workspace, at }),
326 };
327}