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 | * Live notifications in the browser: one socket per tab to the person's | |
| 3 | * feed (`/-/live`, services/notify), a small store the page reads (counts | |
| 4 | * for the rail, the Chat sidebar and the tab title; toasts; whether the | |
| 5 | * socket is up), and delivery through a `NotificationSink`: the web's own | |
| Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules. | 6 | * (toasts and the app badge) or, inside the desktop app, its native |
| 7 | * bridge. Sounds are apart: every notification goes to | |
| 8 | * lib/sound-events.ts, which decides and plays (lib/chat-sounds.ts, | |
| 9 | * lib/sounds.ts). | |
| 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) | 10 | * |
| 11 | * Nothing here polls. The feed sends counts on connect and after every | |
| 12 | * change; while the socket is down, `connected` is false and the pages | |
| 13 | * that used to poll may fall back to a slow refresh. | |
| 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 | 14 | * |
| 15 | * Presence rides the same socket: the tab says when it goes idle (no input | |
| 16 | * for `IDLE_MS`) or comes back, the feed sends how everyone in the | |
| 17 | * workspace shows (`presence`) and your own (`me`), and `setPresence` | |
| 18 | * changes yours (lib/presence.ts, components/presence.tsx). | |
| 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) | 19 | */ |
| 20 | import { useSyncExternalStore } from "react"; | |
| 21 | ||
| 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 | 22 | import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts"; |
| 23 | ||
| Merge branch 'socket-tickets' | 24 | import { openLive } from "./live-socket"; |
| Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules. | 25 | import { dndOn, isIdle, mergePeople } from "./presence"; |
| 26 | import { notificationArrived } from "./sound-events"; | |
| 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) | 27 | |
| 28 | import { | |
| 29 | HEARTBEAT_MS, | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 30 | addRecent, |
| 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) | 31 | addToast, |
| 32 | attentionCount, | |
| 33 | badgesOf, | |
| 34 | dismissToast, | |
| 35 | markReadLocally, | |
| 36 | offerPush, | |
| Live sockets back off until a connection holds: one the server accepts and drops at once (a Worker being replaced) no longer reconnects every second from every open tab | 37 | heldOpen, |
| 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) | 38 | reconnectDelay, |
| 39 | titleWith, | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 40 | waitingCards, |
| 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) | 41 | type LiveBadges, |
| 42 | type PushChoice, | |
| 43 | type Toast, | |
| 44 | } from "./notify-store"; | |
| 45 | ||
| 46 | // ── The desktop bridge ─────────────────────────────────────────────────── | |
| 47 | ||
| 48 | /** | |
| preparing for cloud work | 49 | * What the desktop app (apps/desktop, a Tauri shell loading this site) |
| 50 | * puts at `window.g1tDesktop` from a script it runs at the start of every | |
| 51 | * page (src/bridge.js there). When it is there, notifications go to it | |
| 52 | * instead of the browser, the badge to the app's icon, and links to other | |
| 53 | * sites to the system's browser: | |
| 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) | 54 | * |
| 55 | * ```ts | |
| 56 | * window.g1tDesktop = { | |
| preparing for cloud work | 57 | * version: "0.1.0", |
| 58 | * platform: "windows" | "macos" | "linux", | |
| 59 | * // A notification from the system, for a message to the person while | |
| 60 | * // the window is behind another. Clicking it brings the app forward; | |
| 61 | * // it does not yet open `href`. | |
| 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) | 62 | * notify({ id, title, body, href, tag, icon }) {}, |
| preparing for cloud work | 63 | * // What needs the person, on the app's icon and in the tray; 0 clears it. |
| 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) | 64 | * setBadge(count) {}, |
| 65 | * // Opens a URL outside the app, in the system browser. | |
| 66 | * openUrl(url) {}, | |
| preparing for cloud work | 67 | * // g1t:// links, as site paths ("/acme/-/chat/dm/chn_1"). |
| 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) | 68 | * onNavigate(listener) { return () => {} }, |
| preparing for cloud work | 69 | * // Looks for an update now: "current", "ready:<version>", or rejects with why. |
| 70 | * checkForUpdates() { return Promise.resolve("current") }, | |
| 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) | 71 | * }; |
| 72 | * ``` | |
| 73 | */ | |
| 74 | export type DesktopBridge = { | |
| 75 | version?: string; | |
| preparing for cloud work | 76 | platform?: string; |
| 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) | 77 | notify(notification: { id: string; title: string; body: string; href: string; tag: string; icon?: string | null }): void; |
| 78 | setBadge(count: number): void; | |
| 79 | openUrl(url: string): void; | |
| 80 | onNavigate?(listener: (href: string) => void): () => void; | |
| preparing for cloud work | 81 | checkForUpdates?(): Promise<string>; |
| 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) | 82 | }; |
| 83 | ||
| 84 | declare global { | |
| 85 | interface Window { | |
| 86 | g1tDesktop?: DesktopBridge; | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | export function desktopBridge(): DesktopBridge | null { | |
| 91 | if (typeof window === "undefined") return null; | |
| 92 | const bridge = window.g1tDesktop; | |
| 93 | return bridge && typeof bridge.notify === "function" && typeof bridge.setBadge === "function" ? bridge : null; | |
| 94 | } | |
| 95 | ||
| preparing for cloud work | 96 | const never = () => () => {}; |
| 97 | ||
| 98 | /** Whether this page is inside the desktop app: false on the server, and until hydrated. */ | |
| 99 | export function useDesktopApp(): boolean { | |
| 100 | return useSyncExternalStore(never, () => !!desktopBridge(), () => false); | |
| 101 | } | |
| 102 | ||
| 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) | 103 | // ── Sinks ──────────────────────────────────────────────────────────────── |
| 104 | ||
| 105 | /** Where a live notification goes once it arrives. */ | |
| 106 | export interface NotificationSink { | |
| 107 | readonly kind: "web" | "desktop"; | |
| 108 | /** A notification arrived; `toast` says the preferences want it shown, `focused` whether this tab is in front. */ | |
| 109 | deliver(notification: FeedNotification, options: { toast: boolean; focused: boolean }): void; | |
| 110 | /** What needs the person, for the app icon. */ | |
| 111 | setBadge(count: number): void; | |
| 112 | /** Goes to a page on the site. */ | |
| 113 | open(href: string): void; | |
| 114 | } | |
| 115 | ||
| 116 | let navigator_: ((href: string) => void) | null = null; | |
| 117 | ||
| 118 | /** The router's navigate, so a toast or a notification click moves without a reload. */ | |
| 119 | export function setNavigator(navigate: ((href: string) => void) | null): void { | |
| 120 | navigator_ = navigate; | |
| 121 | } | |
| 122 | ||
| 123 | function openHref(href: string): void { | |
| 124 | if (navigator_) navigator_(href); | |
| 125 | else if (typeof location !== "undefined") location.assign(href); | |
| 126 | } | |
| 127 | ||
| Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules. | 128 | /** The web's: a toast in the tab, the installed app's badge. */ |
| 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) | 129 | export const webSink: NotificationSink = { |
| 130 | kind: "web", | |
| 131 | deliver(notification, { toast }) { | |
| 132 | if (!toast) return; | |
| 133 | showToast(notification); | |
| 134 | }, | |
| 135 | setBadge(count) { | |
| 136 | const nav = typeof navigator !== "undefined" ? (navigator as Navigator & { setAppBadge?(n: number): Promise<void>; clearAppBadge?(): Promise<void> }) : null; | |
| 137 | try { | |
| 138 | if (count > 0) void nav?.setAppBadge?.(count)?.catch(() => {}); | |
| 139 | else void nav?.clearAppBadge?.()?.catch(() => {}); | |
| 140 | } catch { | |
| 141 | // Not installed, or not allowed: the tab title says it anyway. | |
| 142 | } | |
| 143 | }, | |
| 144 | open: openHref, | |
| 145 | }; | |
| 146 | ||
| 147 | /** The desktop app's: toasts while its window is in front, native notifications while it is not. */ | |
| 148 | export function desktopSink(bridge: DesktopBridge): NotificationSink { | |
| 149 | return { | |
| 150 | kind: "desktop", | |
| 151 | deliver(notification, { toast, focused }) { | |
| 152 | if (!toast) return; | |
| 153 | if (focused) { | |
| 154 | showToast(notification); | |
| 155 | return; | |
| 156 | } | |
| 157 | bridge.notify({ | |
| 158 | id: notification.id, | |
| 159 | title: notification.title, | |
| 160 | body: notification.body, | |
| 161 | href: notification.href, | |
| 162 | tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`, | |
| 163 | icon: null, | |
| 164 | }); | |
| 165 | }, | |
| 166 | setBadge(count) { | |
| 167 | bridge.setBadge(count); | |
| 168 | }, | |
| 169 | open: openHref, | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | /** The desktop bridge's sink when there is one, else the web's. */ | |
| 174 | export function currentSink(): NotificationSink { | |
| 175 | const bridge = desktopBridge(); | |
| 176 | return bridge ? desktopSink(bridge) : webSink; | |
| 177 | } | |
| 178 | ||
| 179 | // ── The store ──────────────────────────────────────────────────────────── | |
| 180 | ||
| 181 | export type NotifyState = { | |
| 182 | /** Whether the feed socket is up. */ | |
| 183 | connected: boolean; | |
| 184 | /** Counts by workspace, as the feed last sent them. */ | |
| 185 | counts: Record<string, FeedCounts>; | |
| 186 | /** The inbox's unread, the person's whole inbox. */ | |
| 187 | inbox: number | null; | |
| 188 | toasts: Toast[]; | |
| 189 | preferences: NotifyPreferences | null; | |
| 190 | vapidKey: string | null; | |
| 191 | /** Whether to show the offer to turn on browser notifications. */ | |
| 192 | offer: boolean; | |
| 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 | 193 | /** How the people who share a workspace with you show, by user id. */ |
| 194 | people: Record<string, PresenceEntry>; | |
| 195 | /** Your own presence, status and Do Not Disturb, once the feed has said. */ | |
| 196 | me: OwnPresence | null; | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 197 | /** The latest notifications, newest first: the panel's cards waiting on you come from these. */ |
| 198 | recent: FeedNotification[]; | |
| 199 | /** Card notifications acted on (or put away) in this tab, by id. */ | |
| 200 | settled: ReadonlySet<string>; | |
| 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) | 201 | }; |
| 202 | ||
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 203 | let state: NotifyState = { |
| 204 | connected: false, | |
| 205 | counts: {}, | |
| 206 | inbox: null, | |
| 207 | toasts: [], | |
| 208 | preferences: null, | |
| 209 | vapidKey: null, | |
| 210 | offer: false, | |
| 211 | people: {}, | |
| 212 | me: null, | |
| 213 | recent: [], | |
| 214 | settled: new Set(), | |
| 215 | }; | |
| 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) | 216 | const listeners = new Set<() => void>(); |
| 217 | const SERVER_STATE = state; | |
| 218 | ||
| 219 | function set(next: Partial<NotifyState>): void { | |
| 220 | state = { ...state, ...next }; | |
| 221 | for (const listener of listeners) listener(); | |
| 222 | } | |
| 223 | ||
| 224 | function subscribeStore(listener: () => void): () => void { | |
| 225 | listeners.add(listener); | |
| 226 | return () => listeners.delete(listener); | |
| 227 | } | |
| 228 | ||
| 229 | export function useNotifyState(): NotifyState { | |
| 230 | return useSyncExternalStore(subscribeStore, () => state, () => SERVER_STATE); | |
| 231 | } | |
| 232 | ||
| 233 | /** The rail's numbers for a workspace from the feed, or null while it cannot say. */ | |
| 234 | export function useLiveBadges(workspace: string | null | undefined): Partial<LiveBadges> | null { | |
| 235 | const s = useNotifyState(); | |
| 236 | return badgesOf(workspace ? s.counts[workspace.toLowerCase()] : null, s.inbox); | |
| 237 | } | |
| 238 | ||
| 239 | /** A workspace's counts from the feed, for the Chat sidebar. */ | |
| 240 | export function useLiveCounts(workspace: string | null | undefined): FeedCounts | null { | |
| 241 | const s = useNotifyState(); | |
| 242 | return workspace ? (s.counts[workspace.toLowerCase()] ?? null) : null; | |
| 243 | } | |
| 244 | ||
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 245 | /** A card's notification was acted on, or put away: its toast goes, and the panel stops waiting on it. */ |
| 246 | export function settle(id: string): void { | |
| 247 | if (state.settled.has(id)) return; | |
| 248 | set({ settled: new Set(state.settled).add(id), toasts: dismissToast(state.toasts, id) }); | |
| 249 | } | |
| 250 | ||
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 251 | /** The chat cards waiting on the person, newest first, for the panel. */ |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 252 | export function useWaitingCards(workspace?: string | null): FeedNotification[] { |
| 253 | const s = useNotifyState(); | |
| 254 | return waitingCards(s.recent, s.settled, Date.now(), workspace); | |
| 255 | } | |
| 256 | ||
| 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 | 257 | /** People's presence by user id, with an index by username (lowercased). */ |
| 258 | let peopleIndex: { from: Record<string, PresenceEntry>; byName: Map<string, PresenceEntry> } = { from: {}, byName: new Map() }; | |
| 259 | ||
| 260 | function byName(people: Record<string, PresenceEntry>): Map<string, PresenceEntry> { | |
| 261 | if (peopleIndex.from !== people) { | |
| 262 | peopleIndex = { from: people, byName: new Map(Object.values(people).map((entry) => [entry.username.toLowerCase(), entry])) }; | |
| 263 | } | |
| 264 | return peopleIndex.byName; | |
| 265 | } | |
| 266 | ||
| 267 | /** | |
| 268 | * How a person shows, by id or username, or null while the feed has not | |
| 269 | * said (they share no workspace with you, or the socket is not up yet). | |
| 270 | * Your own comes from `me`, so it moves the moment you change it. | |
| 271 | */ | |
| 272 | export function usePresenceOf(person: { id?: string | null; username?: string | null } | null | undefined): PresenceEntry | null { | |
| 273 | const s = useNotifyState(); | |
| 274 | if (!person) return null; | |
| 275 | const mine = s.me && ((person.id && person.id === s.me.user_id) || (person.username && person.username.toLowerCase() === s.me.username.toLowerCase())); | |
| 276 | if (mine) return s.me; | |
| 277 | if (person.id && s.people[person.id]) return s.people[person.id]!; | |
| 278 | return person.username ? (byName(s.people).get(person.username.toLowerCase()) ?? null) : null; | |
| 279 | } | |
| 280 | ||
| 281 | export function useOwnPresence(): OwnPresence | null { | |
| 282 | return useNotifyState().me; | |
| 283 | } | |
| 284 | ||
| Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules. | 285 | /** Whether you are not to be disturbed right now, for code outside React (the sound rules). */ |
| 286 | export function dndNow(): boolean { | |
| 287 | return dndOn(state.me, Date.now()); | |
| 288 | } | |
| 289 | ||
| 290 | /** Every notification, however it is shown, goes to the sound rules and the desktop toast (lib/sound-events.ts). */ | |
| 291 | function heard(notification: FeedNotification, toast: boolean): void { | |
| 292 | notificationArrived(notification, { toast, dnd: dndNow(), permission: permission(), pushOn: pushChoice() === "on", open: openHref }); | |
| 293 | } | |
| 294 | ||
| 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) | 295 | /** Whether the feed is up: pages fall back to a slow refresh only while it is not. */ |
| 296 | export function feedConnected(): boolean { | |
| 297 | return state.connected; | |
| 298 | } | |
| 299 | ||
| 300 | export function useFeedConnected(): boolean { | |
| 301 | return useNotifyState().connected; | |
| 302 | } | |
| 303 | ||
| 304 | let viewing: { path: string; channel_id?: string | null } = { path: "" }; | |
| 305 | ||
| 306 | /** The page the person is on, so its own conversation does not toast. */ | |
| 307 | export function setViewing(next: { path: string; channel_id?: string | null }): void { | |
| 308 | viewing = next; | |
| 309 | sendState(); | |
| 310 | } | |
| 311 | ||
| 312 | function showToast(notification: FeedNotification): void { | |
| 313 | const toasts = addToast(state.toasts, notification, viewing, Date.now()); | |
| 314 | if (toasts === state.toasts) return; | |
| 315 | const offer = | |
| 316 | state.offer || | |
| 317 | offerPush({ | |
| 318 | kind: notification.kind, | |
| 319 | supported: pushSupported(), | |
| 320 | permission: permission(), | |
| 321 | choice: pushChoice(), | |
| 322 | hasKey: !!state.vapidKey, | |
| 323 | desktop: !!desktopBridge(), | |
| 324 | }); | |
| 325 | set({ toasts, offer }); | |
| 326 | } | |
| 327 | ||
| 328 | export function dismiss(id: string): void { | |
| 329 | set({ toasts: dismissToast(state.toasts, id) }); | |
| 330 | } | |
| 331 | ||
| 332 | export function closeOffer(): void { | |
| 333 | set({ offer: false }); | |
| 334 | } | |
| 335 | ||
| 336 | /** For the settings page's test, and the harness: a notification shown as if it arrived. */ | |
| 337 | export function showLocally(notification: FeedNotification): void { | |
| 338 | currentSink().deliver(notification, { toast: true, focused: true }); | |
| Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules. | 339 | heard(notification, true); |
| 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) | 340 | } |
| 341 | ||
| 342 | // ── The socket ─────────────────────────────────────────────────────────── | |
| 343 | ||
| 344 | let socket: WebSocket | null = null; | |
| 345 | let workspace: string | null = null; | |
| 346 | let attempt = 0; | |
| 347 | let timer: ReturnType<typeof setTimeout> | null = null; | |
| 348 | let beat: ReturnType<typeof setInterval> | null = null; | |
| 349 | let running = false; | |
| 350 | let sink: NotificationSink = webSink; | |
| 351 | ||
| 352 | function focused(): boolean { | |
| 353 | return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus(); | |
| 354 | } | |
| 355 | ||
| 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 | 356 | // ── Idle ───────────────────────────────────────────────────────────────── |
| 357 | ||
| 358 | let lastInput = Date.now(); | |
| 359 | let idle = false; | |
| 360 | ||
| 361 | /** Input in this tab: back from idle at once, if it was. */ | |
| 362 | function onInput(): void { | |
| 363 | lastInput = Date.now(); | |
| 364 | if (idle) { | |
| 365 | idle = false; | |
| 366 | sendState(); | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | /** Every heartbeat: gone idle since the last one? */ | |
| 371 | function checkIdle(): void { | |
| 372 | if (!idle && isIdle(lastInput, Date.now())) { | |
| 373 | idle = true; | |
| 374 | sendState(); | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | const INPUT_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart"] as const; | |
| 379 | ||
| 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) | 380 | function sendState(): void { |
| 381 | if (socket?.readyState !== WebSocket.OPEN) return; | |
| 382 | try { | |
| 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 | 383 | socket.send(JSON.stringify({ type: "state", focused: focused(), path: viewing.path, idle })); |
| 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) | 384 | } catch { |
| 385 | // Closing; it comes back. | |
| 386 | } | |
| 387 | } | |
| 388 | ||
| 389 | /** The inbox count a page just read, so every tab shows it. */ | |
| 390 | export function reportInbox(unread: number): void { | |
| 391 | if (state.inbox === unread) return; | |
| 392 | set({ inbox: unread }); | |
| 393 | if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "inbox", unread })); | |
| 394 | } | |
| 395 | ||
| 396 | function onEvent(event: FeedEvent): void { | |
| 397 | switch (event.type) { | |
| 398 | case "hello": | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 399 | set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) }); |
| 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) | 400 | break; |
| 401 | case "counts": { | |
| 402 | const { type: _, ...counts } = event; | |
| 403 | set({ counts: { ...state.counts, [counts.workspace]: counts }, inbox: counts.inbox_unread }); | |
| 404 | break; | |
| 405 | } | |
| 406 | case "notification": | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 407 | set({ recent: addRecent(state.recent, [event.notification]) }); |
| 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) | 408 | sink.deliver(event.notification, { toast: event.toast, focused: focused() }); |
| Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules. | 409 | heard(event.notification, event.toast); |
| 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) | 410 | break; |
| 411 | case "preferences": | |
| 412 | set({ preferences: event.preferences }); | |
| 413 | break; | |
| 414 | case "inbox": | |
| 415 | set({ inbox: event.unread }); | |
| 416 | break; | |
| 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 | 417 | case "presence": { |
| 418 | const people = mergePeople(state.people, event.people, event.full); | |
| 419 | if (people !== state.people) set({ people }); | |
| 420 | break; | |
| 421 | } | |
| 422 | case "me": | |
| 423 | set({ me: event.me }); | |
| 424 | break; | |
| 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) | 425 | } |
| 426 | } | |
| 427 | ||
| Merge branch 'socket-tickets' | 428 | /** Between asking for a socket ticket and opening the socket (lib/live-socket.ts). */ |
| 429 | let opening = false; | |
| 430 | ||
| 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) | 431 | function connect(): void { |
| Merge branch 'socket-tickets' | 432 | if (!running || opening) return; |
| 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) | 433 | timer = null; |
| Merge branch 'socket-tickets' | 434 | opening = true; |
| 435 | openLive( | |
| 436 | "/-/live", | |
| 437 | // Read when the socket opens: the workspace may change meanwhile. | |
| 438 | () => ({ workspace }), | |
| 439 | (address) => { | |
| 440 | opening = false; | |
| 441 | open(address); | |
| 442 | }, | |
| 443 | () => { | |
| 444 | if (running) return false; | |
| 445 | opening = false; | |
| 446 | return true; | |
| 447 | }, | |
| 448 | ); | |
| 449 | } | |
| 450 | ||
| 451 | function open(address: string): void { | |
| 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) | 452 | let ws: WebSocket; |
| 453 | try { | |
| Merge branch 'socket-tickets' | 454 | ws = new WebSocket(address); |
| 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) | 455 | } catch { |
| 456 | schedule(); | |
| 457 | return; | |
| 458 | } | |
| 459 | socket = ws; | |
| Live sockets back off until a connection holds: one the server accepts and drops at once (a Worker being replaced) no longer reconnects every second from every open tab | 460 | let openedAt: number | null = 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) | 461 | ws.onopen = () => { |
| Live sockets back off until a connection holds: one the server accepts and drops at once (a Worker being replaced) no longer reconnects every second from every open tab | 462 | openedAt = Date.now(); |
| 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) | 463 | set({ connected: true }); |
| 464 | sendState(); | |
| 465 | }; | |
| 466 | ws.onmessage = (message) => { | |
| 467 | if (message.data === "pong") return; | |
| 468 | try { | |
| 469 | onEvent(JSON.parse(String(message.data)) as FeedEvent); | |
| 470 | } catch { | |
| 471 | // Not an event this page knows. | |
| 472 | } | |
| 473 | }; | |
| 474 | ws.onclose = () => { | |
| 475 | if (socket === ws) socket = null; | |
| Live sockets back off until a connection holds: one the server accepts and drops at once (a Worker being replaced) no longer reconnects every second from every open tab | 476 | // Only a connection that held starts the backoff over. |
| 477 | if (heldOpen(openedAt)) attempt = 0; | |
| 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) | 478 | set({ connected: false }); |
| 479 | schedule(); | |
| 480 | }; | |
| 481 | ws.onerror = () => ws.close(); | |
| 482 | } | |
| 483 | ||
| 484 | function schedule(): void { | |
| 485 | if (!running || timer) return; | |
| 486 | timer = setTimeout(connect, reconnectDelay(attempt++)); | |
| 487 | } | |
| 488 | ||
| 489 | /** Back now: the tab is shown, or the network returned. */ | |
| 490 | function now(): void { | |
| 491 | sendState(); | |
| Merge branch 'socket-tickets' | 492 | if (!running || opening || socket || document.visibilityState !== "visible") return; |
| 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) | 493 | if (timer) clearTimeout(timer); |
| 494 | timer = null; | |
| 495 | attempt = 0; | |
| 496 | connect(); | |
| 497 | } | |
| 498 | ||
| 499 | function onRead(event: Event): void { | |
| 500 | const channel = String((event as CustomEvent).detail ?? ""); | |
| 501 | if (!channel || !workspace || !state.counts[workspace]) return; | |
| 502 | set({ counts: { ...state.counts, [workspace]: markReadLocally(state.counts[workspace], channel) } }); | |
| 503 | } | |
| 504 | ||
| 505 | /** | |
| 506 | * Opens the feed for the signed-in person (once per tab; calling again | |
| 507 | * with another workspace reconnects, so the feed reads that workspace's | |
| 508 | * counts). Returns how to close it. | |
| 509 | */ | |
| 510 | export function startFeed(forWorkspace: string | null): () => void { | |
| 511 | const next = forWorkspace?.toLowerCase() ?? null; | |
| 512 | if (running && next === workspace) return stopFeed; | |
| 513 | workspace = next; | |
| 514 | sink = currentSink(); | |
| 515 | if (running) { | |
| 516 | // Another workspace: its counts are read on connect. | |
| 517 | socket?.close(1000, "workspace"); | |
| 518 | return stopFeed; | |
| 519 | } | |
| 520 | running = true; | |
| 521 | connect(); | |
| 522 | beat = setInterval(() => { | |
| 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 | 523 | checkIdle(); |
| 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) | 524 | if (socket?.readyState === WebSocket.OPEN) socket.send("ping"); |
| 525 | }, HEARTBEAT_MS); | |
| 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 | 526 | lastInput = Date.now(); |
| 527 | idle = false; | |
| 528 | for (const name of INPUT_EVENTS) window.addEventListener(name, onInput, { passive: true, capture: true }); | |
| 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) | 529 | document.addEventListener("visibilitychange", now); |
| 530 | window.addEventListener("focus", now); | |
| 531 | window.addEventListener("blur", sendState); | |
| 532 | window.addEventListener("online", now); | |
| 533 | window.addEventListener("g1t:chat-read", onRead); | |
| 534 | const bridge = desktopBridge(); | |
| 535 | const off = bridge?.onNavigate?.((href) => openHref(href)); | |
| 536 | unbridge = off ?? null; | |
| 537 | return stopFeed; | |
| 538 | } | |
| 539 | ||
| 540 | let unbridge: (() => void) | null = null; | |
| 541 | ||
| 542 | export function stopFeed(): void { | |
| 543 | running = false; | |
| 544 | if (timer) clearTimeout(timer); | |
| 545 | if (beat) clearInterval(beat); | |
| 546 | timer = beat = null; | |
| 547 | socket?.close(1000, "bye"); | |
| 548 | socket = null; | |
| 549 | document.removeEventListener("visibilitychange", now); | |
| 550 | window.removeEventListener("focus", now); | |
| 551 | window.removeEventListener("blur", sendState); | |
| 552 | window.removeEventListener("online", now); | |
| 553 | window.removeEventListener("g1t:chat-read", onRead); | |
| 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 | 554 | for (const name of INPUT_EVENTS) window.removeEventListener(name, onInput, { capture: true }); |
| 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) | 555 | unbridge?.(); |
| 556 | unbridge = null; | |
| 557 | set({ connected: false }); | |
| 558 | } | |
| 559 | ||
| 560 | /** Keeps the tab title's count and the app badge in step with `count`. */ | |
| 561 | export function applyAttention(badges: Partial<LiveBadges> | null): number { | |
| 562 | const count = attentionCount(badges); | |
| 563 | if (typeof document !== "undefined") { | |
| 564 | const next = titleWith(document.title, count); | |
| 565 | if (next !== document.title) document.title = next; | |
| 566 | } | |
| 567 | sink.setBadge(count); | |
| 568 | return count; | |
| 569 | } | |
| 570 | ||
| 571 | // ── Browser push ───────────────────────────────────────────────────────── | |
| 572 | ||
| 573 | const PUSH_KEY = "g1t:notify:push"; | |
| 574 | ||
| 575 | export function pushChoice(): PushChoice { | |
| 576 | try { | |
| 577 | const value = localStorage.getItem(PUSH_KEY); | |
| 578 | return value === "declined" || value === "off" || value === "on" ? value : null; | |
| 579 | } catch { | |
| 580 | return null; | |
| 581 | } | |
| 582 | } | |
| 583 | ||
| 584 | function setPushChoice(choice: PushChoice): void { | |
| 585 | try { | |
| 586 | if (choice) localStorage.setItem(PUSH_KEY, choice); | |
| 587 | else localStorage.removeItem(PUSH_KEY); | |
| 588 | } catch { | |
| 589 | // Asked again next time; nothing worse. | |
| 590 | } | |
| 591 | } | |
| 592 | ||
| 593 | /** The person closed the offer or said no: never offered again. */ | |
| 594 | export function declinePush(): void { | |
| 595 | setPushChoice("declined"); | |
| 596 | set({ offer: false }); | |
| 597 | } | |
| 598 | ||
| 599 | export function pushSupported(): boolean { | |
| 600 | return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; | |
| 601 | } | |
| 602 | ||
| 603 | export function permission(): "default" | "granted" | "denied" { | |
| 604 | return typeof window !== "undefined" && "Notification" in window ? Notification.permission : "denied"; | |
| 605 | } | |
| 606 | ||
| 607 | function keyBytes(base64url: string): Uint8Array<ArrayBuffer> { | |
| 608 | const padded = base64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (base64url.length % 4)) % 4); | |
| 609 | const raw = atob(padded); | |
| 610 | const out = new Uint8Array(new ArrayBuffer(raw.length)); | |
| 611 | for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); | |
| 612 | return out; | |
| 613 | } | |
| 614 | ||
| 615 | async function registration(): Promise<ServiceWorkerRegistration> { | |
| 616 | const existing = await navigator.serviceWorker.getRegistration("/"); | |
| 617 | return existing ?? navigator.serviceWorker.register("/sw.js", { scope: "/" }); | |
| 618 | } | |
| 619 | ||
| 620 | async function api(body: object): Promise<Response> { | |
| 621 | return fetch("/-/notify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 622 | } | |
| 623 | ||
| 624 | /** The public key browsers subscribe with, from the feed or asked for. */ | |
| 625 | async function vapidKey(): Promise<string | null> { | |
| 626 | if (state.vapidKey) return state.vapidKey; | |
| 627 | const status = await notifyStatus().catch(() => null); | |
| 628 | return status?.vapid_public_key ?? null; | |
| 629 | } | |
| 630 | ||
| 631 | export type PushResult = "on" | "denied" | "unsupported" | "unavailable" | "failed"; | |
| 632 | ||
| 633 | /** | |
| 634 | * Asks the browser, then subscribes it: only ever from a click. A no is | |
| 635 | * kept, so g1t never asks again. | |
| 636 | */ | |
| 637 | export async function enablePush(): Promise<PushResult> { | |
| 638 | if (!pushSupported()) return "unsupported"; | |
| 639 | const key = await vapidKey(); | |
| 640 | if (!key) return "unavailable"; | |
| 641 | const answer = permission() === "granted" ? "granted" : await Notification.requestPermission(); | |
| 642 | if (answer !== "granted") { | |
| 643 | setPushChoice("declined"); | |
| 644 | set({ offer: false }); | |
| 645 | return "denied"; | |
| 646 | } | |
| 647 | try { | |
| 648 | const reg = await registration(); | |
| 649 | await navigator.serviceWorker.ready; | |
| 650 | const subscription = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) })); | |
| 651 | const response = await api({ intent: "subscribe", subscription: subscription.toJSON() }); | |
| 652 | if (!response.ok) return "failed"; | |
| 653 | setPushChoice("on"); | |
| 654 | set({ offer: false }); | |
| 655 | return "on"; | |
| 656 | } catch (error) { | |
| 657 | console.error("notify: could not subscribe", error); | |
| 658 | return "failed"; | |
| 659 | } | |
| 660 | } | |
| 661 | ||
| 662 | /** Stops pushes to this browser; kept as the person's choice. */ | |
| 663 | export async function disablePush(): Promise<void> { | |
| 664 | setPushChoice("off"); | |
| 665 | if (!pushSupported()) return; | |
| 666 | const reg = await navigator.serviceWorker.getRegistration("/"); | |
| 667 | const subscription = await reg?.pushManager.getSubscription(); | |
| 668 | if (!subscription) return; | |
| 669 | await api({ intent: "unsubscribe", endpoint: subscription.endpoint }).catch(() => null); | |
| 670 | await subscription.unsubscribe().catch(() => false); | |
| 671 | } | |
| 672 | ||
| 673 | /** This browser's push endpoint, if subscribed. */ | |
| 674 | export async function currentEndpoint(): Promise<string | null> { | |
| 675 | if (!pushSupported()) return null; | |
| 676 | const reg = await navigator.serviceWorker.getRegistration("/"); | |
| 677 | return (await reg?.pushManager.getSubscription())?.endpoint ?? null; | |
| 678 | } | |
| 679 | ||
| 680 | export async function notifyStatus(): Promise<import("@g1t/contracts").NotifyStatus | null> { | |
| 681 | const endpoint = await currentEndpoint().catch(() => null); | |
| 682 | const response = await fetch(`/-/notify${endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : ""}`, { headers: { accept: "application/json" } }); | |
| 683 | return response.ok ? response.json() : null; | |
| 684 | } | |
| 685 | ||
| 686 | /** | |
| 687 | * On each visit, for someone who turned pushes on: the service worker is | |
| 688 | * registered, and the subscription is handed over again (browsers renew | |
| 689 | * them), so pushes keep arriving. Asks nothing. | |
| 690 | */ | |
| 691 | export async function refreshPush(): Promise<void> { | |
| 692 | if (!pushSupported() || desktopBridge()) return; | |
| 693 | if (permission() !== "granted" || pushChoice() === "off") return; | |
| 694 | const key = await vapidKey(); | |
| 695 | if (!key) return; | |
| 696 | const reg = await registration(); | |
| 697 | await navigator.serviceWorker.ready; | |
| 698 | let subscription = await reg.pushManager.getSubscription(); | |
| 699 | subscription ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) }); | |
| 700 | await api({ intent: "subscribe", subscription: subscription.toJSON() }); | |
| 701 | } | |
| 702 | ||
| 703 | export async function savePreferences(preferences: import("@g1t/contracts").NotifyPreferencesChange): Promise<NotifyPreferences | null> { | |
| 704 | const response = await api({ intent: "preferences", preferences }); | |
| 705 | if (!response.ok) return null; | |
| 706 | const saved = (await response.json()) as NotifyPreferences; | |
| 707 | set({ preferences: saved }); | |
| 708 | return saved; | |
| 709 | } | |
| 710 | ||
| 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 | 711 | /** |
| 712 | * Changes your own presence: a status, being away, Do Not Disturb. Shown | |
| 713 | * at once, and put back if the feed refuses; every tab and everyone who | |
| 714 | * shares a workspace with you hear of it over the socket. | |
| 715 | */ | |
| 716 | export async function setPresence(change: PresenceChange, optimistic?: Partial<OwnPresence>): Promise<OwnPresence | null> { | |
| 717 | const before = state.me; | |
| 718 | if (before && optimistic) set({ me: { ...before, ...optimistic } }); | |
| 719 | try { | |
| 720 | const response = await api({ intent: "presence", change }); | |
| 721 | if (!response.ok) throw new Error(`status ${response.status}`); | |
| 722 | const me = (await response.json()) as OwnPresence; | |
| 723 | set({ me }); | |
| 724 | return me; | |
| 725 | } catch (error) { | |
| 726 | console.error("notify: could not change presence", error); | |
| 727 | if (optimistic) set({ me: before }); | |
| 728 | return null; | |
| 729 | } | |
| 730 | } | |
| 731 | ||
| 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) | 732 | export async function sendTest(): Promise<{ ok: boolean; pushed: number } | null> { |
| 733 | const response = await api({ intent: "test" }); | |
| 734 | return response.ok ? response.json() : null; | |
| 735 | } | |
| 736 | ||
| 737 | /** Test seams: the store's state as given, for the harness. */ | |
| 738 | export function setStateForTest(next: Partial<NotifyState>): void { | |
| 739 | set(next); | |
| 740 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.