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