Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 1 | import type { Channel, ChatLiveEvent, ChatMessage, ChatSidebar, MemberProfile, MessagePage } from "@g1t/contracts"; |
| 2 | import { type InfiniteData, type QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 4 | ||
| 5 | import type { Client } from "./client"; | |
| 6 | import { openSocket } from "./live"; | |
| 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>[&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 | ||
| 23 | const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`; | |
| 24 | ||
| 25 | export 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. */ | |
| 33 | export type Shown = ChatMessage & { local?: "sending" | "failed" }; | |
| 34 | ||
| 35 | type Pages = InfiniteData<MessagePage & { messages: Shown[] }, string | null>; | |
| 36 | ||
| 37 | /** Posts an intent to the chat route and returns its value. */ | |
| 38 | export 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 | ||
| 44 | export 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`). */ | |
| 55 | export 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 | ||
| 65 | export 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 | ||
| 84 | function 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. */ | |
| 89 | export 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 | ||
| 113 | function 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 | ||
| 119 | function 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. */ | |
| 126 | function 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 | */ | |
| 154 | function 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 | ||
| 167 | let 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 | */ | |
| 174 | export 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. */ | |
| 219 | export 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. */ | |
| 262 | export 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 | ||
| 269 | export 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 | ||
| 295 | export type LiveStatus = "connecting" | "live" | "offline"; | |
| 296 | ||
| 297 | /** Who is typing, until when. */ | |
| 298 | type 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 | */ | |
| 305 | export 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.