Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.