| 1 | /** |
| 2 | * Chat as the site draws it: which messages sit together under one name, |
| 3 | * where a day starts, what an `@…` being typed could become, and a |
| 4 | * message's text as safe blocks and spans. Pure, so it is tested on its |
| 5 | * own (chat.test.ts); the components are in components/chat. |
| 6 | * |
| 7 | * Message bodies are never HTML: they are parsed here into a small tree |
| 8 | * that React renders as text, so nothing anyone writes can run. |
| 9 | */ |
| 10 | import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts"; |
| 11 | |
| 12 | /** Two messages from the same author this close together share one header. */ |
| 13 | export const GROUP_GAP_MS = 5 * 60 * 1000; |
| 14 | |
| 15 | /** A message on its way: shown at once, then swapped for what the service saved. */ |
| 16 | export type ShownMessage = ChatMessage & { pending?: boolean; failed?: boolean; client_id?: string }; |
| 17 | |
| 18 | export type TimelineRow = |
| 19 | | { kind: "day"; key: string; label: string } |
| 20 | | { kind: "message"; key: string; message: ShownMessage; head: boolean }; |
| 21 | |
| 22 | function sameAuthor(a: ChatMessage["author"], b: ChatMessage["author"]): boolean { |
| 23 | return a.kind === b.kind && a.id === b.id; |
| 24 | } |
| 25 | |
| 26 | /** The calendar day of an instant, in the reader's time zone (or `zone`). */ |
| 27 | export function dayKey(at: string | Date, zone?: string): string { |
| 28 | const date = typeof at === "string" ? new Date(at) : at; |
| 29 | return new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date); |
| 30 | } |
| 31 | |
| 32 | /** "Today", "Yesterday", or the date, for the rule between days. */ |
| 33 | export function dayLabel(at: string, now: Date = new Date(), zone?: string): string { |
| 34 | const key = dayKey(at, zone); |
| 35 | if (key === dayKey(now, zone)) return "Today"; |
| 36 | const yesterday = new Date(now.getTime() - 86_400_000); |
| 37 | if (key === dayKey(yesterday, zone)) return "Yesterday"; |
| 38 | const date = new Date(at); |
| 39 | const sameYear = dayKey(date, zone).slice(0, 4) === dayKey(now, zone).slice(0, 4); |
| 40 | return new Intl.DateTimeFormat("en-US", { |
| 41 | timeZone: zone, |
| 42 | weekday: "long", |
| 43 | month: "long", |
| 44 | day: "numeric", |
| 45 | year: sameYear ? undefined : "numeric", |
| 46 | }).format(date); |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * The channel's messages, oldest first, as rows: a rule where each day |
| 51 | * starts, and each message marked with whether it starts a group (and so |
| 52 | * shows its author's name and avatar). A card always starts one. |
| 53 | */ |
| 54 | export function timeline(messages: ShownMessage[], now: Date = new Date(), zone?: string): TimelineRow[] { |
| 55 | const rows: TimelineRow[] = []; |
| 56 | let previous: ShownMessage | null = null; |
| 57 | let day: string | null = null; |
| 58 | for (const message of messages) { |
| 59 | if (message.deleted_at) continue; |
| 60 | const today = dayKey(message.created_at, zone); |
| 61 | if (today !== day) { |
| 62 | rows.push({ kind: "day", key: `day:${today}`, label: dayLabel(message.created_at, now, zone) }); |
| 63 | day = today; |
| 64 | previous = null; |
| 65 | } |
| 66 | const head = |
| 67 | previous == null || |
| 68 | message.kind === "card" || |
| 69 | previous.kind === "card" || |
| 70 | !sameAuthor(previous.author, message.author) || |
| 71 | new Date(message.created_at).getTime() - new Date(previous.created_at).getTime() > GROUP_GAP_MS; |
| 72 | rows.push({ kind: "message", key: message.client_id ?? message.id, message, head }); |
| 73 | previous = message; |
| 74 | } |
| 75 | return rows; |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Messages merged by id, oldest first: what the page had, what arrived |
| 80 | * live and what a reload brought. A newer copy (an edit) wins; a pending |
| 81 | * message is dropped once the saved one with its `client_id` is there. |
| 82 | */ |
| 83 | export function mergeMessages(current: ShownMessage[], incoming: ShownMessage[]): ShownMessage[] { |
| 84 | const byId = new Map<string, ShownMessage>(); |
| 85 | for (const message of [...current, ...incoming]) { |
| 86 | const key = message.id; |
| 87 | const had = byId.get(key); |
| 88 | if (!had || !message.pending || had.pending) byId.set(key, message); |
| 89 | } |
| 90 | const saved = new Set([...byId.values()].filter((m) => !m.pending).map((m) => m.client_id).filter(Boolean)); |
| 91 | return [...byId.values()] |
| 92 | .filter((message) => !(message.pending && message.client_id && saved.has(message.client_id))) |
| 93 | .sort((a, b) => (a.pending === b.pending ? (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) : a.pending ? 1 : -1)); |
| 94 | } |
| 95 | |
| 96 | /** Someone who can be mentioned: a person, by username. */ |
| 97 | export type Mentionable = { |
| 98 | kind: "user"; |
| 99 | /** What follows the `@`: lowercased, as it is kept. */ |
| 100 | name: string; |
| 101 | /** A person's username as they wrote it, when that differs from `name`. */ |
| 102 | display_username?: string | null; |
| 103 | display_name: string; |
| 104 | avatar: string | null; |
| 105 | }; |
| 106 | |
| 107 | export type MentionQuery = { start: number; typed: string; options: Mentionable[] }; |
| 108 | |
| 109 | /** Most suggestions at once. */ |
| 110 | export const MAX_MENTIONS = 6; |
| 111 | |
| 112 | /** |
| 113 | * The `@…` just before the caret and who it could be: names that start with |
| 114 | * what is typed first, then display names with a word that does. Null when |
| 115 | * the caret is not in a mention, or nothing fits. |
| 116 | */ |
| 117 | export function mentionQuery(text: string, caret: number, people: readonly Mentionable[]): MentionQuery | null { |
| 118 | const match = /(?:^|[\s(])@([\w.-]{0,40})$/.exec(text.slice(0, caret)); |
| 119 | if (!match) return null; |
| 120 | const typed = match[1]!.toLowerCase(); |
| 121 | const start = caret - match[1]!.length - 1; |
| 122 | const first: Mentionable[] = []; |
| 123 | const then: Mentionable[] = []; |
| 124 | for (const person of people) { |
| 125 | const name = person.name.toLowerCase(); |
| 126 | if (name === typed) continue; |
| 127 | if (name.startsWith(typed)) first.push(person); |
| 128 | else if (typed && person.display_name.toLowerCase().split(/\s+/).some((word) => word.startsWith(typed))) then.push(person); |
| 129 | } |
| 130 | const options = [...first, ...then].slice(0, MAX_MENTIONS); |
| 131 | return options.length > 0 ? { start, typed, options } : null; |
| 132 | } |
| 133 | |
| 134 | // --------------------------------------------------------------------------- |
| 135 | // A message's text: its Markdown, parsed by the rule every part of g1t reads it with. |
| 136 | |
| 137 | export { type Block, type Span, blocks, inline, plainText, safeHref, spansText } from "@g1t/contracts/chat-markdown"; |
| 138 | |
| 139 | /** Whether a message is only emoji, a few of them: shown larger, as people expect. */ |
| 140 | export function onlyEmoji(text: string): boolean { |
| 141 | const trimmed = text.trim(); |
| 142 | if (!trimmed || trimmed.length > 24) return false; |
| 143 | return /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}||️|\s)+$/u.test(trimmed) && /\p{Extended_Pictographic}/u.test(trimmed); |
| 144 | } |
| 145 | |
| 146 | // --------------------------------------------------------------------------- |
| 147 | // The sidebar. |
| 148 | |
| 149 | export type ChatFilter = "all" | "unread" | "mentions"; |
| 150 | |
| 151 | /** |
| 152 | * The conversations the sidebar lists, by filter and by what is typed in |
| 153 | * Jump to. An old direct message with agents alone (agents no longer |
| 154 | * exist) is not listed on its own: it is found by typing the agent's |
| 155 | * name, and stays while it is the conversation that is open (`current`, |
| 156 | * its channel id). |
| 157 | */ |
| 158 | export function filterEntries(entries: ChatSidebarEntry[], filter: ChatFilter, query: string, current: string | null = null): ChatSidebarEntry[] { |
| 159 | const q = query.trim().toLowerCase().replace(/^[#@]/, ""); |
| 160 | return entries.filter((entry) => { |
| 161 | if (filter === "unread" && entry.unread === 0 && entry.mentions === 0) return false; |
| 162 | if (filter === "mentions" && entry.mentions === 0) return false; |
| 163 | if (!q) return entry.channel.id === current || !agentsOnlyDm(entry); |
| 164 | if (entry.title.toLowerCase().includes(q)) return true; |
| 165 | return entry.others.some((member) => member.name.toLowerCase().includes(q) || member.display_name.toLowerCase().includes(q)); |
| 166 | }); |
| 167 | } |
| 168 | |
| 169 | /** Whether a direct message is with agents alone (one or several, nobody else): an old one, read-only now. */ |
| 170 | export function agentsOnlyDm(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind">[] }): boolean { |
| 171 | return entry.channel.kind === "dm" && entry.others.length > 0 && entry.others.every((member) => member.kind === "agent"); |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * The agent an old direct message was with, when the conversation is with |
| 176 | * agents alone: it opens read-only, a note where the composer was. Null for |
| 177 | * any other conversation, since a person is there to talk to. |
| 178 | */ |
| 179 | export function formerAgentDm<M extends Pick<MemberProfile, "kind">>(entry: { channel: { kind: "channel" | "dm" }; others: readonly M[] }): M | null { |
| 180 | return agentsOnlyDm(entry) ? (entry.others[0] ?? null) : null; |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * The sidebar's sections, in the order they show: what is pinned (the |
| 185 | * `starred` preference), channels by name, then direct messages, the |
| 186 | * latest first. |
| 187 | */ |
| 188 | export function sections(entries: ChatSidebarEntry[]): { |
| 189 | pinned: ChatSidebarEntry[]; |
| 190 | channels: ChatSidebarEntry[]; |
| 191 | dms: ChatSidebarEntry[]; |
| 192 | } { |
| 193 | const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title); |
| 194 | // Direct messages: the latest conversation first, as people scan them. |
| 195 | const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) => |
| 196 | (b.channel.last_message_at ?? b.channel.created_at).localeCompare(a.channel.last_message_at ?? a.channel.created_at); |
| 197 | return { |
| 198 | pinned: entries.filter((e) => e.starred).sort(byName), |
| 199 | channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName), |
| 200 | // Your direct message with @g1t heads the list, whenever you last talked. |
| 201 | dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort((a, b) => Number(g1tEntry(b)) - Number(g1tEntry(a)) || byRecent(a, b)), |
| 202 | }; |
| 203 | } |
| 204 | |
| 205 | /** Whether a sidebar entry is the viewer's direct message with @g1t. */ |
| 206 | export function g1tEntry(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind" | "id">[] }): boolean { |
| 207 | return entry.channel.kind === "dm" && entry.others.length === 1 && entry.others[0]!.kind === "system" && entry.others[0]!.id === "g1t"; |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * What an old conversation with an agent says in place of its composer: |
| 212 | * agents no longer exist, so it is kept to read. |
| 213 | */ |
| 214 | export function formerAgentLine(agent: Pick<MemberProfile, "name" | "display_name"> & { display_username?: string | null }): string { |
| 215 | return `${shownName(agent)} was an agent and no longer answers here. Everything said here stays to read.`; |
| 216 | } |
| 217 | |
| 218 | /** Where a conversation is, under the workspace. */ |
| 219 | export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string { |
| 220 | return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`; |
| 221 | } |
| 222 | |
| 223 | /** Unread across the sidebar, leaving out what is muted: the rail's badge. */ |
| 224 | export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } { |
| 225 | let unread = 0; |
| 226 | let mentions = 0; |
| 227 | for (const entry of entries) { |
| 228 | mentions += entry.mentions; |
| 229 | if (!entry.muted) unread += entry.unread; |
| 230 | } |
| 231 | return { unread, mentions }; |
| 232 | } |
| 233 | |
| 234 | /** Channel names: lowercase words and digits joined by dashes, as people type them. */ |
| 235 | export function channelName(typed: string): string { |
| 236 | return typed |
| 237 | .toLowerCase() |
| 238 | .replace(/^#/, "") |
| 239 | .replace(/[\s_]+/g, "-") |
| 240 | .replace(/[^a-z0-9-]/g, "") |
| 241 | .replace(/-{2,}/g, "-") |
| 242 | .slice(0, 80); |
| 243 | } |
| 244 | |
| 245 | /** Paths under `-/chat/` that are pages, not channels: never a channel's name. */ |
| 246 | export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const; |
| 247 | |
| 248 | /** A principal as a form field carries it: `user:<username>`. */ |
| 249 | export function principalField(kind: Principal["kind"], key: string): string { |
| 250 | return `${kind}:${key}`; |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * A member's handle as it shows after `@`: a person's username in its |
| 255 | * chosen case (an old agent's handle). The same rule as `memberHandle` in |
| 256 | * @g1t/contracts, kept here so this file stays pure for its tests. |
| 257 | */ |
| 258 | export function shownHandle(member: { name: string; display_username?: string | null }): string { |
| 259 | const display = member.display_username; |
| 260 | return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name; |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * The name shown for a member, everywhere in chat (messages, the sidebar, |
| 265 | * typing, cards): their display name, else their handle in its chosen |
| 266 | * case. The same rule as `memberName` in @g1t/contracts, which the chat |
| 267 | * service and its notifications follow. |
| 268 | */ |
| 269 | export function shownName(member: Pick<MemberProfile, "display_name" | "name"> & { display_username?: string | null }): string { |
| 270 | return member.display_name?.trim() || shownHandle(member); |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Who each lowercased handle is, for drawing `@name` as the name people |
| 275 | * know: the workspace's people. |
| 276 | */ |
| 277 | export function mentionNames(people: readonly Mentionable[]): Map<string, string> { |
| 278 | const names = new Map<string, string>(); |
| 279 | for (const person of people) names.set(person.name.toLowerCase(), shownName(person)); |
| 280 | return names; |
| 281 | } |
| 282 | |
| 283 | /** Someone shown as typing. */ |
| 284 | export type Typer = { member: Pick<MemberProfile, "display_name" | "name"> & { display_username?: string | null } }; |
| 285 | |
| 286 | /** The typing line: who, and that they are typing. Null when nobody is. */ |
| 287 | export function typingLabel(typers: readonly Typer[]): { who: string; doing: string } | null { |
| 288 | if (!typers.length) return null; |
| 289 | const names = typers.map((t) => shownName(t.member)); |
| 290 | const who = names.length === 1 ? names[0]! : names.length === 2 ? `${names[0]} and ${names[1]}` : "Several people"; |
| 291 | return { who, doing: typers.length === 1 ? "is typing" : "are typing" }; |
| 292 | } |
| 293 | |
| 294 | /** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */ |
| 295 | export function backoff(attempt: number, random: () => number = Math.random): number { |
| 296 | const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt)); |
| 297 | return Math.round(base * (0.75 + random() * 0.5)); |
| 298 | } |