Skip to content
430 linesCodeBlameRaw
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 */
10import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal, WorkspaceAgent } from "@g1t/contracts";
11
12/** Two messages from the same author this close together share one header. */
13export const GROUP_GAP_MS = 5 * 60 * 1000;
14
15/** A message on its way: shown at once, then swapped for what the service saved. */
16export type ShownMessage = ChatMessage & { pending?: boolean; failed?: boolean; client_id?: string };
17
18export type TimelineRow =
19 | { kind: "day"; key: string; label: string }
20 | { kind: "message"; key: string; message: ShownMessage; head: boolean };
21
22function 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`). */
27export 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. */
33export 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 */
54export 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 */
83export 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, an agent by handle. */
97export type Mentionable = {
98 kind: "user" | "agent";
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 /** An agent's role, shown beside it. */
106 role?: string | null;
107};
108
109export type MentionQuery = { start: number; typed: string; options: Mentionable[] };
110
111/** Most suggestions at once. */
112export const MAX_MENTIONS = 6;
113
114/**
115 * The `@…` just before the caret and who it could be: names that start with
116 * what is typed first, then display names with a word that does. Null when
117 * the caret is not in a mention, or nothing fits.
118 */
119export function mentionQuery(text: string, caret: number, people: readonly Mentionable[]): MentionQuery | null {
120 const match = /(?:^|[\s(])@([\w.-]{0,40})$/.exec(text.slice(0, caret));
121 if (!match) return null;
122 const typed = match[1]!.toLowerCase();
123 const start = caret - match[1]!.length - 1;
124 const first: Mentionable[] = [];
125 const then: Mentionable[] = [];
126 for (const person of people) {
127 const name = person.name.toLowerCase();
128 if (name === typed) continue;
129 if (name.startsWith(typed)) first.push(person);
130 else if (typed && person.display_name.toLowerCase().split(/\s+/).some((word) => word.startsWith(typed))) then.push(person);
131 }
132 const options = [...first, ...then].slice(0, MAX_MENTIONS);
133 return options.length > 0 ? { start, typed, options } : null;
134}
135
136// ---------------------------------------------------------------------------
137// A message's text: its Markdown, parsed by the rule every part of g1t reads it with.
138
139export { type Block, type Span, blocks, inline, plainText, safeHref, spansText } from "@g1t/contracts/chat-markdown";
140
141/** Whether a message is only emoji, a few of them: shown larger, as people expect. */
142export function onlyEmoji(text: string): boolean {
143 const trimmed = text.trim();
144 if (!trimmed || trimmed.length > 24) return false;
145 return /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|‍|️|\s)+$/u.test(trimmed) && /\p{Extended_Pictographic}/u.test(trimmed);
146}
147
148// ---------------------------------------------------------------------------
149// The sidebar.
150
151export type ChatFilter = "all" | "unread" | "mentions";
152
153/** The sidebar's rows that pass the filter and the "Jump to" text. */
154/**
155 * The conversations the sidebar lists, by filter and by what is typed in
156 * Jump to. A direct message with archived or deleted agents alone is not
157 * listed on its own: it is found by typing the agent's name, and stays
158 * while it is the conversation that is open (`current`, its channel id).
159 */
160export function filterEntries(entries: ChatSidebarEntry[], filter: ChatFilter, query: string, current: string | null = null): ChatSidebarEntry[] {
161 const q = query.trim().toLowerCase().replace(/^[#@]/, "");
162 return entries.filter((entry) => {
163 if (filter === "unread" && entry.unread === 0 && entry.mentions === 0) return false;
164 if (filter === "mentions" && entry.mentions === 0) return false;
165 if (!q) return entry.channel.id === current || !archivedAgentsDm(entry);
166 if (entry.title.toLowerCase().includes(q)) return true;
167 return entry.others.some((member) => member.name.toLowerCase().includes(q) || member.display_name.toLowerCase().includes(q));
168 });
169}
170
171/** The agent a conversation is a direct message with, when it is with one agent alone; else null. */
172export function agentDmOf(entry: ChatSidebarEntry): string | null {
173 const only = entry.others.length === 1 ? entry.others[0]! : null;
174 return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null;
175}
176
177type Members = Pick<MemberProfile, "kind">[];
178
179/** Whether a direct message is with agents alone (one or several, nobody else). */
180export function agentsOnlyDm(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind">[] }): boolean {
181 return entry.channel.kind === "dm" && entry.others.length > 0 && entry.others.every((member) => member.kind === "agent");
182}
183
184/**
185 * Whether a direct message is mixed: at least one person besides you and
186 * at least one agent in it. Such a conversation lists under People (a
187 * person is in it) and says, everywhere it shows, that an agent is too.
188 */
189export function mixedDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean {
190 return entry.channel.kind === "dm" && isMixed(entry.others);
191}
192
193/** Whether the others in a conversation are a person or more and an agent or more. */
194export function isMixed(others: Members): boolean {
195 return others.some((member) => member.kind === "agent") && others.some((member) => member.kind === "user");
196}
197
198/** The agents among a conversation's other members. */
199export function agentsAmong<M extends Pick<MemberProfile, "kind">>(others: readonly M[]): M[] {
200 return others.filter((member) => member.kind === "agent");
201}
202
203/**
204 * The composer's placeholder in a direct message: "Message @mike and @sam
205 * (agent)", each agent said to be one, so nobody writes to a person and
206 * reaches a bot unawares. A conversation with yourself alone is for notes.
207 */
208export function dmPlaceholder(others: readonly Pick<MemberProfile, "kind" | "name" | "display_username">[]): string {
209 if (others.length === 0) return "Message yourself";
210 const names = others.map((member) => `@${shownHandle(member)}${member.kind === "agent" ? " (agent)" : ""}`);
211 const list = names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
212 return `Message ${list}`;
213}
214
215/**
216 * The one-line notice above a mixed conversation's first message, the
217 * first time a person opens it: who the agents are and that they read and
218 * can act on what is said.
219 */
220export function mixedNotice(others: readonly Pick<MemberProfile, "kind" | "name" | "display_name" | "display_username">[]): string {
221 const agents = agentsAmong(others).map(shownName);
222 if (agents.length === 0) return "";
223 if (agents.length === 1) return `${agents[0]} is an agent and reads everything here; it can act on what you say.`;
224 const list = `${agents.slice(0, -1).join(", ")} and ${agents[agents.length - 1]}`;
225 return `${list} are agents and read everything here; they can act on what you say.`;
226}
227
228/**
229 * The sidebar's sections, in the order they show: what is pinned (the
230 * `starred` preference), channels, then direct messages in two buckets,
231 * each the latest first: People, every conversation with a person in it
232 * (a mixed one too, since a person is in it), and Agents, conversations
233 * with agents alone. `agentDms` finds the direct message with each agent
234 * on its own, by agent id, so the Agents section lists only the agents
235 * you have not talked to yet.
236 */
237export function sections(entries: ChatSidebarEntry[]): {
238 pinned: ChatSidebarEntry[];
239 channels: ChatSidebarEntry[];
240 agentDms: Map<string, ChatSidebarEntry>;
241 people: ChatSidebarEntry[];
242 agents: ChatSidebarEntry[];
243} {
244 const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title);
245 // Direct messages: the latest conversation first, as people scan them.
246 const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) =>
247 (b.channel.last_message_at ?? b.channel.created_at).localeCompare(a.channel.last_message_at ?? a.channel.created_at);
248 const agentDms = new Map<string, ChatSidebarEntry>();
249 for (const entry of entries) {
250 const agent = agentDmOf(entry);
251 if (agent) agentDms.set(agent, entry);
252 }
253 const dms = entries.filter((e) => !e.starred && e.channel.kind === "dm");
254 return {
255 pinned: entries.filter((e) => e.starred).sort(byName),
256 channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName),
257 agentDms,
258 people: dms.filter((e) => !agentsOnlyDm(e)).sort(byRecent),
259 agents: dms.filter(agentsOnlyDm).sort(byRecent),
260 };
261}
262
263/**
264 * The archived (or deleted) agent a direct message is with, when the
265 * conversation is with agents alone and every one of them is archived or
266 * deleted for good (docs.g1t.sh/guides/chat/, "Archived and deleted
267 * agents"): the row is dimmed and says Archived (or Deleted, when the
268 * agent returned is `deleted`), and a conversation with one agent opens
269 * read-only. Null for any other conversation, a mixed one included, since
270 * a person is still there to talk to.
271 */
272export function archivedAgentsDm<M extends Pick<MemberProfile, "kind" | "archived_at" | "deleted">>(entry: { channel: { kind: "channel" | "dm" }; others: readonly M[] }): M | null {
273 if (!agentsOnlyDm(entry)) return null;
274 return entry.others.every((member) => !!member.archived_at || member.deleted === true) ? (entry.others[0] ?? null) : null;
275}
276
277/**
278 * The agents you have not talked to yet, for the sidebar's Agents section:
279 * live ones (an archived agent is never offered, though its old conversation
280 * stays listed), with no direct message yet, g1t first, then by name,
281 * narrowed by what is typed in Jump to (handle, name or role).
282 */
283export function agentsToTalkTo<A extends Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "role" | "builtin" | "archived_at">>(agents: readonly A[], agentDms: ReadonlyMap<string, unknown>, query = ""): A[] {
284 const q = query.trim().toLowerCase().replace(/^@/, "");
285 return agents
286 .filter((agent) => !agent.archived_at && !agentDms.has(agent.id))
287 .sort((a, b) => Number(!!b.builtin) - Number(!!a.builtin) || a.display_name.localeCompare(b.display_name))
288 .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q));
289}
290
291/**
292 * What an archived agent's conversation says in place of its composer:
293 * who archived it and when, as far as that is known.
294 */
295export function archivedAgentLine(agent: Pick<MemberProfile, "name" | "display_name" | "display_username" | "archived_at" | "archived_by">, now: Date = new Date()): string {
296 return `${shownName(agent)} was ${archivedAgentWhen(agent, now).replace(/^A/, "a")}. It no longer answers here.`;
297}
298
299/** "Archived 2 days ago by @ana": under an archived agent's name, where its title was. */
300export function archivedAgentWhen(agent: Pick<MemberProfile, "archived_at" | "archived_by">, now: Date = new Date()): string {
301 const when = agent.archived_at ? ` ${timeSince(agent.archived_at, now)}` : "";
302 const by = agent.archived_by ? ` by @${agent.archived_by}` : "";
303 return `Archived${when}${by}`;
304}
305
306/** "3 days ago", "2 hours ago", "just now": for a line of prose. */
307export function timeSince(at: string, now: Date = new Date()): string {
308 const elapsed = Math.max(0, now.getTime() - new Date(at).getTime());
309 const units: [string, number][] = [
310 ["day", 86_400_000],
311 ["hour", 3_600_000],
312 ["minute", 60_000],
313 ];
314 for (const [unit, size] of units) {
315 if (elapsed >= size) {
316 const n = Math.floor(elapsed / size);
317 return `${n} ${unit}${n === 1 ? "" : "s"} ago`;
318 }
319 }
320 return "just now";
321}
322
323/** Where a conversation is, under the workspace. */
324export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string {
325 return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
326}
327
328/** Unread across the sidebar, leaving out what is muted: the rail's badge. */
329export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } {
330 let unread = 0;
331 let mentions = 0;
332 for (const entry of entries) {
333 mentions += entry.mentions;
334 if (!entry.muted) unread += entry.unread;
335 }
336 return { unread, mentions };
337}
338
339/** Channel names: lowercase words and digits joined by dashes, as people type them. */
340export function channelName(typed: string): string {
341 return typed
342 .toLowerCase()
343 .replace(/^#/, "")
344 .replace(/[\s_]+/g, "-")
345 .replace(/[^a-z0-9-]/g, "")
346 .replace(/-{2,}/g, "-")
347 .slice(0, 80);
348}
349
350/** Paths under `-/chat/` that are pages, not channels: never a channel's name. */
351export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const;
352
353/** A principal as a form field carries it: `user:<username>` or `agent:<id>`. */
354export function principalField(kind: Principal["kind"], key: string): string {
355 return `${kind}:${key}`;
356}
357
358/**
359 * A member's handle as it shows after `@`: a person's username in its
360 * chosen case, an agent's handle. The same rule as `memberHandle` in
361 * @g1t/contracts, kept here so this file stays pure for its tests.
362 */
363export function shownHandle(member: { name: string; display_username?: string | null }): string {
364 const display = member.display_username;
365 return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name;
366}
367
368/**
369 * The name shown for a member, everywhere in chat (messages, the sidebar,
370 * typing, cards): their display name, else their handle in its chosen
371 * case. The same rule as `memberName` in @g1t/contracts, which the chat
372 * service and its notifications follow.
373 */
374export function shownName(member: Pick<MemberProfile, "display_name" | "name"> & { display_username?: string | null }): string {
375 return member.display_name?.trim() || shownHandle(member);
376}
377
378/**
379 * Who each lowercased handle is, for drawing `@name` as the name people
380 * know: the workspace's people and agents.
381 */
382export function mentionNames(people: readonly Mentionable[]): Map<string, string> {
383 const names = new Map<string, string>();
384 for (const person of people) names.set(person.name.toLowerCase(), shownName(person));
385 return names;
386}
387
388/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
389const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"];
390
391export function agentDoing(id: string): string {
392 let hash = 0;
393 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
394 return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
395}
396
397/**
398 * After this long shown as typing, an agent is said to be still working
399 * instead: a reply that reads code, consults a colleague or starts a
400 * session takes a minute or more, and it keeps saying so the whole time.
401 */
402export const WORKING_AFTER_MS = 10_000;
403
404/** Someone shown as typing, and since when (the first typing event for them, kept across the ones that follow). */
405export type Typer = { member: Pick<MemberProfile, "kind" | "id" | "display_name" | "name"> & { display_username?: string | null }; since: number };
406
407/**
408 * The typing line: who, and what they are doing. A person is typing. An
409 * agent is doing what its phrase says, then, past `WORKING_AFTER_MS`,
410 * still working on it; several agents are on it together, and a mix of
411 * people and agents are typing. Null when nobody is.
412 */
413export function typingLabel(typers: readonly Typer[], now: number): { who: string; doing: string } | null {
414 if (!typers.length) return null;
415 const names = typers.map((t) => shownName(t.member));
416 const agents = typers.every((t) => t.member.kind === "agent");
417 const who = names.length === 1 ? names[0]! : names.length === 2 ? `${names[0]} and ${names[1]}` : agents ? "Several agents" : "Several people";
418 const long = typers.some((t) => now - t.since >= WORKING_AFTER_MS);
419 if (typers.length === 1) {
420 const [one] = typers;
421 return { who, doing: one!.member.kind === "agent" ? (long ? "is still working on it" : agentDoing(one!.member.id)) : "is typing" };
422 }
423 return { who, doing: agents ? (long ? "are still working on it" : "are on it") : "are typing" };
424}
425
426/** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */
427export function backoff(attempt: number, random: () => number = Math.random): number {
428 const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
429 return Math.round(base * (0.75 + random() * 0.5));
430}