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.

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