| 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 | */ |
| 10 | import type { CardAction } from "./chat"; |
| 11 | import type { ServiceBinding } from "./clients"; |
| 12 | import type { User } from "./identity"; |
| 13 | |
| 14 | /** What a notification is about; the preferences decide which ones toast and push. */ |
| 15 | export type NotificationKind = "dm" | "mention" | "thread_reply" | "inbox" | "agent_waiting" | "approval"; |
| 16 | |
| 17 | export 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. */ |
| 20 | export 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 | |
| 31 | export 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. */ |
| 59 | export 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. */ |
| 69 | export type NotifyLevel = "all" | "dms_mentions" | "none"; |
| 70 | |
| 71 | export const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"]; |
| 72 | |
| 73 | export 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. */ |
| 80 | export type NotifyPreferencesChange = { level?: NotifyLevel; workspaces?: Record<string, NotifyLevel | null> }; |
| 81 | |
| 82 | export const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} }; |
| 83 | |
| 84 | /** A browser's push subscription, as `PushSubscription.toJSON()` gives it. */ |
| 85 | export 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. */ |
| 92 | export 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 | */ |
| 101 | export 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 | |
| 123 | export 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 | */ |
| 130 | export type StatusSource = "manual" | "calendar" | "integration"; |
| 131 | |
| 132 | export const STATUS_SOURCES: readonly StatusSource[] = ["manual", "calendar", "integration"]; |
| 133 | |
| 134 | export 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. */ |
| 147 | export 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. */ |
| 159 | export 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 | */ |
| 165 | export 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. */ |
| 172 | export 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 | */ |
| 193 | export 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 | */ |
| 202 | export 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. */ |
| 216 | export 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. */ |
| 225 | export const NOTIFY_VIEWER_HEADER = "x-g1t-notify-viewer"; |
| 226 | export const NOTIFY_SEED_HEADER = "x-g1t-notify-seed"; |
| 227 | |
| 228 | export 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 | |
| 238 | export 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 | |
| 259 | async 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 | |
| 269 | export 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 | } |