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.

390 linesCodeBlameRaw
1import type { Channel, ChatLiveEvent, ChatMessage, ChatSidebar, MemberProfile, MessagePage } from "@g1t/contracts";
2import { type InfiniteData, type QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useEffect, useRef, useState } from "react";
4
5import type { Client } from "./client";
6import { openSocket } from "./live";
7import { useSignedIn } from "./session";
8
9/**
10 * Chat, through the site's routes, as the site's own Chat pages use them
11 * (apps/web/app/routes/workspace/chat/api.ts):
12 *
13 * - `GET /<workspace>/-/chat/api`: the sidebar.
14 * - `GET /<workspace>/-/chat/api?channel=<id>[&thread=<id>]&before=<id>`: a page of messages.
15 * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: everything sent.
16 * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens in a
17 * conversation, live, and the one thing a client says there: typing.
18 *
19 * The app signs in with `chat:read` and `chat:write`, which the site takes
20 * on these routes alone.
21 */
22
23const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`;
24
25export const chatKeys = {
26 all: (workspace: string) => ["chat", workspace] as const,
27 sidebar: (workspace: string) => ["chat", workspace, "sidebar"] as const,
28 browse: (workspace: string) => ["chat", workspace, "browse"] as const,
29 messages: (workspace: string, channel: string, thread: string | null) => ["chat", workspace, "messages", channel, thread] as const,
30};
31
32/** A message as the app keeps it: a sent one may still be on its way, or have failed. */
33export type Shown = ChatMessage & { local?: "sending" | "failed" };
34
35type Pages = InfiniteData<MessagePage & { messages: Shown[] }, string | null>;
36
37/** Posts an intent to the chat route and returns its value. */
38export function sendIntent<T>(client: Client, workspace: string, intent: string, body: Record<string, unknown>): Promise<T> {
39 return client.site<T>(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent, ...body }) });
40}
41
42// ── Reading ────────────────────────────────────────────────────────────────
43
44export function useChatSidebar() {
45 const { client, workspace } = useSignedIn();
46 const slug = workspace?.slug ?? "";
47 return useQuery({
48 queryKey: chatKeys.sidebar(slug),
49 queryFn: () => client.site<ChatSidebar>(chatPath(slug)),
50 enabled: !!slug,
51 });
52}
53
54/** The workspace's channels, to find and join (`GET …/chat/api?browse=1`). */
55export function useBrowseChannels() {
56 const { client, workspace } = useSignedIn();
57 const slug = workspace?.slug ?? "";
58 return useQuery({
59 queryKey: chatKeys.browse(slug),
60 queryFn: () => client.site<Channel[]>(`${chatPath(slug)}?browse=1`),
61 enabled: !!slug,
62 });
63}
64
65export function useMessages(channelId: string, thread: string | null = null) {
66 const { client, workspace } = useSignedIn();
67 const slug = workspace?.slug ?? "";
68 return useInfiniteQuery({
69 queryKey: chatKeys.messages(slug, channelId, thread),
70 initialPageParam: null as string | null,
71 queryFn: ({ pageParam }) => {
72 const query = new URLSearchParams({ channel: channelId, limit: "50" });
73 if (thread) query.set("thread", thread);
74 if (pageParam) query.set("before", pageParam);
75 return client.site<MessagePage>(`${chatPath(slug)}?${query}`);
76 },
77 getNextPageParam: (page) => page.older,
78 enabled: !!slug && !!channelId,
79 });
80}
81
82// ── The cache, as messages arrive ───────────────────────────────────────────
83
84function updatePages(queries: QueryClient, key: readonly unknown[], change: (messages: Shown[], index: number) => Shown[]) {
85 queries.setQueryData<Pages>(key, (data) =>
86 data ? { ...data, pages: data.pages.map((page, index) => ({ ...page, messages: change(page.messages, index) })) } : data,
87 );
88}
89
90/** A message, put where it belongs: replacing itself, or its local copy, or added as the newest. */
91export function upsertMessage(queries: QueryClient, workspace: string, message: Shown, localId?: string) {
92 const key = chatKeys.messages(workspace, message.channel_id, message.thread_root ?? null);
93 const data = queries.getQueryData<Pages>(key);
94 const had = !!data?.pages.some((page) => page.messages.some((one) => one.id === message.id));
95 const hadLocal = !!localId && !!data?.pages.some((page) => page.messages.some((one) => one.id === localId));
96 if (data) {
97 if (had || hadLocal) {
98 updatePages(queries, key, (messages) => {
99 const replaced = messages.map((one) => (one.id === message.id || (localId && one.id === localId) ? message : one));
100 // A local copy and the real one may both have arrived: keep one.
101 return replaced.filter((one, index) => replaced.findIndex((other) => other.id === one.id) === index);
102 });
103 } else {
104 updatePages(queries, key, (messages, index) => (index === 0 ? [...messages, message] : messages));
105 }
106 }
107 // A reply new to the app also counts on its root, in the conversation.
108 if (message.thread_root && !message.local && !had) {
109 updatePages(queries, chatKeys.messages(workspace, message.channel_id, null), (messages) =>
110 messages.map((one) => (one.id === message.thread_root ? { ...one, reply_count: one.reply_count + 1, last_reply_at: message.created_at } : one)),
111 );
112 }
113}
114
115function removeMessage(queries: QueryClient, workspace: string, channelId: string, id: string) {
116 for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
117 updatePages(queries, key, (messages) => messages.filter((one) => one.id !== id));
118 }
119}
120
121function patchMessage(queries: QueryClient, workspace: string, channelId: string, id: string, change: (message: Shown) => Shown) {
122 for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
123 updatePages(queries, key, (messages) => messages.map((one) => (one.id === id ? change(one) : one)));
124 }
125}
126
127/** A reaction added or taken away, by the viewer (`mine`) or someone else, on a message in the cache. */
128function applyReaction(
129 queries: QueryClient,
130 workspace: string,
131 channelId: string,
132 messageId: string,
133 emoji: string,
134 member: MemberProfile | null,
135 added: boolean,
136 mine: boolean,
137) {
138 patchMessage(queries, workspace, channelId, messageId, (message) => {
139 const reactions = [...(message.reactions ?? [])];
140 const at = reactions.findIndex((one) => one.emoji === emoji);
141 if (at === -1) {
142 if (!added) return message;
143 reactions.push({ emoji, count: 1, me: mine, by: member ? [member] : [] });
144 } else {
145 const one = reactions[at];
146 if (mine && one.me === added) return message;
147 const count = one.count + (added ? 1 : -1);
148 if (count <= 0) reactions.splice(at, 1);
149 else
150 reactions[at] = {
151 ...one,
152 count,
153 me: mine ? added : one.me,
154 by: member ? (added ? [...one.by, member] : one.by.filter((m) => m.id !== member.id)) : one.by,
155 };
156 }
157 return { ...message, reactions };
158 });
159}
160
161/**
162 * Live events carry every reaction's `me` as false, since the whole room
163 * gets them: keep the viewer's own from the copy the app has.
164 */
165function keepMine(queries: QueryClient, workspace: string, message: ChatMessage): ChatMessage {
166 for (const [, data] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", message.channel_id] })) {
167 const had = data?.pages.flatMap((page) => page.messages).find((one) => one.id === message.id);
168 if (had) {
169 const mine = new Set((had.reactions ?? []).filter((one) => one.me).map((one) => one.emoji));
170 return { ...message, reactions: (message.reactions ?? []).map((one) => ({ ...one, me: mine.has(one.emoji) })) };
171 }
172 }
173 return message;
174}
175
176// ── Sending ────────────────────────────────────────────────────────────────
177
178let localCounter = 0;
179
180/**
181 * Sends a message. It shows at once, marked as sending, and becomes the
182 * real one when the site answers (or when the live socket brings it,
183 * whichever is first); a failed one stays, marked, to send again.
184 */
185export function useSend(channelId: string, thread: string | null = null) {
186 const { client, workspace, me } = useSignedIn();
187 const queries = useQueryClient();
188 const slug = workspace?.slug ?? "";
189
190 return useCallback(
191 (body: string, retryOf?: Shown) => {
192 const localId = retryOf?.id ?? `local-${Date.now()}-${localCounter++}`;
193 const local: Shown = {
194 id: localId,
195 channel_id: channelId,
196 author: {
197 kind: "user",
198 id: me.id,
199 name: me.username,
200 display_username: me.display_username ?? null,
201 display_name: me.display_username ?? me.username,
202 avatar: me.avatar ?? null,
203 role: null,
204 } as MemberProfile,
205 kind: "text",
206 body,
207 card: null,
208 thread_root: thread,
209 reply_count: 0,
210 last_reply_at: null,
211 created_at: new Date().toISOString(),
212 edited_at: null,
213 deleted_at: null,
214 reactions: [],
215 local: "sending",
216 };
217 upsertMessage(queries, slug, local, localId);
218 sendIntent<ChatMessage>(client, slug, "post", { channel_id: channelId, body, thread_root: thread })
219 .then((message) => {
220 upsertMessage(queries, slug, message, localId);
221 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
222 })
223 .catch(() => upsertMessage(queries, slug, { ...local, local: "failed" }, localId));
224 },
225 [client, queries, slug, channelId, thread, me],
226 );
227}
228
229/** Everything else a person does to messages and conversations. */
230export function useChatActions(channelId: string) {
231 const { client, workspace } = useSignedIn();
232 const queries = useQueryClient();
233 const slug = workspace?.slug ?? "";
234
235 const react = useMutation({
236 mutationFn: ({ message, emoji, add }: { message: ChatMessage; emoji: string; add: boolean }) =>
237 sendIntent(client, slug, add ? "react" : "unreact", { channel_id: channelId, message_id: message.id, emoji }),
238 onMutate: ({ message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, add, true),
239 onError: (_error, { message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, !add, true),
240 });
241
242 const edit = useMutation({
243 mutationFn: ({ message, body }: { message: ChatMessage; body: string }) =>
244 sendIntent<ChatMessage>(client, slug, "edit", { channel_id: channelId, id: message.id, body }),
245 onSuccess: (message) => upsertMessage(queries, slug, message),
246 });
247
248 const remove = useMutation({
249 mutationFn: (message: ChatMessage) => sendIntent(client, slug, "remove", { channel_id: channelId, id: message.id }),
250 onMutate: (message) => removeMessage(queries, slug, channelId, message.id),
251 onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
252 });
253
254 const preferences = useMutation({
255 mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }),
256 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
257 });
258
259 const leave = useMutation({
260 mutationFn: () => sendIntent(client, slug, "leave", { channel_id: channelId }),
261 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
262 });
263
264 return { react, edit, remove, preferences, leave };
265}
266
267/** Marks a conversation read up to a message; the sidebar's counts follow. */
268export function markRead(client: Client, queries: QueryClient, workspace: string, channelId: string, messageId: string) {
269 queries.setQueryData<ChatSidebar>(chatKeys.sidebar(workspace), (data) =>
270 data ? { ...data, entries: data.entries.map((entry) => (entry.channel.id === channelId ? { ...entry, unread: 0, mentions: 0 } : entry)) } : data,
271 );
272 return sendIntent(client, workspace, "read", { channel_id: channelId, id: messageId }).catch(() => undefined);
273}
274
275export function useStartConversation() {
276 const { client, workspace } = useSignedIn();
277 const queries = useQueryClient();
278 const slug = workspace?.slug ?? "";
279 return {
280 /** Opens (or finds) a direct message with these people: `user:<username>`. */
281 dm: useMutation({
282 mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }),
283 onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
284 }),
285 join: useMutation({
286 mutationFn: (channelId: string) => sendIntent(client, slug, "join", { channel_id: channelId }),
287 onSuccess: () => {
288 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
289 void queries.invalidateQueries({ queryKey: chatKeys.browse(slug) });
290 },
291 }),
292 create: useMutation({
293 mutationFn: (channel: { name: string; topic?: string; private?: boolean }) => sendIntent<{ channel: Channel }>(client, slug, "create_channel", channel),
294 onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
295 }),
296 };
297}
298
299// ── Live ───────────────────────────────────────────────────────────────────
300
301export type LiveStatus = "connecting" | "live" | "offline";
302
303/** Who is typing, until when. */
304type Typing = { member: MemberProfile; until: number };
305
306/**
307 * Keeps a conversation live while its screen is open: applies what its
308 * socket says to the cache, says who is typing, and lets the composer say
309 * the viewer is (at most every 3 s, as the room takes it).
310 */
311export function useChannelLive(channelId: string) {
312 const { client, workspace, me } = useSignedIn();
313 const queries = useQueryClient();
314 const slug = workspace?.slug ?? "";
315 const [status, setStatus] = useState<LiveStatus>("connecting");
316 const [typing, setTyping] = useState<Typing[]>([]);
317 const send = useRef<((frame: unknown) => void) | null>(null);
318 const typedAt = useRef(0);
319
320 useEffect(() => {
321 if (!slug || !channelId) return;
322 const socket = openSocket(
323 client,
324 `/${encodeURIComponent(slug)}/-/chat/live`,
325 { channel: channelId },
326 {
327 onStatus: setStatus,
328 onReconnect: () => void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
329 onMessage: (data) => {
330 const live = data as ChatLiveEvent;
331 switch (live.type) {
332 case "message.created":
333 case "message.updated":
334 upsertMessage(queries, slug, keepMine(queries, slug, live.message));
335 setTyping((now) => now.filter((one) => one.member.id !== live.message.author.id));
336 break;
337 case "message.deleted":
338 removeMessage(queries, slug, live.channel_id, live.id);
339 break;
340 case "message.streaming":
341 // @g1t's answer as it is written: the text so far, in place.
342 updatePages(queries, chatKeys.messages(slug, live.channel_id, null), (messages) =>
343 messages.map((one) => (one.id === live.id ? { ...one, body: live.body, answer: live.answer } : one)),
344 );
345 break;
346 case "reaction.added":
347 case "reaction.removed":
348 if (live.member.id !== me.id)
349 applyReaction(queries, slug, live.channel_id, live.message_id, live.emoji, live.member, live.type === "reaction.added", false);
350 break;
351 case "typing":
352 if (live.member.id === me.id) break;
353 setTyping((now) => [...now.filter((one) => one.member.id !== live.member.id), { member: live.member, until: Date.parse(live.until) }]);
354 break;
355 case "channel.updated":
356 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
357 break;
358 case "channel.cleared":
359 // The person cleared their conversation with @g1t (in the site): nothing in it is left.
360 updatePages(queries, chatKeys.messages(slug, live.channel_id, null), () => []);
361 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
362 break;
363 }
364 },
365 },
366 );
367 send.current = socket.send;
368 return () => {
369 send.current = null;
370 socket.close();
371 };
372 }, [client, queries, slug, channelId, me.id]);
373
374 // Typing lapses on its own.
375 useEffect(() => {
376 if (typing.length === 0) return;
377 const next = Math.min(...typing.map((one) => one.until)) - Date.now();
378 const timer = setTimeout(() => setTyping((now) => now.filter((one) => one.until > Date.now())), Math.max(250, next));
379 return () => clearTimeout(timer);
380 }, [typing]);
381
382 const sayTyping = useCallback(() => {
383 const now = Date.now();
384 if (now - typedAt.current < 3000) return;
385 typedAt.current = now;
386 send.current?.({ type: "typing", channel_id: channelId });
387 }, [channelId]);
388
389 return { status, typing: typing.map((one) => one.member), sayTyping };
390}