Skip to content
182 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 { ServiceBinding } from "./clients";
11import type { User } from "./identity";
12
13/** What a notification is about; the preferences decide which ones toast and push. */
14export type NotificationKind = "dm" | "mention" | "thread_reply" | "inbox" | "agent_waiting" | "approval";
15
16export const NOTIFICATION_KINDS: readonly NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval"];
17
18/** Who it is from: a person, an agent, or g1t itself. */
19export type NotificationActor = {
20 kind: "user" | "agent" | "system";
21 id: string;
22 /** How they show: a display name. */
23 name: string;
24 /** A person's uploaded avatar hash, or null for the letter avatar. */
25 avatar?: string | null;
26 /** What an agent's pixel creature is drawn from. */
27 avatar_seed?: string | null;
28};
29
30export type FeedNotification = {
31 /** Unique per notification: the same id twice is told once. */
32 id: string;
33 kind: NotificationKind;
34 /** The workspace's slug. */
35 workspace: string;
36 title: string;
37 /** A short preview, a line or two. */
38 body: string;
39 /** Where clicking it goes, relative to the site. */
40 href: string;
41 actor: NotificationActor;
42 /** The conversation it is in, for a chat notification: toasts for the open one are skipped, and pushes collapse by it. */
43 channel_id?: string | null;
44 /** The thread it is in, for a reply: quick replies go there. */
45 thread_root?: string | null;
46 created_at: string;
47};
48
49/** How much a person hears of. Counts always move; this governs toasts and pushes. */
50export type NotifyLevel = "all" | "dms_mentions" | "none";
51
52export const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
53
54export type NotifyPreferences = {
55 level: NotifyLevel;
56 /** A different level for a workspace, by slug. */
57 workspaces: Record<string, NotifyLevel>;
58};
59
60/** A change to them: a workspace set to null goes back to the general level. */
61export type NotifyPreferencesChange = { level?: NotifyLevel; workspaces?: Record<string, NotifyLevel | null> };
62
63export const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
64
65/** A browser's push subscription, as `PushSubscription.toJSON()` gives it. */
66export type PushSubscriptionJson = {
67 endpoint: string;
68 expiration_time?: number | null;
69 keys: { p256dh: string; auth: string };
70};
71
72/** A conversation's unread counts for one person. */
73export type ChannelCounts = { channel_id: string; unread: number; mentions: number; muted: boolean };
74
75/**
76 * What a person has unread in a workspace. `chat_unread` leaves out muted
77 * conversations, as the rail's badge does; mentions count everywhere.
78 * `inbox_unread` is the person's whole inbox, which is not per workspace.
79 * `complete` says `per_channel` was read from chat itself (on connect), so
80 * a conversation left out of it has nothing unread.
81 */
82export type FeedCounts = {
83 workspace: string;
84 chat_unread: number;
85 chat_mentions: number;
86 inbox_unread: number;
87 per_channel: Omit<ChannelCounts, "muted">[];
88 complete: boolean;
89};
90
91/** What the feed socket sends a tab. */
92export type FeedEvent =
93 | { type: "hello"; notifications: FeedNotification[]; vapid_public_key: string | null; preferences: NotifyPreferences }
94 | { type: "notification"; notification: FeedNotification; toast: boolean }
95 | ({ type: "counts" } & FeedCounts)
96 | { type: "preferences"; preferences: NotifyPreferences }
97 /** The person's inbox count as it now is, after items arrive or are marked anywhere. */
98 | { type: "inbox"; unread: number };
99
100/**
101 * What a tab sends over the feed socket: plain `ping` every 25 s (answered
102 * without waking the feed), and a state frame whenever it gains or loses
103 * focus or moves to another page, and the inbox count the page last read.
104 */
105export type FeedClientFrame =
106 | { type: "state"; focused: boolean; path: string }
107 | { type: "inbox"; unread: number };
108
109/**
110 * One delivery from chat: counts to move for one person in one
111 * conversation, and maybe a notification. `set` replaces the counts (after
112 * reading, or writing); otherwise they are added.
113 */
114export type FeedDelivery = {
115 user_id: string;
116 workspace: string;
117 counts?: {
118 channel_id: string;
119 unread: number;
120 mentions: number;
121 muted?: boolean | null;
122 set?: boolean;
123 } | null;
124 notification?: FeedNotification | null;
125};
126
127/** What the site hands the feed with a socket: who, and the counts read from chat and the inbox just now. */
128export type FeedSeed = {
129 workspace: string | null;
130 per_channel: ChannelCounts[] | null;
131 inbox_unread: number | null;
132};
133
134/** Headers the site sets on a forwarded feed socket. */
135export const NOTIFY_VIEWER_HEADER = "x-g1t-notify-viewer";
136export const NOTIFY_SEED_HEADER = "x-g1t-notify-seed";
137
138export type NotifyStatus = {
139 preferences: NotifyPreferences;
140 /** How many browsers get pushes. */
141 subscriptions: number;
142 /** Whether this browser's endpoint is one of them, when it was asked about. */
143 subscribed: boolean;
144 /** The public key a browser subscribes with; null when push is not set up. */
145 vapid_public_key: string | null;
146};
147
148export type NotifyApi = {
149 /** Tells a person: their open tabs at once, and a push when none is in front of them. */
150 notify(target: { user_id?: string; username?: string }, notification: FeedNotification): Promise<{ ok: boolean }>;
151 /** Many at once, as chat sends them for a message. */
152 deliver(items: FeedDelivery[]): Promise<{ ok: boolean }>;
153 subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>;
154 unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>;
155 status(user: User, endpoint?: string | null): Promise<NotifyStatus>;
156 setPreferences(user: User, preferences: NotifyPreferencesChange): Promise<NotifyPreferences>;
157 /** Sends the person a test notification, toasted and pushed whatever their focus. */
158 test(user: User): Promise<{ ok: boolean; pushed: number }>;
159};
160
161async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
162 const response = await service.fetch(`https://service/rpc/${method}`, {
163 method: "POST",
164 headers: { "content-type": "application/json" },
165 body: JSON.stringify(args),
166 });
167 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
168 return (await response.json()) as T;
169}
170
171export function notifyClient(service: ServiceBinding): NotifyApi {
172 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
173 return {
174 notify: (target, notification) => call("notify", { user_id: target.user_id ?? null, username: target.username ?? null, notification }),
175 deliver: (items) => call("deliver", { items }),
176 subscribe: (user, subscription, userAgent) => call("subscribe", { user_id: user.id, subscription, user_agent: userAgent ?? null }),
177 unsubscribe: (user, endpoint) => call("unsubscribe", { user_id: user.id, endpoint }),
178 status: (user, endpoint) => call("status", { user_id: user.id, endpoint: endpoint ?? null }),
179 setPreferences: (user, preferences) => call("set_preferences", { user_id: user.id, preferences }),
180 test: (user) => call("test", { user_id: user.id, username: user.username }),
181 };
182}