Skip to content
367 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) => (data ? { ...data, pages: data.pages.map((page, index) => ({ ...page, messages: change(page.messages, index) })) } : data));
86}
87
88/** A message, put where it belongs: replacing itself, or its local copy, or added as the newest. */
89export function upsertMessage(queries: QueryClient, workspace: string, message: Shown, localId?: string) {
90 const key = chatKeys.messages(workspace, message.channel_id, message.thread_root ?? null);
91 const data = queries.getQueryData<Pages>(key);
92 const had = !!data?.pages.some((page) => page.messages.some((one) => one.id === message.id));
93 const hadLocal = !!localId && !!data?.pages.some((page) => page.messages.some((one) => one.id === localId));
94 if (data) {
95 if (had || hadLocal) {
96 updatePages(queries, key, (messages) => {
97 const replaced = messages.map((one) => (one.id === message.id || (localId && one.id === localId) ? message : one));
98 // A local copy and the real one may both have arrived: keep one.
99 return replaced.filter((one, index) => replaced.findIndex((other) => other.id === one.id) === index);
100 });
101 } else {
102 updatePages(queries, key, (messages, index) => (index === 0 ? [...messages, message] : messages));
103 }
104 }
105 // A reply new to the app also counts on its root, in the conversation.
106 if (message.thread_root && !message.local && !had) {
107 updatePages(queries, chatKeys.messages(workspace, message.channel_id, null), (messages) =>
108 messages.map((one) => (one.id === message.thread_root ? { ...one, reply_count: one.reply_count + 1, last_reply_at: message.created_at } : one)),
109 );
110 }
111}
112
113function removeMessage(queries: QueryClient, workspace: string, channelId: string, id: string) {
114 for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
115 updatePages(queries, key, (messages) => messages.filter((one) => one.id !== id));
116 }
117}
118
119function patchMessage(queries: QueryClient, workspace: string, channelId: string, id: string, change: (message: Shown) => Shown) {
120 for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
121 updatePages(queries, key, (messages) => messages.map((one) => (one.id === id ? change(one) : one)));
122 }
123}
124
125/** A reaction added or taken away, by the viewer (`mine`) or someone else, on a message in the cache. */
126function applyReaction(queries: QueryClient, workspace: string, channelId: string, messageId: string, emoji: string, member: MemberProfile | null, added: boolean, mine: boolean) {
127 patchMessage(queries, workspace, channelId, messageId, (message) => {
128 const reactions = [...(message.reactions ?? [])];
129 const at = reactions.findIndex((one) => one.emoji === emoji);
130 if (at === -1) {
131 if (!added) return message;
132 reactions.push({ emoji, count: 1, me: mine, by: member ? [member] : [] });
133 } else {
134 const one = reactions[at];
135 if (mine && one.me === added) return message;
136 const count = one.count + (added ? 1 : -1);
137 if (count <= 0) reactions.splice(at, 1);
138 else
139 reactions[at] = {
140 ...one,
141 count,
142 me: mine ? added : one.me,
143 by: member ? (added ? [...one.by, member] : one.by.filter((m) => m.id !== member.id)) : one.by,
144 };
145 }
146 return { ...message, reactions };
147 });
148}
149
150/**
151 * Live events carry every reaction's `me` as false, since the whole room
152 * gets them: keep the viewer's own from the copy the app has.
153 */
154function keepMine(queries: QueryClient, workspace: string, message: ChatMessage): ChatMessage {
155 for (const [, data] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", message.channel_id] })) {
156 const had = data?.pages.flatMap((page) => page.messages).find((one) => one.id === message.id);
157 if (had) {
158 const mine = new Set((had.reactions ?? []).filter((one) => one.me).map((one) => one.emoji));
159 return { ...message, reactions: (message.reactions ?? []).map((one) => ({ ...one, me: mine.has(one.emoji) })) };
160 }
161 }
162 return message;
163}
164
165// ── Sending ────────────────────────────────────────────────────────────────
166
167let localCounter = 0;
168
169/**
170 * Sends a message. It shows at once, marked as sending, and becomes the
171 * real one when the site answers (or when the live socket brings it,
172 * whichever is first); a failed one stays, marked, to send again.
173 */
174export function useSend(channelId: string, thread: string | null = null) {
175 const { client, workspace, me } = useSignedIn();
176 const queries = useQueryClient();
177 const slug = workspace?.slug ?? "";
178
179 return useCallback(
180 (body: string, retryOf?: Shown) => {
181 const localId = retryOf?.id ?? `local-${Date.now()}-${localCounter++}`;
182 const local: Shown = {
183 id: localId,
184 channel_id: channelId,
185 author: {
186 kind: "user",
187 id: me.id,
188 name: me.username,
189 display_username: me.display_username ?? null,
190 display_name: me.display_username ?? me.username,
191 avatar: me.avatar ?? null,
192 role: null,
193 } as MemberProfile,
194 kind: "text",
195 body,
196 card: null,
197 thread_root: thread,
198 reply_count: 0,
199 last_reply_at: null,
200 created_at: new Date().toISOString(),
201 edited_at: null,
202 deleted_at: null,
203 reactions: [],
204 local: "sending",
205 };
206 upsertMessage(queries, slug, local, localId);
207 sendIntent<ChatMessage>(client, slug, "post", { channel_id: channelId, body, thread_root: thread })
208 .then((message) => {
209 upsertMessage(queries, slug, message, localId);
210 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
211 })
212 .catch(() => upsertMessage(queries, slug, { ...local, local: "failed" }, localId));
213 },
214 [client, queries, slug, channelId, thread, me],
215 );
216}
217
218/** Everything else a person does to messages and conversations. */
219export function useChatActions(channelId: string) {
220 const { client, workspace } = useSignedIn();
221 const queries = useQueryClient();
222 const slug = workspace?.slug ?? "";
223
224 const react = useMutation({
225 mutationFn: ({ message, emoji, add }: { message: ChatMessage; emoji: string; add: boolean }) =>
226 sendIntent(client, slug, add ? "react" : "unreact", { channel_id: channelId, message_id: message.id, emoji }),
227 onMutate: ({ message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, add, true),
228 onError: (_error, { message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, !add, true),
229 });
230
231 const edit = useMutation({
232 mutationFn: ({ message, body }: { message: ChatMessage; body: string }) => sendIntent<ChatMessage>(client, slug, "edit", { channel_id: channelId, id: message.id, body }),
233 onSuccess: (message) => upsertMessage(queries, slug, message),
234 });
235
236 const remove = useMutation({
237 mutationFn: (message: ChatMessage) => sendIntent(client, slug, "remove", { channel_id: channelId, id: message.id }),
238 onMutate: (message) => removeMessage(queries, slug, channelId, message.id),
239 onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
240 });
241
242 /** Presses a button on an agent's card: approve, stop, file. */
243 const cardAction = useMutation({
244 mutationFn: ({ message, action, input }: { message: ChatMessage; action: string; input?: string | null }) =>
245 sendIntent<{ ok: boolean; message: string | null }>(client, slug, "card_action", { channel_id: channelId, message_id: message.id, action_id: action, input: input ?? null }),
246 });
247
248 const preferences = useMutation({
249 mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }),
250 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
251 });
252
253 const leave = useMutation({
254 mutationFn: () => sendIntent(client, slug, "leave", { channel_id: channelId }),
255 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
256 });
257
258 return { react, edit, remove, cardAction, preferences, leave };
259}
260
261/** Marks a conversation read up to a message; the sidebar's counts follow. */
262export function markRead(client: Client, queries: QueryClient, workspace: string, channelId: string, messageId: string) {
263 queries.setQueryData<ChatSidebar>(chatKeys.sidebar(workspace), (data) =>
264 data ? { ...data, entries: data.entries.map((entry) => (entry.channel.id === channelId ? { ...entry, unread: 0, mentions: 0 } : entry)) } : data,
265 );
266 return sendIntent(client, workspace, "read", { channel_id: channelId, id: messageId }).catch(() => undefined);
267}
268
269export function useStartConversation() {
270 const { client, workspace } = useSignedIn();
271 const queries = useQueryClient();
272 const slug = workspace?.slug ?? "";
273 return {
274 /** Opens (or finds) a direct message with these members: `user:<username>` or `agent:<id>`. */
275 dm: useMutation({
276 mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }),
277 onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
278 }),
279 join: useMutation({
280 mutationFn: (channelId: string) => sendIntent(client, slug, "join", { channel_id: channelId }),
281 onSuccess: () => {
282 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
283 void queries.invalidateQueries({ queryKey: chatKeys.browse(slug) });
284 },
285 }),
286 create: useMutation({
287 mutationFn: (channel: { name: string; topic?: string; private?: boolean }) => sendIntent<{ channel: Channel }>(client, slug, "create_channel", channel),
288 onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
289 }),
290 };
291}
292
293// ── Live ───────────────────────────────────────────────────────────────────
294
295export type LiveStatus = "connecting" | "live" | "offline";
296
297/** Who is typing, until when. */
298type Typing = { member: MemberProfile; until: number };
299
300/**
301 * Keeps a conversation live while its screen is open: applies what its
302 * socket says to the cache, says who is typing, and lets the composer say
303 * the viewer is (at most every 3 s, as the room takes it).
304 */
305export function useChannelLive(channelId: string) {
306 const { client, workspace, me } = useSignedIn();
307 const queries = useQueryClient();
308 const slug = workspace?.slug ?? "";
309 const [status, setStatus] = useState<LiveStatus>("connecting");
310 const [typing, setTyping] = useState<Typing[]>([]);
311 const send = useRef<((frame: unknown) => void) | null>(null);
312 const typedAt = useRef(0);
313
314 useEffect(() => {
315 if (!slug || !channelId) return;
316 const socket = openSocket(client, `/${encodeURIComponent(slug)}/-/chat/live`, { channel: channelId }, {
317 onStatus: setStatus,
318 onReconnect: () => void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
319 onMessage: (data) => {
320 const live = data as ChatLiveEvent;
321 switch (live.type) {
322 case "message.created":
323 case "message.updated":
324 upsertMessage(queries, slug, keepMine(queries, slug, live.message));
325 setTyping((now) => now.filter((one) => one.member.id !== live.message.author.id));
326 break;
327 case "message.deleted":
328 removeMessage(queries, slug, live.channel_id, live.id);
329 break;
330 case "reaction.added":
331 case "reaction.removed":
332 if (live.member.id !== me.id) applyReaction(queries, slug, live.channel_id, live.message_id, live.emoji, live.member, live.type === "reaction.added", false);
333 break;
334 case "typing":
335 if (live.member.id === me.id) break;
336 setTyping((now) => [...now.filter((one) => one.member.id !== live.member.id), { member: live.member, until: Date.parse(live.until) }]);
337 break;
338 case "channel.updated":
339 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
340 break;
341 }
342 },
343 });
344 send.current = socket.send;
345 return () => {
346 send.current = null;
347 socket.close();
348 };
349 }, [client, queries, slug, channelId, me.id]);
350
351 // Typing lapses on its own.
352 useEffect(() => {
353 if (typing.length === 0) return;
354 const next = Math.min(...typing.map((one) => one.until)) - Date.now();
355 const timer = setTimeout(() => setTyping((now) => now.filter((one) => one.until > Date.now())), Math.max(250, next));
356 return () => clearTimeout(timer);
357 }, [typing]);
358
359 const sayTyping = useCallback(() => {
360 const now = Date.now();
361 if (now - typedAt.current < 3000) return;
362 typedAt.current = now;
363 send.current?.({ type: "typing", channel_id: channelId });
364 }, [channelId]);
365
366 return { status, typing: typing.map((one) => one.member), sayTyping };
367}