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 | const preferences = useMutation({ | |
| 243 | mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }), | |
| 244 | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 245 | }); | |
| 246 | ||
| 247 | const leave = useMutation({ | |
| 248 | mutationFn: () => sendIntent(client, slug, "leave", { channel_id: channelId }), | |
| 249 | onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 250 | }); | |
| 251 | ||
| The phone app no longer has g1t's own agents, to match the site. The Agents tab is gone from the bar, and Customize (skills, connectors and plugins) is gone with its row in More. New message finds and messages people only, and its search and heading say so. A card an agent posted earlier still shows with its title, state, details and links, but its other buttons are no longer drawn, since chat no longer answers them; an old agent's messages and direct messages still show, under the name chat gives them, marked Deleted agent. The app stops asking for agents:run when it signs in. An old g1t:// link to Agents, the skill library, Customize or the Marketplace opens Home, while the integrations directory opens on the site. Notifications, the sign-in screen and the access-token error no longer mention agents waiting on you or agent tokens, and the sign-in screen speaks of reviewing what you and your coding agents make. Pull requests still show the coding agent they were made with. | 252 | return { react, edit, remove, preferences, leave }; |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 253 | } |
| 254 | ||
| 255 | /** Marks a conversation read up to a message; the sidebar's counts follow. */ | |
| 256 | export function markRead(client: Client, queries: QueryClient, workspace: string, channelId: string, messageId: string) { | |
| 257 | queries.setQueryData<ChatSidebar>(chatKeys.sidebar(workspace), (data) => | |
| 258 | data ? { ...data, entries: data.entries.map((entry) => (entry.channel.id === channelId ? { ...entry, unread: 0, mentions: 0 } : entry)) } : data, | |
| 259 | ); | |
| 260 | return sendIntent(client, workspace, "read", { channel_id: channelId, id: messageId }).catch(() => undefined); | |
| 261 | } | |
| 262 | ||
| 263 | export function useStartConversation() { | |
| 264 | const { client, workspace } = useSignedIn(); | |
| 265 | const queries = useQueryClient(); | |
| 266 | const slug = workspace?.slug ?? ""; | |
| 267 | return { | |
| The phone app no longer has g1t's own agents, to match the site. The Agents tab is gone from the bar, and Customize (skills, connectors and plugins) is gone with its row in More. New message finds and messages people only, and its search and heading say so. A card an agent posted earlier still shows with its title, state, details and links, but its other buttons are no longer drawn, since chat no longer answers them; an old agent's messages and direct messages still show, under the name chat gives them, marked Deleted agent. The app stops asking for agents:run when it signs in. An old g1t:// link to Agents, the skill library, Customize or the Marketplace opens Home, while the integrations directory opens on the site. Notifications, the sign-in screen and the access-token error no longer mention agents waiting on you or agent tokens, and the sign-in screen speaks of reviewing what you and your coding agents make. Pull requests still show the coding agent they were made with. | 268 | /** Opens (or finds) a direct message with these people: `user:<username>`. */ |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 269 | dm: useMutation({ |
| 270 | mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }), | |
| 271 | onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 272 | }), | |
| 273 | join: useMutation({ | |
| 274 | mutationFn: (channelId: string) => sendIntent(client, slug, "join", { channel_id: channelId }), | |
| 275 | onSuccess: () => { | |
| 276 | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 277 | void queries.invalidateQueries({ queryKey: chatKeys.browse(slug) }); | |
| 278 | }, | |
| 279 | }), | |
| 280 | create: useMutation({ | |
| 281 | mutationFn: (channel: { name: string; topic?: string; private?: boolean }) => sendIntent<{ channel: Channel }>(client, slug, "create_channel", channel), | |
| 282 | onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }), | |
| 283 | }), | |
| 284 | }; | |
| 285 | } | |
| 286 | ||
| 287 | // ── Live ─────────────────────────────────────────────────────────────────── | |
| 288 | ||
| 289 | export type LiveStatus = "connecting" | "live" | "offline"; | |
| 290 | ||
| 291 | /** Who is typing, until when. */ | |
| 292 | type Typing = { member: MemberProfile; until: number }; | |
| 293 | ||
| 294 | /** | |
| 295 | * Keeps a conversation live while its screen is open: applies what its | |
| 296 | * socket says to the cache, says who is typing, and lets the composer say | |
| 297 | * the viewer is (at most every 3 s, as the room takes it). | |
| 298 | */ | |
| 299 | export function useChannelLive(channelId: string) { | |
| 300 | const { client, workspace, me } = useSignedIn(); | |
| 301 | const queries = useQueryClient(); | |
| 302 | const slug = workspace?.slug ?? ""; | |
| 303 | const [status, setStatus] = useState<LiveStatus>("connecting"); | |
| 304 | const [typing, setTyping] = useState<Typing[]>([]); | |
| 305 | const send = useRef<((frame: unknown) => void) | null>(null); | |
| 306 | const typedAt = useRef(0); | |
| 307 | ||
| 308 | useEffect(() => { | |
| 309 | if (!slug || !channelId) return; | |
| 310 | const socket = openSocket(client, `/${encodeURIComponent(slug)}/-/chat/live`, { channel: channelId }, { | |
| 311 | onStatus: setStatus, | |
| 312 | onReconnect: () => void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }), | |
| 313 | onMessage: (data) => { | |
| 314 | const live = data as ChatLiveEvent; | |
| 315 | switch (live.type) { | |
| 316 | case "message.created": | |
| 317 | case "message.updated": | |
| 318 | upsertMessage(queries, slug, keepMine(queries, slug, live.message)); | |
| 319 | setTyping((now) => now.filter((one) => one.member.id !== live.message.author.id)); | |
| 320 | break; | |
| 321 | case "message.deleted": | |
| 322 | removeMessage(queries, slug, live.channel_id, live.id); | |
| 323 | break; | |
| 324 | case "reaction.added": | |
| 325 | case "reaction.removed": | |
| 326 | if (live.member.id !== me.id) applyReaction(queries, slug, live.channel_id, live.message_id, live.emoji, live.member, live.type === "reaction.added", false); | |
| 327 | break; | |
| 328 | case "typing": | |
| 329 | if (live.member.id === me.id) break; | |
| 330 | setTyping((now) => [...now.filter((one) => one.member.id !== live.member.id), { member: live.member, until: Date.parse(live.until) }]); | |
| 331 | break; | |
| 332 | case "channel.updated": | |
| 333 | void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }); | |
| 334 | break; | |
| 335 | } | |
| 336 | }, | |
| 337 | }); | |
| 338 | send.current = socket.send; | |
| 339 | return () => { | |
| 340 | send.current = null; | |
| 341 | socket.close(); | |
| 342 | }; | |
| 343 | }, [client, queries, slug, channelId, me.id]); | |
| 344 | ||
| 345 | // Typing lapses on its own. | |
| 346 | useEffect(() => { | |
| 347 | if (typing.length === 0) return; | |
| 348 | const next = Math.min(...typing.map((one) => one.until)) - Date.now(); | |
| 349 | const timer = setTimeout(() => setTyping((now) => now.filter((one) => one.until > Date.now())), Math.max(250, next)); | |
| 350 | return () => clearTimeout(timer); | |
| 351 | }, [typing]); | |
| 352 | ||
| 353 | const sayTyping = useCallback(() => { | |
| 354 | const now = Date.now(); | |
| 355 | if (now - typedAt.current < 3000) return; | |
| 356 | typedAt.current = now; | |
| 357 | send.current?.({ type: "typing", channel_id: channelId }); | |
| 358 | }, [channelId]); | |
| 359 | ||
| 360 | return { status, typing: typing.map((one) => one.member), sayTyping }; | |
| 361 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.