@marktiderman/genesis-chat 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Tiderman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,164 @@
1
- # Temporary Holding Version
1
+ # @marktiderman/genesis-chat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Chat for Genesis apps, in the app's own Supabase: direct messages, groups and the app's own
4
+ kinds of conversation (a cohort, a support ticket), with photos, files, voice notes and videos,
5
+ replies, threads, reactions, read state, live updates and push.
6
+
7
+ | Entry | What it is |
8
+ |---|---|
9
+ | `@marktiderman/genesis-chat` | The headless core: types, `createChatClient`, the send outbox, thread logic, media rules. No React. |
10
+ | `/react` | Hooks for web and native: `ChatProvider`, `useInbox`, `useConversation`, `useSend`, `useMessageActions`. |
11
+ | `/web` | Renderers on genesis-ui: inbox, thread, composer (text, photos, voice), media. |
12
+ | `/native` | Coming in the next release: renderers on genesis-ui-native (`InboxPage` and `ThreadPage` wired to chat, voice notes with expo-audio). Empty in this version. |
13
+ | `/server` | `createChatRunner`: works the outbox (push, transcripts, storage cleanup). |
14
+ | `migrations/0001_genesis_chat.sql` | The schema, copied into your app by `genesis add chat`, with its pgTAP test. |
15
+
16
+ ## On the web
17
+
18
+ ```tsx
19
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
20
+ import { createChatClient } from "@marktiderman/genesis-chat";
21
+ import { ChatProvider } from "@marktiderman/genesis-chat/react";
22
+ import { ChatPanel } from "@marktiderman/genesis-chat/web";
23
+
24
+ const chat = createChatClient(supabase);
25
+
26
+ <QueryClientProvider client={queryClient}>
27
+ <ChatProvider client={chat} userId={user.id}>
28
+ <ChatPanel resolveUser={(id) => ({ name: people[id]?.name ?? "Someone", avatarUrl: people[id]?.avatar })} />
29
+ </ChatProvider>
30
+ </QueryClientProvider>
31
+ ```
32
+
33
+ `resolveUser` is required: the package knows no people tables, so the app names everyone. Use
34
+ `ChatInbox` and `ChatThread` on their own for your own layout, and `ChatComposer` with
35
+ `useConversation` for your own thread. Swap parts with `slots` (`header`, `empty`, `attachment`
36
+ for your own attachment kinds, `composerAccessory`). Mount a Genesis `<Toaster />` for refusals
37
+ (a file too big, a send not allowed). Tailwind must scan the package
38
+ (`@source "../node_modules/@marktiderman/genesis-chat/dist"`). `createMemoryChatClient` runs it
39
+ all with no backend, for demos, stories and tests.
40
+
41
+ ## Kinds and rules
42
+
43
+ A kind of conversation carries its rules (`genesis_chat.register_kind`): how many members, who
44
+ posts (members or admins), who manages members (the app, or the conversation's admins), whether
45
+ threads are on, a **start check** (who may start one, with whom) and an **observer check** (who
46
+ reads without being a member, such as a support team). Both checks are functions of your own,
47
+ registered in a migration after 0001; without a start check only your server starts that kind.
48
+
49
+ ```sql
50
+ create function public.chat_may_dm(member_ids uuid[], subject_type text, subject_id text, user_id uuid)
51
+ returns boolean language sql stable security definer set search_path = '' as $$
52
+ select exists (select 1 from public.team t1 join public.team t2 using (org_id)
53
+ where t1.user_id = chat_may_dm.user_id and t2.user_id = member_ids[1])
54
+ $$;
55
+ revoke execute on function public.chat_may_dm(uuid[], text, text, uuid) from public, anon, authenticated;
56
+ select genesis_chat.register_kind('direct', 'Direct message', p_max_members => 2,
57
+ p_start_check => 'public.chat_may_dm(uuid[], text, text, uuid)');
58
+ ```
59
+
60
+ A conversation tied to one of your records (a group, a ticket) is made by your server with
61
+ `genesis_chat.ensure_conversation(kind, subject_type, subject_id, title)`, and its members set with
62
+ `genesis_chat.sync_members(conversation, user_ids, role)`.
63
+
64
+ ## Access
65
+
66
+ Members read a conversation; who posts follows its kind and settings (`{"who_posts": "admins"}`
67
+ makes it one-way). Observers read it and, where the kind allows, reply and leave internal notes
68
+ (`visibility: "observers"`) members never see. Files go in the private `genesis-chat` bucket under
69
+ `<conversation>/<uploader>/`, readable by whoever reads the message that carries them. anon
70
+ reaches nothing. The pgTAP test beside the migration proves each rule.
71
+
72
+ ## Live updates
73
+
74
+ New and changed messages, reactions and memberships are broadcast as hints (ids only) to private
75
+ Realtime topics, `genesis-chat:conv:<id>` and `genesis-chat:user:<id>`; clients fetch through row
76
+ level security, and fetch what they missed after a reconnect. Typing and recording are broadcast
77
+ by clients and never stored.
78
+
79
+ ## The outbox
80
+
81
+ A message queues a `push` effect, a voice note or video a `transcribe` effect, a deleted file a
82
+ `storage.remove` effect. `createChatRunner` (from `/server`) claims and settles them; nothing is sent
83
+ from a trigger.
84
+
85
+ ## Server: the runner
86
+
87
+ `@marktiderman/genesis-chat/server` works the outbox with a service-role client. It runs in Node,
88
+ edge functions and Deno (fetch and Web Crypto only).
89
+
90
+ ```ts
91
+ import { createClient } from "@supabase/supabase-js";
92
+ import { createChatRunner, chatRunnerHandler, createExpoDeliver, deepgramTranscriber, muxVideo } from "@marktiderman/genesis-chat/server";
93
+
94
+ const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { auth: { persistSession: false } });
95
+ export const video = muxVideo({
96
+ tokenId: process.env.MUX_TOKEN_ID!, tokenSecret: process.env.MUX_TOKEN_SECRET!, webhookSecret: process.env.MUX_WEBHOOK_SECRET!,
97
+ });
98
+ const runner = createChatRunner({
99
+ supabase,
100
+ // Or: (n) => notify({ type: "chat.message", recipients: n.recipients, data: n.data, idempotencyKey: n.idempotencyKey, threadKey: n.threadKey, collapseKey: n.collapseKey })
101
+ deliver: createExpoDeliver({ tokensFor: myPushTokens, accessToken: process.env.EXPO_ACCESS_TOKEN }),
102
+ transcriber: deepgramTranscriber({ apiKey: process.env.DEEPGRAM_API_KEY! }),
103
+ video,
104
+ resolveName: async (userId) => (await myProfile(userId))?.name,
105
+ });
106
+ export const POST = chatRunnerHandler({ runner, secret: process.env.CHAT_RUNNER_SECRET! });
107
+ ```
108
+
109
+ Each `run()` claims up to 50 due effects (`batch.limit`), works up to 4 of a kind at once
110
+ (`batch.concurrency`), settles each (`complete_effect`; an error backs off 30 s × 2^attempts) and
111
+ answers `{ claimed, done, failed }`. It claims only `push`, `transcribe`, `storage.remove` and
112
+ `video.remove` (or the `kinds` you pass); after the 8th attempt an effect is left alone.
113
+
114
+ - **push**: one `deliver(notification)` per message, with every recipient and their badge. The
115
+ title is the conversation's, or the sender's name (`resolveName`) in a direct conversation; in a
116
+ group the body is `Name: preview`. `idempotencyKey` is `push:<message id>`, so a sender that
117
+ dedupes never pushes a message twice. Throw from `deliver` to retry. `createExpoDeliver` sends
118
+ through Expo in requests of 100 (`threadId`, `collapseId`, `badge`, channel `messages`, category
119
+ `chat-message`) and returns the tickets: forget `deadTokens(tickets)`. It throws only when
120
+ nothing was sent. Note that `collapseId` replaces a conversation's earlier notification on iOS.
121
+ - **transcribe**: a 10 minute signed URL to the transcriber, then `transcript_status: ready`. With no
122
+ transcriber the status is `off`; on the last attempt a failure sets `failed`. Hosted videos get
123
+ their transcript from Mux's captions instead. `openAiTranscriber` (gpt-4o-mini-transcribe, files
124
+ up to 25 MB) is the other choice.
125
+ - **storage.remove** and **video.remove**: delete the file or the hosted asset; already gone is done.
126
+
127
+ ### Run it every minute (Vercel cron)
128
+
129
+ `vercel.json`: `{ "crons": [{ "path": "/api/chat-runner", "schedule": "* * * * *" }] }`, and set
130
+ `CHAT_RUNNER_SECRET` to the value of Vercel's `CRON_SECRET` (Vercel sends it as
131
+ `Authorization: Bearer <CRON_SECRET>` on a GET; the handler takes GET and POST). Minute crons need
132
+ a paid Vercel plan; on Hobby, use the webhook below or a Supabase `pg_cron` + `pg_net` call.
133
+
134
+ ### Or on every effect (Supabase database webhook)
135
+
136
+ Database → Webhooks → new hook on `genesis_chat.effects`, Insert, HTTP POST to your route, header
137
+ `Authorization: Bearer <CHAT_RUNNER_SECRET>`. Pushes are queued 2 seconds ahead (a burst
138
+ collapses) and retries are backed off, so keep a schedule too (every minute or five) to pick
139
+ those up.
140
+
141
+ ### Env
142
+
143
+ | Variable | For |
144
+ |---|---|
145
+ | `SUPABASE_URL`, `SUPABASE_SECRET_KEY` | the service-role client |
146
+ | `CHAT_RUNNER_SECRET` | the handler's bearer, at least 32 characters |
147
+ | `EXPO_ACCESS_TOKEN` | `createExpoDeliver`, when the Expo project has enhanced push security |
148
+ | `DEEPGRAM_API_KEY` | `deepgramTranscriber` (nova-3, language detected; `Authorization: Token`) |
149
+ | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` | `muxVideo` uploads and removal |
150
+ | `MUX_WEBHOOK_SECRET` | the Mux webhook's signing secret |
151
+ | `MUX_SIGNING_KEY_ID`, `MUX_SIGNING_KEY` | only for `playbackPolicy: "signed"` (`signing` option) |
152
+
153
+ ### Mux
154
+
155
+ The client asks your server for an upload (`video.createUpload({ corsOrigin, passthrough })`), puts
156
+ the file to its `url`, and sends the message with an attachment `{ kind: "video", provider: "mux",
157
+ provider_id: uploadId, status: "pending" }`. Point a Mux webhook (Settings → Webhooks) at a route
158
+ that calls `video.handleWebhook(request, { supabase })`. It checks `mux-signature` (HMAC-SHA256,
159
+ 5 minutes' tolerance), and on `video.asset.ready` sets the attachment ready with its asset id,
160
+ playback id and duration; on `video.asset.errored` failed; on the English auto-generated caption
161
+ track's `video.asset.track.ready` it stores the caption text as the transcript. An event for an
162
+ attachment not there yet answers 404, so Mux retries it. Playback is public by default
163
+ (`https://stream.mux.com/<playback id>.m3u8`); with `playbackPolicy: "signed"` and `signing`,
164
+ `video.playbackUrl(id)` (or `muxPlaybackUrl`) returns a URL with an RS256 token.
@@ -0,0 +1,322 @@
1
+ import { createOutbox, upsertMessages, currentActivity, mergeThread, throttle, firstUnreadId } from './chunk-SAY24E6V.js';
2
+ import { useQueryClient, useQuery } from '@tanstack/react-query';
3
+ import { createContext, useState, useRef, useMemo, useEffect, useContext, useCallback } from 'react';
4
+ import { jsx } from 'react/jsx-runtime';
5
+
6
+ var chatKeys = {
7
+ all: ["genesis-chat"],
8
+ inbox: (opts) => ["genesis-chat", "inbox", opts ?? {}],
9
+ unread: () => ["genesis-chat", "unread"],
10
+ conversation: (id) => ["genesis-chat", "conversation", id],
11
+ members: (id) => ["genesis-chat", "members", id],
12
+ role: (id) => ["genesis-chat", "role", id],
13
+ pins: (id) => ["genesis-chat", "pins", id],
14
+ files: (paths) => ["genesis-chat", "files", [...paths].sort()]
15
+ };
16
+ function belongsTo(key, m) {
17
+ return key[0] === "genesis-chat" && key[1] === "conversation" && key[2] === m.conversationId && key[3] === "messages" && (key[4] ?? null) === (m.threadRootId ?? null) && (key[5] == null || key[5] === m.topic);
18
+ }
19
+ var ChatContext = createContext(null);
20
+ function ChatProvider({ client, userId, outboxStore, readFile, upload, newId, persistFile, loadPeople, children }) {
21
+ const queryClient = useQueryClient();
22
+ const [queue, setQueue] = useState([]);
23
+ const listeners = useRef(/* @__PURE__ */ new Set());
24
+ const outbox = useMemo(
25
+ () => createOutbox({
26
+ client,
27
+ userId,
28
+ store: outboxStore,
29
+ readFile,
30
+ upload,
31
+ newId,
32
+ persistFile,
33
+ onChange: setQueue,
34
+ onSent: (message) => {
35
+ queryClient.setQueriesData(
36
+ { predicate: (q) => belongsTo(q.queryKey, message) },
37
+ (prev) => Array.isArray(prev) ? upsertMessages(prev, [message]) : prev
38
+ );
39
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
40
+ }
41
+ }),
42
+ // A new person or client is a new outbox.
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ [client, userId]
45
+ );
46
+ useEffect(() => {
47
+ void outbox.load();
48
+ const sub = client.subscribeInbox(userId, (signal) => {
49
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
50
+ void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
51
+ for (const l of listeners.current) l(signal);
52
+ });
53
+ return () => sub.unsubscribe();
54
+ }, [client, userId, outbox, queryClient]);
55
+ const value = useMemo(
56
+ () => ({
57
+ client,
58
+ userId,
59
+ outbox,
60
+ queue,
61
+ loadPeople,
62
+ onInbox: (l) => {
63
+ listeners.current.add(l);
64
+ return () => listeners.current.delete(l);
65
+ }
66
+ }),
67
+ [client, userId, outbox, queue, loadPeople]
68
+ );
69
+ return /* @__PURE__ */ jsx(ChatContext.Provider, { value, children });
70
+ }
71
+ function useChat() {
72
+ const ctx = useContext(ChatContext);
73
+ if (!ctx) throw new Error("Chat hooks need a <ChatProvider> above them.");
74
+ return ctx;
75
+ }
76
+ function useInbox(opts = {}) {
77
+ const { client } = useChat();
78
+ const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15e3 });
79
+ return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };
80
+ }
81
+ function useUnreadTotal() {
82
+ const { client } = useChat();
83
+ return useQuery({ queryKey: chatKeys.unread(), queryFn: () => client.unreadTotal(), staleTime: 15e3 }).data ?? 0;
84
+ }
85
+ function useConversation(conversationId, opts = {}) {
86
+ const { client, userId, outbox, queue } = useChat();
87
+ const queryClient = useQueryClient();
88
+ const id = conversationId ?? "";
89
+ const enabled = Boolean(conversationId);
90
+ const pageSize = opts.pageSize ?? 50;
91
+ const messagesKey = useMemo(
92
+ () => [...chatKeys.conversation(id), "messages", opts.threadRootId ?? null, opts.topic ?? null],
93
+ [id, opts.threadRootId, opts.topic]
94
+ );
95
+ const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });
96
+ const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 6e4 });
97
+ const kindRules = useQuery({
98
+ queryKey: ["genesis-chat", "kind", conversation.data?.kind ?? ""],
99
+ queryFn: () => client.kind(conversation.data.kind),
100
+ enabled: Boolean(conversation.data?.kind),
101
+ staleTime: 10 * 6e4
102
+ });
103
+ const access = useQuery({
104
+ queryKey: chatKeys.role(id),
105
+ queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),
106
+ enabled,
107
+ staleTime: 6e4
108
+ });
109
+ const [hasEarlier, setHasEarlier] = useState(false);
110
+ const [loadingEarlier, setLoadingEarlier] = useState(false);
111
+ const messages = useQuery({
112
+ queryKey: messagesKey,
113
+ queryFn: async () => {
114
+ const page = await client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
115
+ setHasEarlier(page.hasEarlier);
116
+ return page.messages;
117
+ },
118
+ enabled,
119
+ staleTime: Infinity
120
+ });
121
+ const [readWhenOpened, setReadWhenOpened] = useState(null);
122
+ const merge = useCallback(
123
+ (incoming) => queryClient.setQueryData(messagesKey, (prev) => upsertMessages(prev ?? [], incoming)),
124
+ [queryClient, messagesKey]
125
+ );
126
+ const catchUp = useCallback(async () => {
127
+ const current = queryClient.getQueryData(messagesKey) ?? [];
128
+ const after = current.length ? current[current.length - 1].seq : void 0;
129
+ if (after == null) {
130
+ await queryClient.invalidateQueries({ queryKey: messagesKey });
131
+ return;
132
+ }
133
+ const page = await client.messages(id, { afterSeq: after, limit: 200, threadRootId: opts.threadRootId, topic: opts.topic });
134
+ merge(page.messages);
135
+ }, [client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);
136
+ const [activity, setActivity] = useState([]);
137
+ const channel = useRef(null);
138
+ useEffect(() => {
139
+ if (!enabled) return;
140
+ const fetchOne = async (messageId) => {
141
+ const m = await client.message(messageId);
142
+ if (!m) return;
143
+ const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;
144
+ if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);
145
+ if (m.threadRootId && !opts.threadRootId) {
146
+ const root = await client.message(m.threadRootId);
147
+ if (root) merge([root]);
148
+ }
149
+ };
150
+ channel.current = client.subscribeConversation(id, userId, {
151
+ onSignal: (s) => {
152
+ if (s.type === "message") void fetchOne(s.id);
153
+ else if (s.type === "reaction") void fetchOne(s.messageId);
154
+ else if (s.type === "members") {
155
+ void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });
156
+ void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });
157
+ } else if (s.type === "conversation") {
158
+ void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id) });
159
+ void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });
160
+ }
161
+ },
162
+ onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),
163
+ onReconnect: () => void catchUp()
164
+ });
165
+ return () => {
166
+ channel.current?.unsubscribe();
167
+ channel.current = null;
168
+ };
169
+ }, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);
170
+ useEffect(() => {
171
+ if (activity.length === 0) return;
172
+ const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2e3);
173
+ return () => clearTimeout(t);
174
+ }, [activity]);
175
+ const list = messages.data ?? [];
176
+ const latestSeq = list.length ? list[list.length - 1].seq : 0;
177
+ const myLastRead = members.data?.find((m) => m.userId === userId)?.lastReadSeq;
178
+ useEffect(() => {
179
+ if (readWhenOpened === null && myLastRead !== void 0) setReadWhenOpened(myLastRead);
180
+ }, [myLastRead, readWhenOpened]);
181
+ const markRead = useCallback(async () => {
182
+ if (!enabled || latestSeq === 0) return;
183
+ await client.markRead(id, latestSeq);
184
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
185
+ void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
186
+ }, [client, enabled, id, latestSeq, queryClient]);
187
+ const shouldMark = opts.markRead !== false;
188
+ useEffect(() => {
189
+ if (shouldMark && latestSeq > 0 && !opts.threadRootId) void markRead().catch(() => void 0);
190
+ }, [shouldMark, latestSeq, markRead, opts.threadRootId]);
191
+ const loadEarlier = useCallback(async () => {
192
+ const first = list[0];
193
+ if (!first || loadingEarlier) return;
194
+ setLoadingEarlier(true);
195
+ try {
196
+ const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
197
+ merge(page.messages);
198
+ setHasEarlier(page.hasEarlier);
199
+ } finally {
200
+ setLoadingEarlier(false);
201
+ }
202
+ }, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);
203
+ const pending = useMemo(
204
+ () => queue.filter(
205
+ (q) => q.input.conversationId === id && (q.input.threadRootId ?? null) === (opts.threadRootId ?? null) && (!opts.topic || q.input.topic === opts.topic)
206
+ ).map((q) => ({ ...q, files: q.pendingFiles })),
207
+ [queue, id, opts.threadRootId, opts.topic]
208
+ );
209
+ const items = useMemo(() => mergeThread(list, pending), [list, pending]);
210
+ const signalTyping = useMemo(() => throttle(() => channel.current?.signal("typing")), []);
211
+ const signalRecording = useMemo(
212
+ () => throttle(() => channel.current?.signal("recording-voice")),
213
+ []
214
+ );
215
+ const send = useCallback(
216
+ (input, files = []) => outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),
217
+ [outbox, id, opts.threadRootId, opts.topic]
218
+ );
219
+ return {
220
+ conversation: conversation.data ?? null,
221
+ members: members.data ?? [],
222
+ role: access.data?.role ?? null,
223
+ canPost: access.data?.canPost ?? false,
224
+ /** Your role and posting rights are still loading: show the composer as waiting, not refused. */
225
+ accessLoading: enabled && access.isLoading,
226
+ /** The kind's rules (max members, threads, who posts). */
227
+ kind: kindRules.data ?? null,
228
+ /** May you pin here: owners and admins, or anyone in a two-person conversation. */
229
+ canPin: access.data?.role === "owner" || access.data?.role === "admin" || access.data?.role === "member" && kindRules.data?.maxMembers === 2,
230
+ /** Gives a message whose files were lost on a relaunch its files again. */
231
+ reattach: outbox.reattach,
232
+ items,
233
+ messages: list,
234
+ isLoading: messages.isLoading || conversation.isLoading,
235
+ error: messages.error ?? conversation.error,
236
+ hasEarlier,
237
+ loadingEarlier,
238
+ loadEarlier,
239
+ /** Where to draw the "New" line, from what you had read when it opened. */
240
+ firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),
241
+ activity: currentActivity(activity),
242
+ signalTyping,
243
+ signalRecording,
244
+ markRead,
245
+ send,
246
+ retry: outbox.retry,
247
+ discard: outbox.discard,
248
+ refetch: catchUp
249
+ };
250
+ }
251
+ function useMessageActions() {
252
+ const { client } = useChat();
253
+ const queryClient = useQueryClient();
254
+ return useMemo(() => {
255
+ const refresh = async (conversationId, messageId) => {
256
+ const m = await client.message(messageId);
257
+ queryClient.setQueriesData(
258
+ { queryKey: chatKeys.conversation(conversationId) },
259
+ (prev) => Array.isArray(prev) && m ? upsertMessages(prev, [m]) : prev
260
+ );
261
+ };
262
+ return {
263
+ edit: async (m, body, mentions = []) => {
264
+ await client.edit(m.id, body, mentions);
265
+ await refresh(m.conversationId, m.id);
266
+ },
267
+ remove: async (m) => {
268
+ await client.remove(m.id);
269
+ await refresh(m.conversationId, m.id);
270
+ },
271
+ react: async (m, emoji, on) => {
272
+ await client.react(m.id, emoji, on);
273
+ await refresh(m.conversationId, m.id);
274
+ },
275
+ pin: async (m, on) => {
276
+ await client.pin(m.id, on);
277
+ void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });
278
+ },
279
+ save: (m, on, note) => client.save(m.id, on, note),
280
+ report: (m, reason, note) => client.report(m.id, reason, note)
281
+ };
282
+ }, [client, queryClient]);
283
+ }
284
+ function usePeople(userIds) {
285
+ const { loadPeople } = useChat();
286
+ const ids = useMemo(() => [...new Set(userIds.filter((x) => Boolean(x)))].sort(), [userIds]);
287
+ return useQuery({
288
+ queryKey: ["genesis-chat", "people", ids],
289
+ queryFn: () => loadPeople(ids),
290
+ enabled: Boolean(loadPeople) && ids.length > 0,
291
+ staleTime: 5 * 6e4
292
+ }).data ?? {};
293
+ }
294
+ function usePerson(userId) {
295
+ const people = usePeople(useMemo(() => [userId], [userId]));
296
+ return userId ? people[userId] ?? null : null;
297
+ }
298
+ function usePins(conversationId) {
299
+ const { client } = useChat();
300
+ return useQuery({ queryKey: chatKeys.pins(conversationId ?? ""), queryFn: () => client.pins(conversationId), enabled: Boolean(conversationId) }).data ?? [];
301
+ }
302
+ function useFileUrls(paths) {
303
+ const { client } = useChat();
304
+ const list = useMemo(() => [...new Set(paths.filter((p) => Boolean(p)))], [paths]);
305
+ return useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 6e4 }).data ?? {};
306
+ }
307
+ function useStartConversation() {
308
+ const { client } = useChat();
309
+ const queryClient = useQueryClient();
310
+ return useCallback(
311
+ async (kind, memberIds, opts) => {
312
+ const id = await client.startConversation(kind, memberIds, opts);
313
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
314
+ return id;
315
+ },
316
+ [client, queryClient]
317
+ );
318
+ }
319
+
320
+ export { ChatProvider, belongsTo, chatKeys, useChat, useConversation, useFileUrls, useInbox, useMessageActions, usePeople, usePerson, usePins, useStartConversation, useUnreadTotal };
321
+ //# sourceMappingURL=chunk-27UTR5FW.js.map
322
+ //# sourceMappingURL=chunk-27UTR5FW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/index.tsx"],"names":[],"mappings":"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","file":"chunk-27UTR5FW.js","sourcesContent":["/**\n * @feat:chat\n * React hooks for chat, the same on web and native: the inbox, one\n * conversation (its messages, members, your role, who is typing) kept live by\n * Realtime hints, sending through the outbox, and message actions. Server\n * state is TanStack Query's; the renderers in /web and /native only draw.\n */\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type { ChatClient, ConversationChannel } from \"../core/client\";\nimport { createOutbox, type LocalFile, type Outbox, type OutboxStore, type QueuedMessage } from \"../core/outbox\";\nimport { currentActivity, firstUnreadId, mergeThread, throttle, upsertMessages, type ThreadItem } from \"../core/thread\";\nimport type { ChatActivity, ChatMessage, ChatSignal, SendInput } from \"../core/types\";\n\nexport const chatKeys = {\n all: [\"genesis-chat\"] as const,\n inbox: (opts?: object) => [\"genesis-chat\", \"inbox\", opts ?? {}] as const,\n unread: () => [\"genesis-chat\", \"unread\"] as const,\n conversation: (id: string) => [\"genesis-chat\", \"conversation\", id] as const,\n members: (id: string) => [\"genesis-chat\", \"members\", id] as const,\n role: (id: string) => [\"genesis-chat\", \"role\", id] as const,\n pins: (id: string) => [\"genesis-chat\", \"pins\", id] as const,\n files: (paths: string[]) => [\"genesis-chat\", \"files\", [...paths].sort()] as const,\n};\n\n/** Does a messages list (`[genesis-chat, conversation, id, messages, threadRootId, topic]`) hold this message? */\nexport function belongsTo(key: readonly unknown[], m: ChatMessage): boolean {\n return (\n key[0] === \"genesis-chat\" &&\n key[1] === \"conversation\" &&\n key[2] === m.conversationId &&\n key[3] === \"messages\" &&\n (key[4] ?? null) === (m.threadRootId ?? null) &&\n (key[5] == null || key[5] === m.topic)\n );\n}\n\ninterface ChatContextValue {\n client: ChatClient;\n userId: string;\n outbox: Outbox;\n queue: QueuedMessage[];\n loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;\n /** Subscribe to inbox hints (one channel for the app, fanned out here). */\n onInbox(listener: (s: ChatSignal) => void): () => void;\n}\n\nconst ChatContext = createContext<ChatContextValue | null>(null);\n\nexport interface ChatProviderProps {\n client: ChatClient;\n /** The signed-in user's id (auth.users). */\n userId: string;\n /** Where unsent messages wait between launches (localStorage, MMKV…). Memory by default. */\n outboxStore?: OutboxStore;\n /** Reads a native file URI for upload. */\n readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;\n /** Replaces the plain upload (resumable uploads on native). */\n upload?: (conversationId: string, file: LocalFile) => Promise<string>;\n /** Makes client ids (default: a random UUID that works without crypto.randomUUID). */\n newId?: () => string;\n /** Keeps a byte file (a pasted image, a recording) across launches: write it, return a URI. */\n persistFile?: (file: LocalFile) => Promise<string>;\n /** Names and avatars for user ids, fetched in batches (usePeople, usePerson). */\n loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;\n children: ReactNode;\n}\n\n/** How the app shows a person. */\nexport interface ChatPerson {\n name: string;\n avatarUrl?: string | null;\n}\n\n/** Puts chat in reach of every hook below it. Needs a TanStack QueryClientProvider above it. */\nexport function ChatProvider({ client, userId, outboxStore, readFile, upload, newId, persistFile, loadPeople, children }: ChatProviderProps) {\n const queryClient = useQueryClient();\n const [queue, setQueue] = useState<QueuedMessage[]>([]);\n const listeners = useRef(new Set<(s: ChatSignal) => void>());\n\n const outbox = useMemo(\n () =>\n createOutbox({\n client,\n userId,\n store: outboxStore,\n readFile,\n upload,\n newId,\n persistFile,\n onChange: setQueue,\n onSent: (message) => {\n // Into every loaded list it belongs to (the main timeline, its thread, its topic), so\n // the bubble never disappears between the send landing and the live hint.\n queryClient.setQueriesData<ChatMessage[]>(\n { predicate: (q) => belongsTo(q.queryKey, message) },\n (prev) => (Array.isArray(prev) ? upsertMessages(prev, [message]) : prev),\n );\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n },\n }),\n // A new person or client is a new outbox.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [client, userId],\n );\n\n useEffect(() => {\n void outbox.load();\n const sub = client.subscribeInbox(userId, (signal) => {\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });\n for (const l of listeners.current) l(signal);\n });\n return () => sub.unsubscribe();\n }, [client, userId, outbox, queryClient]);\n\n const value = useMemo<ChatContextValue>(\n () => ({\n client,\n userId,\n outbox,\n queue,\n loadPeople,\n onInbox: (l) => {\n listeners.current.add(l);\n return () => listeners.current.delete(l);\n },\n }),\n [client, userId, outbox, queue, loadPeople],\n );\n return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;\n}\n\nexport function useChat(): ChatContextValue {\n const ctx = useContext(ChatContext);\n if (!ctx) throw new Error(\"Chat hooks need a <ChatProvider> above them.\");\n return ctx;\n}\n\n/** Your conversations, live. `kind` narrows to one kind; `archived` shows the archived ones. */\nexport function useInbox(opts: { kind?: string; archived?: boolean; limit?: number } = {}) {\n const { client } = useChat();\n const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15_000 });\n return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };\n}\n\n/** How many messages wait for you everywhere (for a badge), live. */\nexport function useUnreadTotal(): number {\n const { client } = useChat();\n return useQuery({ queryKey: chatKeys.unread(), queryFn: () => client.unreadTotal(), staleTime: 15_000 }).data ?? 0;\n}\n\nexport interface UseConversationOptions {\n /** Mark messages read as they arrive while this is true (the thread is on screen). Default true. */\n markRead?: boolean;\n /** One thread's replies instead of the main timeline. */\n threadRootId?: string;\n /** One topic's messages. */\n topic?: string;\n pageSize?: number;\n}\n\n/** One conversation: its messages (with what you are still sending), members, your role, and who is typing. */\nexport function useConversation(conversationId: string | null | undefined, opts: UseConversationOptions = {}) {\n const { client, userId, outbox, queue } = useChat();\n const queryClient = useQueryClient();\n const id = conversationId ?? \"\";\n const enabled = Boolean(conversationId);\n const pageSize = opts.pageSize ?? 50;\n const messagesKey = useMemo(\n () => [...chatKeys.conversation(id), \"messages\", opts.threadRootId ?? null, opts.topic ?? null] as const,\n [id, opts.threadRootId, opts.topic],\n );\n\n const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });\n const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 60_000 });\n const kindRules = useQuery({\n queryKey: [\"genesis-chat\", \"kind\", conversation.data?.kind ?? \"\"],\n queryFn: () => client.kind(conversation.data!.kind),\n enabled: Boolean(conversation.data?.kind),\n staleTime: 10 * 60_000,\n });\n const access = useQuery({\n queryKey: chatKeys.role(id),\n queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),\n enabled,\n staleTime: 60_000,\n });\n\n const [hasEarlier, setHasEarlier] = useState(false);\n const [loadingEarlier, setLoadingEarlier] = useState(false);\n const messages = useQuery({\n queryKey: messagesKey,\n queryFn: async () => {\n const page = await client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });\n setHasEarlier(page.hasEarlier);\n return page.messages;\n },\n enabled,\n staleTime: Infinity,\n });\n // Where \"New\" goes: what you had read when the conversation opened.\n const [readWhenOpened, setReadWhenOpened] = useState<number | null>(null);\n\n const merge = useCallback(\n (incoming: ChatMessage[]) =>\n queryClient.setQueryData<ChatMessage[]>(messagesKey, (prev) => upsertMessages(prev ?? [], incoming)),\n [queryClient, messagesKey],\n );\n\n const catchUp = useCallback(async () => {\n const current = queryClient.getQueryData<ChatMessage[]>(messagesKey) ?? [];\n const after = current.length ? current[current.length - 1]!.seq : undefined;\n if (after == null) {\n await queryClient.invalidateQueries({ queryKey: messagesKey });\n return;\n }\n const page = await client.messages(id, { afterSeq: after, limit: 200, threadRootId: opts.threadRootId, topic: opts.topic });\n merge(page.messages);\n }, [client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);\n\n const [activity, setActivity] = useState<ChatActivity[]>([]);\n const channel = useRef<ConversationChannel | null>(null);\n useEffect(() => {\n if (!enabled) return;\n const fetchOne = async (messageId: string) => {\n const m = await client.message(messageId);\n if (!m) return;\n const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;\n if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);\n if (m.threadRootId && !opts.threadRootId) {\n const root = await client.message(m.threadRootId);\n if (root) merge([root]);\n }\n };\n channel.current = client.subscribeConversation(id, userId, {\n onSignal: (s) => {\n if (s.type === \"message\") void fetchOne(s.id);\n else if (s.type === \"reaction\") void fetchOne(s.messageId);\n else if (s.type === \"members\") {\n void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });\n void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });\n } else if (s.type === \"conversation\") {\n void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id) });\n void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });\n }\n },\n onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),\n onReconnect: () => void catchUp(),\n });\n return () => {\n channel.current?.unsubscribe();\n channel.current = null;\n };\n }, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);\n\n // Activity fades after a few seconds without news.\n useEffect(() => {\n if (activity.length === 0) return;\n const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2000);\n return () => clearTimeout(t);\n }, [activity]);\n\n const list = messages.data ?? [];\n const latestSeq = list.length ? list[list.length - 1]!.seq : 0;\n const myLastRead = members.data?.find((m) => m.userId === userId)?.lastReadSeq;\n useEffect(() => {\n if (readWhenOpened === null && myLastRead !== undefined) setReadWhenOpened(myLastRead);\n }, [myLastRead, readWhenOpened]);\n\n const markRead = useCallback(async () => {\n if (!enabled || latestSeq === 0) return;\n await client.markRead(id, latestSeq);\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });\n }, [client, enabled, id, latestSeq, queryClient]);\n\n const shouldMark = opts.markRead !== false;\n useEffect(() => {\n if (shouldMark && latestSeq > 0 && !opts.threadRootId) void markRead().catch(() => undefined);\n }, [shouldMark, latestSeq, markRead, opts.threadRootId]);\n\n const loadEarlier = useCallback(async () => {\n const first = list[0];\n if (!first || loadingEarlier) return;\n setLoadingEarlier(true);\n try {\n const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });\n merge(page.messages);\n setHasEarlier(page.hasEarlier);\n } finally {\n setLoadingEarlier(false);\n }\n }, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);\n\n const pending = useMemo(\n () =>\n queue.filter(\n (q) =>\n q.input.conversationId === id &&\n (q.input.threadRootId ?? null) === (opts.threadRootId ?? null) &&\n (!opts.topic || q.input.topic === opts.topic),\n ).map((q) => ({ ...q, files: q.pendingFiles })),\n [queue, id, opts.threadRootId, opts.topic],\n );\n const items: ThreadItem[] = useMemo(() => mergeThread(list, pending), [list, pending]);\n\n const signalTyping = useMemo(() => throttle(() => channel.current?.signal(\"typing\")), []);\n const signalRecording = useMemo(\n () => throttle(() => channel.current?.signal(\"recording-voice\")),\n [],\n );\n\n /** Sends text and files to this conversation (through the outbox: shown at once, retried safely). */\n const send = useCallback(\n (input: Omit<SendInput, \"conversationId\" | \"clientId\">, files: LocalFile[] = []) =>\n outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),\n [outbox, id, opts.threadRootId, opts.topic],\n );\n\n return {\n conversation: conversation.data ?? null,\n members: members.data ?? [],\n role: access.data?.role ?? null,\n canPost: access.data?.canPost ?? false,\n /** Your role and posting rights are still loading: show the composer as waiting, not refused. */\n accessLoading: enabled && access.isLoading,\n /** The kind's rules (max members, threads, who posts). */\n kind: kindRules.data ?? null,\n /** May you pin here: owners and admins, or anyone in a two-person conversation. */\n canPin:\n access.data?.role === \"owner\" ||\n access.data?.role === \"admin\" ||\n (access.data?.role === \"member\" && kindRules.data?.maxMembers === 2),\n /** Gives a message whose files were lost on a relaunch its files again. */\n reattach: outbox.reattach,\n items,\n messages: list,\n isLoading: messages.isLoading || conversation.isLoading,\n error: messages.error ?? conversation.error,\n hasEarlier,\n loadingEarlier,\n loadEarlier,\n /** Where to draw the \"New\" line, from what you had read when it opened. */\n firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),\n activity: currentActivity(activity),\n signalTyping,\n signalRecording,\n markRead,\n send,\n retry: outbox.retry,\n discard: outbox.discard,\n refetch: catchUp,\n };\n}\n\nexport type ConversationState = ReturnType<typeof useConversation>;\n\n/** Edit, delete, react, pin, save and report, refreshing what they change. */\nexport function useMessageActions() {\n const { client } = useChat();\n const queryClient = useQueryClient();\n return useMemo(() => {\n const refresh = async (conversationId: string, messageId: string) => {\n const m = await client.message(messageId);\n queryClient.setQueriesData<ChatMessage[]>({ queryKey: chatKeys.conversation(conversationId) }, (prev) =>\n Array.isArray(prev) && m ? upsertMessages(prev, [m]) : prev,\n );\n };\n return {\n edit: async (m: ChatMessage, body: string, mentions: string[] = []) => {\n await client.edit(m.id, body, mentions);\n await refresh(m.conversationId, m.id);\n },\n remove: async (m: ChatMessage) => {\n await client.remove(m.id);\n await refresh(m.conversationId, m.id);\n },\n react: async (m: ChatMessage, emoji: string, on: boolean) => {\n await client.react(m.id, emoji, on);\n await refresh(m.conversationId, m.id);\n },\n pin: async (m: ChatMessage, on: boolean) => {\n await client.pin(m.id, on);\n void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });\n },\n save: (m: ChatMessage, on: boolean, note?: string) => client.save(m.id, on, note),\n report: (m: ChatMessage, reason: \"spam\" | \"harassment\" | \"safety\" | \"other\", note?: string) => client.report(m.id, reason, note),\n };\n }, [client, queryClient]);\n}\n\n/**\n * Names and avatars for these people, from the provider's `loadPeople`, fetched as one\n * batch per set of ids and cached. Unknown ids are left out.\n */\nexport function usePeople(userIds: (string | null | undefined)[]): Record<string, ChatPerson> {\n const { loadPeople } = useChat();\n const ids = useMemo(() => [...new Set(userIds.filter((x): x is string => Boolean(x)))].sort(), [userIds]);\n return (\n useQuery({\n queryKey: [\"genesis-chat\", \"people\", ids],\n queryFn: () => loadPeople!(ids),\n enabled: Boolean(loadPeople) && ids.length > 0,\n staleTime: 5 * 60_000,\n }).data ?? {}\n );\n}\n\n/** One person's name and avatar (see usePeople). */\nexport function usePerson(userId: string | null | undefined): ChatPerson | null {\n const people = usePeople(useMemo(() => [userId], [userId]));\n return userId ? (people[userId] ?? null) : null;\n}\n\n/** The pinned message ids of a conversation. */\nexport function usePins(conversationId: string | null | undefined): string[] {\n const { client } = useChat();\n return (\n useQuery({ queryKey: chatKeys.pins(conversationId ?? \"\"), queryFn: () => client.pins(conversationId!), enabled: Boolean(conversationId) }).data ?? []\n );\n}\n\n/** Signed links for files (cached by the client until shortly before they expire). */\nexport function useFileUrls(paths: (string | null | undefined)[]): Record<string, string> {\n const { client } = useChat();\n const list = useMemo(() => [...new Set(paths.filter((p): p is string => Boolean(p)))], [paths]);\n return (\n useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 60_000 }).data ?? {}\n );\n}\n\n/** Starts (or finds again) a conversation, then refreshes the inbox. */\nexport function useStartConversation() {\n const { client } = useChat();\n const queryClient = useQueryClient();\n return useCallback(\n async (kind: string, memberIds: string[], opts?: { title?: string; subjectType?: string; subjectId?: string }) => {\n const id = await client.startConversation(kind, memberIds, opts);\n void queryClient.invalidateQueries({ queryKey: [\"genesis-chat\", \"inbox\"] });\n return id;\n },\n [client, queryClient],\n );\n}\n\nexport type { ThreadItem, LocalFile, QueuedMessage };\n"]}