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.
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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"; | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 4 | |
| 5 | import type { Client } from "./client"; | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 6 | import { openSocket } from "./live"; |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other 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. | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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. | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 21 | */ |
| 22 | ||
| 23 | const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`; | |
| 24 | ||
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 | ||
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 44 | export function useChatSidebar() { |
| 45 | const { client, workspace } = useSignedIn(); | |
| 46 | const slug = workspace?.slug ?? ""; | |
| 47 | return useQuery({ | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 48 | queryKey: chatKeys.sidebar(slug), |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 49 | queryFn: () => client.site<ChatSidebar>(chatPath(slug)), |
| 50 | enabled: !!slug, | |
| 51 | }); | |
| 52 | } | |
| 53 | ||
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 54 | /** The workspace's channels, to find and join (`GET …/chat/api?browse=1`). */ |
| 55 | export function useBrowseChannels() { | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 56 | const { client, workspace } = useSignedIn(); |
| 57 | const slug = workspace?.slug ?? ""; | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 ?? ""; | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 68 | return useInfiniteQuery({ |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 69 | queryKey: chatKeys.messages(slug, channelId, thread), |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 70 | initialPageParam: null as string | null, |
| 71 | queryFn: ({ pageParam }) => { | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 72 | const query = new URLSearchParams({ channel: channelId, limit: "50" }); |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 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 | ||
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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) { | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 220 | const { client, workspace } = useSignedIn(); |
| 221 | const queries = useQueryClient(); | |
| 222 | const slug = workspace?.slug ?? ""; | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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), | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 229 | }); |
| 230 | ||
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 | }); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 235 | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 | }); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 241 | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 }), | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 246 | }); |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 }; | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 259 | } |
| 260 | ||
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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, | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 265 | ); |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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 | }; | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 291 | } |
| 292 | ||
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 293 | // ── Live ─────────────────────────────────────────────────────────────────── |
| 294 | ||
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 295 | export type LiveStatus = "connecting" | "live" | "offline"; |
| 296 | ||
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 297 | /** Who is typing, until when. */ |
| 298 | type Typing = { member: MemberProfile; until: number }; | |
| 299 | ||
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 300 | /** |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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). | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 304 | */ |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 305 | export function useChannelLive(channelId: string) { |
| 306 | const { client, workspace, me } = useSignedIn(); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 307 | const queries = useQueryClient(); |
| 308 | const slug = workspace?.slug ?? ""; | |
| 309 | const [status, setStatus] = useState<LiveStatus>("connecting"); | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 310 | const [typing, setTyping] = useState<Typing[]>([]); |
| 311 | const send = useRef<((frame: unknown) => void) | null>(null); | |
| 312 | const typedAt = useRef(0); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 313 | |
| 314 | useEffect(() => { | |
| 315 | if (!slug || !channelId) return; | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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; | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 341 | } |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 342 | }, |
| 343 | }); | |
| 344 | send.current = socket.send; | |
| 345 | return () => { | |
| 346 | send.current = null; | |
| 347 | socket.close(); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 348 | }; |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 349 | }, [client, queries, slug, channelId, me.id]); |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 350 | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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]); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 358 | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 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]); | |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 365 | |
| Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More. | 366 | return { status, typing: typing.map((one) => one.member), sayTyping }; |
| The phone app's scaffolding, in apps/mobile: Expo SDK 57 with Expo Router, Android first, with Chat, Code and Customize. It signs in with g1t's own OAuth 2.1 (PKCE, a client id that encodes "g1t for Android" and g1t://oauth/callback, so nothing is registered), keeps the server and credentials in the phone's keystore, refreshes the 30-day token a minute early and shares the single-use refresh between requests. A personal access token is the other way in, and a self-hosted g1t the other server. | 367 | } |