| 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 | /** What the feed socket sends a tab. */ |
| 92 | export 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 | */ |
| 105 | export 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 | */ |
| 114 | export 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. */ |
| 128 | export 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. */ |
| 135 | export const NOTIFY_VIEWER_HEADER = "x-g1t-notify-viewer"; |
| 136 | export const NOTIFY_SEED_HEADER = "x-g1t-notify-seed"; |
| 137 | |
| 138 | export 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 | |
| 148 | export 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 | |
| 161 | async 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 | |
| 171 | export 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 | } |