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