| 1 | import type { ChatLiveEvent, ChatMessage, ChatSidebar, MessagePage } from "@g1t/contracts"; |
| 2 | import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useEffect, useState } from "react"; |
| 4 | |
| 5 | import type { Client } from "./client"; |
| 6 | import { socketUrl } from "./server"; |
| 7 | import { 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 | |
| 21 | const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`; |
| 22 | |
| 23 | export 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 | |
| 33 | export 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. */ |
| 51 | export 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. */ |
| 66 | export 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 | |
| 72 | type Pages = InfiniteData<MessagePage, string | null>; |
| 73 | |
| 74 | /** Puts a message into the newest page of its channel's cache, once. */ |
| 75 | function 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 | |
| 85 | function 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 | |
| 93 | export 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 | */ |
| 100 | export 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 | } |