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