| 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 { 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 | // ── Counts ──────────────────────────────────────────────────────────────── |
| 98 | |
| 99 | /** What the rail shows for a workspace. */ |
| 100 | export 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 | */ |
| 106 | export 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. */ |
| 118 | export 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 | */ |
| 135 | export 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. */ |
| 151 | export 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 | |
| 159 | const COUNTED = /^\(\d+\+?\) /; |
| 160 | |
| 161 | /** The tab's title with `count` in front, or without one at zero. */ |
| 162 | export 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. */ |
| 169 | export 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. */ |
| 177 | export 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 | */ |
| 184 | export 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. */ |
| 199 | export 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 | } |