Skip to content
202 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 { 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// ── Counts ────────────────────────────────────────────────────────────────
98
99/** What the rail shows for a workspace. */
100export type LiveBadges = { chat: number; mentions: number; inbox: number };
101
102/**
103 * The rail's numbers from the feed, or null while it cannot say: chat's
104 * only once the feed read them from chat itself (`complete`).
105 */
106export function badgesOf(counts: FeedCounts | null | undefined, inbox: number | null): Partial<LiveBadges> | null {
107 const out: Partial<LiveBadges> = {};
108 if (counts?.complete) {
109 out.chat = counts.chat_unread;
110 out.mentions = counts.chat_mentions;
111 }
112 const inboxUnread = inbox ?? (counts ? counts.inbox_unread : null);
113 if (inboxUnread != null) out.inbox = inboxUnread;
114 return Object.keys(out).length ? out : null;
115}
116
117/** A conversation read here, zeroed until the feed says so itself. */
118export function markReadLocally(counts: FeedCounts, channelId: string): FeedCounts {
119 const row = counts.per_channel.find((c) => c.channel_id === channelId);
120 if (!row) return counts;
121 // Muted unread was never in chat_unread; it is not known here, so the feed's next word settles it.
122 return {
123 ...counts,
124 chat_unread: Math.max(0, counts.chat_unread - row.unread),
125 chat_mentions: Math.max(0, counts.chat_mentions - row.mentions),
126 per_channel: counts.per_channel.filter((c) => c.channel_id !== channelId),
127 };
128}
129
130/**
131 * The Chat sidebar with the feed's counts: each conversation's unread and
132 * mentions as the feed has them (none, when it does not list it). Only
133 * once the feed's counts are complete; before, the sidebar as it was read.
134 */
135export function overlayEntries(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): ChatSidebarEntry[] {
136 if (!counts?.complete) return entries;
137 const by = new Map(counts.per_channel.map((c) => [c.channel_id, c]));
138 let changed = false;
139 const out = entries.map((entry) => {
140 const live = by.get(entry.channel.id);
141 const unread = live?.unread ?? 0;
142 const mentions = live?.mentions ?? 0;
143 if (unread === entry.unread && mentions === entry.mentions) return entry;
144 changed = true;
145 return { ...entry, unread, mentions };
146 });
147 return changed ? out : entries;
148}
149
150/** Conversations the feed counts that the sidebar does not list: a new DM, a channel just joined. */
151export function unlisted(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): string[] {
152 if (!counts) return [];
153 const listed = new Set(entries.map((e) => e.channel.id));
154 return counts.per_channel.filter((c) => c.unread > 0 && !listed.has(c.channel_id)).map((c) => c.channel_id);
155}
156
157// ── The tab ───────────────────────────────────────────────────────────────
158
159const COUNTED = /^\(\d+\+?\) /;
160
161/** The tab's title with `count` in front, or without one at zero. */
162export function titleWith(title: string, count: number): string {
163 const bare = title.replace(COUNTED, "");
164 if (count <= 0) return bare;
165 return `(${count > 99 ? "99+" : count}) ${bare}`;
166}
167
168/** What the tab and the app icon count: chat unread (mentions when there are none) and the inbox. */
169export function attentionCount(badges: Partial<LiveBadges> | null): number {
170 if (!badges) return 0;
171 return Math.max(badges.chat ?? 0, badges.mentions ?? 0) + (badges.inbox ?? 0);
172}
173
174// ── Browser notifications ─────────────────────────────────────────────────
175
176/** What the person said to the offer, kept in the browser. */
177export type PushChoice = "declined" | "off" | "on" | null;
178
179/**
180 * Whether to offer browser notifications: after a DM or mention toast,
181 * only where the browser can, only while it has not been asked, and never
182 * again once the person said no or closed the offer.
183 */
184export function offerPush(input: {
185 kind: FeedNotification["kind"];
186 supported: boolean;
187 permission: "default" | "granted" | "denied";
188 choice: PushChoice;
189 hasKey: boolean;
190 desktop: boolean;
191}): boolean {
192 if (input.desktop || !input.supported || !input.hasKey) return false;
193 if (input.kind !== "dm" && input.kind !== "mention") return false;
194 if (input.permission !== "default") return false;
195 return input.choice === null;
196}
197
198/** Full jitter: a wait anywhere up to the doubled step, from a second up to half a minute. */
199export function reconnectDelay(attempt: number, random: () => number = Math.random): number {
200 const ceiling = Math.min(30_000, 1_000 * 2 ** Math.max(0, Math.min(attempt, 10)));
201 return Math.round(500 + random() * (ceiling - 500));
202}