Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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