| 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 | */ |
| 8 | import type { CardAction, ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts"; |
| 9 | |
| 10 | /** The most toasts on screen; a fourth pushes the oldest out. */ |
| 11 | export const MAX_TOASTS = 3; |
| 12 | /** How long a toast stays, unless the pointer or the keyboard is on it. */ |
| 13 | export const TOAST_MS = 6_000; |
| 14 | /** Every so often the socket says it is still there, focus and all. */ |
| 15 | export const HEARTBEAT_MS = 25_000; |
| 16 | /** While the socket is down, the Chat sidebar asks again this often. */ |
| 17 | export const FALLBACK_REFRESH_MS = 60_000; |
| 18 | |
| 19 | export type Toast = { notification: FeedNotification; at: number }; |
| 20 | |
| 21 | /** The path a link goes to, without its query or fragment. */ |
| 22 | export 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 | */ |
| 31 | export 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 | */ |
| 41 | export 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. */ |
| 55 | export const TOAST_GAP = 8; |
| 56 | /** The "+2 more" pill's height. */ |
| 57 | export const PILL_HEIGHT = 30; |
| 58 | /** A toast's height before it was measured. */ |
| 59 | export 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 | */ |
| 67 | export 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 | |
| 78 | export 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. */ |
| 83 | export 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. */ |
| 88 | export 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 (docs/WORKSPACE.md, "Cards"). */ |
| 100 | export 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. */ |
| 107 | export 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". */ |
| 121 | export const WAITING_MS = 24 * 3600_000; |
| 122 | /** The most notifications the panel keeps to look through. */ |
| 123 | export const RECENT_KEPT = 50; |
| 124 | |
| 125 | /** Notifications as the feed tells them, newest first, each once, at most `RECENT_KEPT`. */ |
| 126 | export 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 | */ |
| 142 | export 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. */ |
| 163 | export 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 | */ |
| 169 | export 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. */ |
| 181 | export 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 | */ |
| 198 | export 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. */ |
| 214 | export 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 | |
| 222 | const COUNTED = /^\(\d+\+?\) /; |
| 223 | |
| 224 | /** The tab's title with `count` in front, or without one at zero. */ |
| 225 | export 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. */ |
| 232 | export 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. */ |
| 240 | export 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 | */ |
| 247 | export 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 | export function reconnectDelay(attempt: number, random: () => number = Math.random): number { |
| 263 | const ceiling = Math.min(30_000, 1_000 * 2 ** Math.max(0, Math.min(attempt, 10))); |
| 264 | return Math.round(500 + random() * (ceiling - 500)); |
| 265 | } |