Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

276 linesCodeBlameRaw
1/**
2 * The rules behind live notifications in the browser, apart from the
3 * browser: which toasts show, how counts merge, what the tab title says,
4 * when to offer browser notifications, and how long to wait before
5 * reconnecting. Pure, so they are tested apart from the socket
6 * (lib/notify-client.ts) and the toasts (components/notifications/).
7 */
8import type { CardAction, ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts";
9
10/** The most toasts on screen; a fourth pushes the oldest out. */
11export const MAX_TOASTS = 3;
12/** How long a toast stays, unless the pointer or the keyboard is on it. */
13export const TOAST_MS = 6_000;
14/** Every so often the socket says it is still there, focus and all. */
15export const HEARTBEAT_MS = 25_000;
16/** While the socket is down, the Chat sidebar asks again this often. */
17export const FALLBACK_REFRESH_MS = 60_000;
18
19export type Toast = { notification: FeedNotification; at: number };
20
21/** The path a link goes to, without its query or fragment. */
22export function pathOf(href: string): string {
23 return href.split(/[?#]/)[0].replace(/\/+$/, "").toLowerCase();
24}
25
26/** What the page shows: its path, the conversation open in Chat, and conversations shown besides (g1t's answer on Home). */
27export type Viewing = { path: string; channel_id?: string | null; shown?: ReadonlySet<string> };
28
29/**
30 * Whether the person is looking at the conversation a notification is
31 * about right now: its page is open, the chat page open says it shows
32 * that channel, or another surface on the page shows its messages (Home's
33 * Summarize, the Ask panel). A toast for it would only repeat what is on
34 * screen.
35 */
36export function isViewing(notification: FeedNotification, viewing: Viewing): boolean {
37 if (notification.channel_id && viewing.channel_id && notification.channel_id === viewing.channel_id) return true;
38 if (!notification.channel_id) return false;
39 if (viewing.shown?.has(notification.channel_id)) return true;
40 return pathOf(notification.href) === pathOf(viewing.path);
41}
42
43/**
44 * The toasts after one arrives: never twice, never for what is on screen,
45 * newest last, at most `MAX_TOASTS`.
46 */
47export function addToast(toasts: Toast[], notification: FeedNotification, viewing: Viewing, now: number): Toast[] {
48 if (toasts.some((t) => t.notification.id === notification.id)) return toasts;
49 if (isViewing(notification, viewing)) return toasts;
50 // A newer message in the same conversation replaces the older toast.
51 const kept = notification.channel_id ? toasts.filter((t) => t.notification.channel_id !== notification.channel_id) : toasts;
52 return [...kept, { notification, at: now }].slice(-MAX_TOASTS);
53}
54
55/** The space between stacked toasts. */
56export const TOAST_GAP = 8;
57/** The "+2 more" pill's height. */
58export const PILL_HEIGHT = 30;
59/** A toast's height before it was measured. */
60export const TOAST_GUESS = 120;
61
62/**
63 * How many of the oldest toasts fold into "+N more" so the stack fits in
64 * `available` pixels. `heights` are oldest first; `reserved` is what
65 * always shows besides (the offer of browser notifications). The newest
66 * toast always shows; nothing ever overlaps.
67 */
68export function hiddenToFit(heights: number[], available: number, reserved = 0): number {
69 const height = (hidden: number) => {
70 const shown = heights.slice(hidden);
71 const parts = shown.length + (reserved > 0 ? 1 : 0) + (hidden > 0 ? 1 : 0);
72 return reserved + shown.reduce((sum, h) => sum + h, 0) + (hidden > 0 ? PILL_HEIGHT : 0) + TOAST_GAP * Math.max(0, parts - 1);
73 };
74 let hidden = 0;
75 while (hidden < heights.length - 1 && height(hidden) > available) hidden++;
76 return hidden;
77}
78
79export function dismissToast(toasts: Toast[], id: string): Toast[] {
80 return toasts.filter((t) => t.notification.id !== id);
81}
82
83/** Whether a notification gets a reply box: something said to you, in a conversation. */
84export function canQuickReply(notification: FeedNotification): boolean {
85 return (
86 (notification.kind === "dm" || notification.kind === "mention" || notification.kind === "thread_reply") &&
87 !!notification.channel_id &&
88 !!notification.workspace
89 );
90}
91
92/** What a quick reply posts to the chat api route. */
93export function quickReplyRequest(
94 notification: FeedNotification,
95 body: string,
96): { url: string; body: { intent: "post"; channel_id: string; body: string; thread_root: string | null } } | null {
97 const text = body.trim();
98 if (!text || !canQuickReply(notification)) return null;
99 return {
100 url: `/${notification.workspace}/-/chat/api`,
101 body: { intent: "post", channel_id: notification.channel_id!, body: text, thread_root: notification.thread_root ?? null },
102 };
103}
104
105// ── Cards ─────────────────────────────────────────────────────────────────
106
107/**
108 * A card's actions on its notification, what the toast and the panel
109 * offer: its links. Nothing answers a card's other buttons any more (they
110 * were an agent's), so they are left out.
111 */
112export function notificationActions(notification: FeedNotification): CardAction[] {
113 const card = notification.card;
114 if (!card || !notification.workspace || !card.channel_id || !card.message_id) return [];
115 return card.actions.filter((a) => !!a.id && !!a.label && !!a.href);
116}
117
118/** How long a card's notification stays in the panel's "Waiting on you". */
119export const WAITING_MS = 24 * 3600_000;
120/** The most notifications the panel keeps to look through. */
121export const RECENT_KEPT = 50;
122
123/** Notifications as the feed tells them, newest first, each once, at most `RECENT_KEPT`. */
124export function addRecent(recent: FeedNotification[], incoming: FeedNotification[]): FeedNotification[] {
125 const seen = new Set<string>();
126 const out: FeedNotification[] = [];
127 for (const n of [...incoming, ...recent]) {
128 if (seen.has(n.id)) continue;
129 seen.add(n.id);
130 out.push(n);
131 }
132 return out.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)).slice(0, RECENT_KEPT);
133}
134
135/**
136 * The cards waiting on the person, for the panel: notifications with
137 * actions from the last day, the newest one per card, unless it was acted
138 * on or put away here.
139 */
140export function waitingCards(recent: FeedNotification[], settled: ReadonlySet<string>, now: number, workspace?: string | null): FeedNotification[] {
141 const cards = new Set<string>();
142 const out: FeedNotification[] = [];
143 for (const n of recent) {
144 if (!notificationActions(n).length) continue;
145 if (workspace && n.workspace !== workspace.toLowerCase()) continue;
146 // The newest about a card speaks for it: once that one is acted on, older ones are moot.
147 const key = `${n.card!.channel_id}:${n.card!.message_id}`;
148 if (cards.has(key)) continue;
149 cards.add(key);
150 if (settled.has(n.id)) continue;
151 const at = Date.parse(n.created_at);
152 if (Number.isFinite(at) && now - at > WAITING_MS) continue;
153 out.push(n);
154 }
155 return out;
156}
157
158// ── Counts ────────────────────────────────────────────────────────────────
159
160/** What the rail shows for a workspace. */
161export type LiveBadges = { chat: number; mentions: number; inbox: number };
162
163/**
164 * The rail's numbers from the feed, or null while it cannot say: chat's
165 * only once the feed read them from chat itself (`complete`).
166 */
167export function badgesOf(counts: FeedCounts | null | undefined, inbox: number | null): Partial<LiveBadges> | null {
168 const out: Partial<LiveBadges> = {};
169 if (counts?.complete) {
170 out.chat = counts.chat_unread;
171 out.mentions = counts.chat_mentions;
172 }
173 const inboxUnread = inbox ?? (counts ? counts.inbox_unread : null);
174 if (inboxUnread != null) out.inbox = inboxUnread;
175 return Object.keys(out).length ? out : null;
176}
177
178/** A conversation read here, zeroed until the feed says so itself. */
179export function markReadLocally(counts: FeedCounts, channelId: string): FeedCounts {
180 const row = counts.per_channel.find((c) => c.channel_id === channelId);
181 if (!row) return counts;
182 // Muted unread was never in chat_unread; it is not known here, so the feed's next word settles it.
183 return {
184 ...counts,
185 chat_unread: Math.max(0, counts.chat_unread - row.unread),
186 chat_mentions: Math.max(0, counts.chat_mentions - row.mentions),
187 per_channel: counts.per_channel.filter((c) => c.channel_id !== channelId),
188 };
189}
190
191/**
192 * The Chat sidebar with the feed's counts: each conversation's unread and
193 * mentions as the feed has them (none, when it does not list it). Only
194 * once the feed's counts are complete; before, the sidebar as it was read.
195 */
196export function overlayEntries(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): ChatSidebarEntry[] {
197 if (!counts?.complete) return entries;
198 const by = new Map(counts.per_channel.map((c) => [c.channel_id, c]));
199 let changed = false;
200 const out = entries.map((entry) => {
201 const live = by.get(entry.channel.id);
202 const unread = live?.unread ?? 0;
203 const mentions = live?.mentions ?? 0;
204 if (unread === entry.unread && mentions === entry.mentions) return entry;
205 changed = true;
206 return { ...entry, unread, mentions };
207 });
208 return changed ? out : entries;
209}
210
211/** Conversations the feed counts that the sidebar does not list: a new DM, a channel just joined. */
212export function unlisted(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): string[] {
213 if (!counts) return [];
214 const listed = new Set(entries.map((e) => e.channel.id));
215 return counts.per_channel.filter((c) => c.unread > 0 && !listed.has(c.channel_id)).map((c) => c.channel_id);
216}
217
218// ── The tab ───────────────────────────────────────────────────────────────
219
220const COUNTED = /^\(\d+\+?\) /;
221
222/** The tab's title with `count` in front, or without one at zero. */
223export function titleWith(title: string, count: number): string {
224 const bare = title.replace(COUNTED, "");
225 if (count <= 0) return bare;
226 return `(${count > 99 ? "99+" : count}) ${bare}`;
227}
228
229/** What the tab and the app icon count: chat unread (mentions when there are none) and the inbox. */
230export function attentionCount(badges: Partial<LiveBadges> | null): number {
231 if (!badges) return 0;
232 return Math.max(badges.chat ?? 0, badges.mentions ?? 0) + (badges.inbox ?? 0);
233}
234
235// ── Browser notifications ─────────────────────────────────────────────────
236
237/** What the person said to the offer, kept in the browser. */
238export type PushChoice = "declined" | "off" | "on" | null;
239
240/**
241 * Whether to offer browser notifications: after a DM or mention toast,
242 * only where the browser can, only while it has not been asked, and never
243 * again once the person said no or closed the offer.
244 */
245export function offerPush(input: {
246 kind: FeedNotification["kind"];
247 supported: boolean;
248 permission: "default" | "granted" | "denied";
249 choice: PushChoice;
250 hasKey: boolean;
251 desktop: boolean;
252}): boolean {
253 if (input.desktop || !input.supported || !input.hasKey) return false;
254 if (input.kind !== "dm" && input.kind !== "mention") return false;
255 if (input.permission !== "default") return false;
256 return input.choice === null;
257}
258
259/** Full jitter: a wait anywhere up to the doubled step, from a second up to half a minute. */
260/**
261 * How long a connection must stay open before its backoff starts over. A
262 * socket the server accepts and drops at once (a Worker being replaced, a
263 * room that can't start) would otherwise reconnect every second from every
264 * open tab, and a busy network runs into the rate limit.
265 */
266export const STABLE_MS = 10_000;
267
268/** Whether a connection that opened at `openedAt` held long enough to start the backoff over. */
269export function heldOpen(openedAt: number | null, now: number = Date.now()): boolean {
270 return openedAt !== null && now - openedAt >= STABLE_MS;
271}
272
273export function reconnectDelay(attempt: number, random: () => number = Math.random): number {
274 const ceiling = Math.min(30_000, 1_000 * 2 ** Math.max(0, Math.min(attempt, 10)));
275 return Math.round(500 + random() * (ceiling - 500));
276}