| 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 { ServiceBinding } from "./clients"; |
| 11 | import type { User } from "./identity"; |
| 12 | |
| 13 | /** What a notification is about; the preferences decide which ones toast and push. */ |
| 14 | export type NotificationKind = "dm" | "mention" | "thread_reply" | "inbox" | "agent_waiting" | "approval"; |
| 15 | |
| 16 | export 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. */ |
| 19 | export 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 | |
| 30 | export 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. */ |
| 50 | export type NotifyLevel = "all" | "dms_mentions" | "none"; |
| 51 | |
| 52 | export const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"]; |
| 53 | |
| 54 | export 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. */ |
| 61 | export type NotifyPreferencesChange = { level?: NotifyLevel; workspaces?: Record<string, NotifyLevel | null> }; |
| 62 | |
| 63 | export const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} }; |
| 64 | |
| 65 | /** A browser's push subscription, as `PushSubscription.toJSON()` gives it. */ |
| 66 | export 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. */ |
| 73 | export 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 | */ |
| 82 | export 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 | // ── Presence and status ────────────────────────────────────────────────── |
| 92 | // |
| 93 | // Presence is worked out live by the person's feed from their open tabs: |
| 94 | // `active` while a tab has been used in the last few minutes, `away` when |
| 95 | // every tab has sat idle (or they set themselves away), `offline` once no |
| 96 | // tab is open. Status is what they say about themselves: an emoji and a |
| 97 | // few words, cleared at a time they chose. Do Not Disturb silences toasts |
| 98 | // and pushes until a time; counts still move. |
| 99 | // |
| 100 | // Both are kept by the notify service (the feed, one per person) and told |
| 101 | // to everyone who shares a workspace with them, over the same socket, |
| 102 | // through one presence room per workspace. Nothing polls. |
| 103 | |
| 104 | export type Presence = "active" | "away" | "offline"; |
| 105 | |
| 106 | /** |
| 107 | * Who set a status: the person, or something acting for them. Calendars |
| 108 | * and other integrations set `calendar` or `integration` (with their own |
| 109 | * `clear_at`); a status the person set by hand is never replaced by one. |
| 110 | */ |
| 111 | export type StatusSource = "manual" | "calendar" | "integration"; |
| 112 | |
| 113 | export const STATUS_SOURCES: readonly StatusSource[] = ["manual", "calendar", "integration"]; |
| 114 | |
| 115 | export type PersonStatus = { |
| 116 | /** One emoji, or a custom emoji's `:name:`; null for none. */ |
| 117 | emoji: string | null; |
| 118 | /** A few words: "In a meeting". At most 100 characters. */ |
| 119 | text: string; |
| 120 | /** When it clears itself (RFC 3339); null keeps it until changed. */ |
| 121 | clear_at: string | null; |
| 122 | source: StatusSource; |
| 123 | /** When it was set (RFC 3339). */ |
| 124 | set_at: string; |
| 125 | }; |
| 126 | |
| 127 | /** How a person shows to the people who share a workspace with them. */ |
| 128 | export type PresenceEntry = { |
| 129 | user_id: string; |
| 130 | username: string; |
| 131 | presence: Presence; |
| 132 | /** Do Not Disturb, until then (RFC 3339); null when off. */ |
| 133 | dnd_until: string | null; |
| 134 | status: PersonStatus | null; |
| 135 | /** When this last changed, in ms: a later word wins. */ |
| 136 | at: number; |
| 137 | }; |
| 138 | |
| 139 | /** The signed-in person's own: what everyone sees, and whether they set themselves away. */ |
| 140 | export type OwnPresence = PresenceEntry & { away_manual: boolean }; |
| 141 | |
| 142 | /** |
| 143 | * A change to your own. `status: null` clears it; `away` sets (or ends) |
| 144 | * being away by hand; `dnd_until: null` resumes notifications. |
| 145 | */ |
| 146 | export type PresenceChange = { |
| 147 | status?: { emoji?: string | null; text: string; clear_at?: string | null; source?: StatusSource } | null; |
| 148 | away?: boolean; |
| 149 | dnd_until?: string | null; |
| 150 | }; |
| 151 | |
| 152 | /** What the feed socket sends a tab. */ |
| 153 | export type FeedEvent = |
| 154 | | { type: "hello"; notifications: FeedNotification[]; vapid_public_key: string | null; preferences: NotifyPreferences } |
| 155 | | { type: "notification"; notification: FeedNotification; toast: boolean } |
| 156 | | ({ type: "counts" } & FeedCounts) |
| 157 | | { type: "preferences"; preferences: NotifyPreferences } |
| 158 | /** The person's inbox count as it now is, after items arrive or are marked anywhere. */ |
| 159 | | { type: "inbox"; unread: number } |
| 160 | /** |
| 161 | * People in a workspace: everyone known on connect (`full`), then each |
| 162 | * one as they change. |
| 163 | */ |
| 164 | | { type: "presence"; workspace: string; people: PresenceEntry[]; full: boolean } |
| 165 | /** Your own presence, status and Do Not Disturb, on connect and after every change. */ |
| 166 | | { type: "me"; me: OwnPresence }; |
| 167 | |
| 168 | /** |
| 169 | * What a tab sends over the feed socket: plain `ping` every 25 s (answered |
| 170 | * without waking the feed), and a state frame whenever it gains or loses |
| 171 | * focus, moves to another page, or goes idle (no input for a while) or |
| 172 | * back, and the inbox count the page last read. |
| 173 | */ |
| 174 | export type FeedClientFrame = |
| 175 | | { type: "state"; focused: boolean; path: string; idle?: boolean } |
| 176 | | { type: "inbox"; unread: number }; |
| 177 | |
| 178 | /** |
| 179 | * One delivery from chat: counts to move for one person in one |
| 180 | * conversation, and maybe a notification. `set` replaces the counts (after |
| 181 | * reading, or writing); otherwise they are added. |
| 182 | */ |
| 183 | export type FeedDelivery = { |
| 184 | user_id: string; |
| 185 | workspace: string; |
| 186 | counts?: { |
| 187 | channel_id: string; |
| 188 | unread: number; |
| 189 | mentions: number; |
| 190 | muted?: boolean | null; |
| 191 | set?: boolean; |
| 192 | } | null; |
| 193 | notification?: FeedNotification | null; |
| 194 | }; |
| 195 | |
| 196 | /** What the site hands the feed with a socket: who, and the counts read from chat and the inbox just now. */ |
| 197 | export type FeedSeed = { |
| 198 | workspace: string | null; |
| 199 | per_channel: ChannelCounts[] | null; |
| 200 | inbox_unread: number | null; |
| 201 | /** Every workspace the person belongs to, by slug: whose rooms hear of their presence. */ |
| 202 | workspaces?: string[] | null; |
| 203 | }; |
| 204 | |
| 205 | /** Headers the site sets on a forwarded feed socket. */ |
| 206 | export const NOTIFY_VIEWER_HEADER = "x-g1t-notify-viewer"; |
| 207 | export const NOTIFY_SEED_HEADER = "x-g1t-notify-seed"; |
| 208 | |
| 209 | export type NotifyStatus = { |
| 210 | preferences: NotifyPreferences; |
| 211 | /** How many browsers get pushes. */ |
| 212 | subscriptions: number; |
| 213 | /** Whether this browser's endpoint is one of them, when it was asked about. */ |
| 214 | subscribed: boolean; |
| 215 | /** The public key a browser subscribes with; null when push is not set up. */ |
| 216 | vapid_public_key: string | null; |
| 217 | }; |
| 218 | |
| 219 | export type NotifyApi = { |
| 220 | /** Tells a person: their open tabs at once, and a push when none is in front of them. */ |
| 221 | notify(target: { user_id?: string; username?: string }, notification: FeedNotification): Promise<{ ok: boolean }>; |
| 222 | /** Many at once, as chat sends them for a message. */ |
| 223 | deliver(items: FeedDelivery[]): Promise<{ ok: boolean }>; |
| 224 | subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>; |
| 225 | unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>; |
| 226 | status(user: User, endpoint?: string | null): Promise<NotifyStatus>; |
| 227 | setPreferences(user: User, preferences: NotifyPreferencesChange): Promise<NotifyPreferences>; |
| 228 | /** Sends the person a test notification, toasted and pushed whatever their focus. */ |
| 229 | test(user: User): Promise<{ ok: boolean; pushed: number }>; |
| 230 | /** Your own presence, status and Do Not Disturb. */ |
| 231 | presence(user: User): Promise<OwnPresence>; |
| 232 | /** |
| 233 | * Changes your own, and tells everyone who shares a workspace with you. |
| 234 | * Integrations set a status for someone the same way, with their own |
| 235 | * `source`; one the person set by hand is kept over theirs. |
| 236 | */ |
| 237 | setPresence(user: Pick<User, "id" | "username">, change: PresenceChange): Promise<OwnPresence>; |
| 238 | }; |
| 239 | |
| 240 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { |
| 241 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 242 | method: "POST", |
| 243 | headers: { "content-type": "application/json" }, |
| 244 | body: JSON.stringify(args), |
| 245 | }); |
| 246 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 247 | return (await response.json()) as T; |
| 248 | } |
| 249 | |
| 250 | export function notifyClient(service: ServiceBinding): NotifyApi { |
| 251 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); |
| 252 | return { |
| 253 | notify: (target, notification) => call("notify", { user_id: target.user_id ?? null, username: target.username ?? null, notification }), |
| 254 | deliver: (items) => call("deliver", { items }), |
| 255 | subscribe: (user, subscription, userAgent) => call("subscribe", { user_id: user.id, subscription, user_agent: userAgent ?? null }), |
| 256 | unsubscribe: (user, endpoint) => call("unsubscribe", { user_id: user.id, endpoint }), |
| 257 | status: (user, endpoint) => call("status", { user_id: user.id, endpoint: endpoint ?? null }), |
| 258 | setPreferences: (user, preferences) => call("set_preferences", { user_id: user.id, preferences }), |
| 259 | test: (user) => call("test", { user_id: user.id, username: user.username }), |
| 260 | presence: (user) => call("presence", { user_id: user.id, username: user.username }), |
| 261 | setPresence: (user, change) => call("set_presence", { user_id: user.id, username: user.username, change }), |
| 262 | }; |
| 263 | } |