Skip to content
167 linesCodeBlameRaw
1import type { ChatLiveEvent, ChatMessage, ChatSidebar, MessagePage } from "@g1t/contracts";
2import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useState } from "react";
4
5import type { Client } from "./client";
6import { socketUrl } from "./server";
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>&before=<id>`: a page of messages.
15 * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: post, read, react.
16 * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens, live.
17 * A socket cannot carry a header, so it asks `GET /-/live/ticket` for a
18 * ticket first (apps/web/app/lib/socket-ticket.ts).
19 */
20
21const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`;
22
23export function useChatSidebar() {
24 const { client, workspace } = useSignedIn();
25 const slug = workspace?.slug ?? "";
26 return useQuery({
27 queryKey: ["chat", slug, "sidebar"],
28 queryFn: () => client.site<ChatSidebar>(chatPath(slug)),
29 enabled: !!slug,
30 });
31}
32
33export function useMessages(channelId: string, thread?: string | null) {
34 const { client, workspace } = useSignedIn();
35 const slug = workspace?.slug ?? "";
36 return useInfiniteQuery({
37 queryKey: ["chat", slug, "messages", channelId, thread ?? null],
38 initialPageParam: null as string | null,
39 queryFn: ({ pageParam }) => {
40 const query = new URLSearchParams({ channel: channelId, limit: "60" });
41 if (thread) query.set("thread", thread);
42 if (pageParam) query.set("before", pageParam);
43 return client.site<MessagePage>(`${chatPath(slug)}?${query}`);
44 },
45 getNextPageParam: (page) => page.older,
46 enabled: !!slug && !!channelId,
47 });
48}
49
50/** Sends a message; it appears through the live socket, or on the next read. */
51export function usePostMessage(channelId: string, thread?: string | null) {
52 const { client, workspace } = useSignedIn();
53 const queries = useQueryClient();
54 const slug = workspace?.slug ?? "";
55 return useMutation({
56 mutationFn: (body: string) =>
57 client.site<ChatMessage>(chatPath(slug), {
58 method: "POST",
59 body: JSON.stringify({ intent: "post", channel_id: channelId, body, thread_root: thread ?? null }),
60 }),
61 onSuccess: (message) => addMessage(queries, slug, message),
62 });
63}
64
65/** Marks a channel read up to a message. */
66export function markRead(client: Client, workspace: string, channelId: string, messageId: string) {
67 return client
68 .site(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent: "read", channel_id: channelId, id: messageId }) })
69 .catch(() => undefined);
70}
71
72type Pages = InfiniteData<MessagePage, string | null>;
73
74/** Puts a message into the newest page of its channel's cache, once. */
75function addMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) {
76 queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) => {
77 if (!data) return data;
78 if (data.pages.some((page) => page.messages.some((one) => one.id === message.id))) return data;
79 const [newest, ...rest] = data.pages;
80 return { ...data, pages: [{ ...newest, messages: [...newest.messages, message] }, ...rest] };
81 });
82 void queries.invalidateQueries({ queryKey: ["chat", workspace, "sidebar"] });
83}
84
85function replaceMessage(queries: ReturnType<typeof useQueryClient>, workspace: string, message: ChatMessage) {
86 queries.setQueryData<Pages>(["chat", workspace, "messages", message.channel_id, message.thread_root ?? null], (data) =>
87 data
88 ? { ...data, pages: data.pages.map((page) => ({ ...page, messages: page.messages.map((one) => (one.id === message.id ? message : one)) })) }
89 : data,
90 );
91}
92
93export type LiveStatus = "connecting" | "live" | "offline";
94
95/**
96 * Keeps a channel's messages live while its screen is open: opens the
97 * channel's socket with a ticket, applies what it says to the cache, and
98 * opens it again (with a new ticket) when it drops.
99 */
100export function useChannelLive(channelId: string): LiveStatus {
101 const { client, workspace } = useSignedIn();
102 const queries = useQueryClient();
103 const slug = workspace?.slug ?? "";
104 const [status, setStatus] = useState<LiveStatus>("connecting");
105
106 useEffect(() => {
107 if (!slug || !channelId) return;
108 let socket: WebSocket | null = null;
109 let closed = false;
110 let retry: ReturnType<typeof setTimeout> | null = null;
111 let attempts = 0;
112
113 const open = async () => {
114 setStatus("connecting");
115 const path = `/${encodeURIComponent(slug)}/-/chat/live`;
116 try {
117 const token = await client.token();
118 const response = await fetch(`${client.server.site}/-/live/ticket?path=${encodeURIComponent(path)}`, {
119 headers: { authorization: `Bearer ${token}`, accept: "application/json" },
120 });
121 if (!response.ok) throw new Error(`ticket ${response.status}`);
122 const { ticket } = (await response.json()) as { ticket: string };
123 if (closed) return;
124 const query = new URLSearchParams({ channel: channelId, ticket });
125 socket = new WebSocket(`${socketUrl(client.server, path)}?${query}`);
126 } catch {
127 return again();
128 }
129 socket.onopen = () => {
130 attempts = 0;
131 setStatus("live");
132 };
133 socket.onmessage = (event) => {
134 let live: ChatLiveEvent;
135 try {
136 live = JSON.parse(String(event.data)) as ChatLiveEvent;
137 } catch {
138 return;
139 }
140 if (live.type === "message.created") addMessage(queries, slug, live.message);
141 else if (live.type === "message.updated") replaceMessage(queries, slug, live.message);
142 else if (live.type === "message.deleted" || live.type.startsWith("reaction.")) {
143 void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] });
144 }
145 };
146 socket.onclose = () => {
147 if (!closed) again();
148 };
149 };
150
151 const again = () => {
152 setStatus("offline");
153 if (closed) return;
154 attempts += 1;
155 retry = setTimeout(open, Math.min(30_000, 1_000 * 2 ** Math.min(attempts, 5)));
156 };
157
158 void open();
159 return () => {
160 closed = true;
161 if (retry) clearTimeout(retry);
162 socket?.close();
163 };
164 }, [client, queries, slug, channelId]);
165
166 return status;
167}