Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | ||
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 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 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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. */ | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 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 }; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 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. | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 173 | */ |
| 174 | export type FeedClientFrame = | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 175 | | { type: "state"; focused: boolean; path: string; idle?: boolean } |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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; | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 201 | /** Every workspace the person belongs to, by slug: whose rooms hear of their presence. */ |
| 202 | workspaces?: string[] | null; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 }>; | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 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>; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 }), | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 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 }), | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 262 | }; |
| 263 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.