@marktiderman/genesis-chat 0.1.1 → 0.2.1

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.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,609 @@
1
+ import { createOutbox, upsertMessages, currentActivity, mergeThread, throttle, firstUnreadId } from './chunk-A4YJX7KT.js';
2
+ import { sendBytes, attachmentKindFor, randomId } from './chunk-GBJ3YI4E.js';
3
+ import { useQueryClient, useQuery, useQueries } from '@tanstack/react-query';
4
+ import { createContext, useState, useRef, useMemo, useEffect, useContext, useCallback } from 'react';
5
+ import { jsx } from 'react/jsx-runtime';
6
+
7
+ var chatKeys = {
8
+ all: ["genesis-chat"],
9
+ inbox: (opts) => ["genesis-chat", "inbox", opts ?? {}],
10
+ unread: () => ["genesis-chat", "unread"],
11
+ conversation: (id) => ["genesis-chat", "conversation", id],
12
+ members: (id) => ["genesis-chat", "members", id],
13
+ role: (id) => ["genesis-chat", "role", id],
14
+ pins: (id) => ["genesis-chat", "pins", id],
15
+ files: (paths) => ["genesis-chat", "files", [...paths].sort()],
16
+ /** Under the conversation's key, so its `conversation` hint (set_topic broadcasts one) refreshes them. */
17
+ topics: (id) => ["genesis-chat", "conversation", id, "topics"],
18
+ scheduled: (conversationId) => ["genesis-chat", "scheduled", conversationId ?? null],
19
+ availability: (conversationId) => ["genesis-chat", "conversation", conversationId, "availability"],
20
+ myAvailability: () => ["genesis-chat", "my-availability"],
21
+ search: (query, conversationId) => ["genesis-chat", "search", conversationId ?? null, query],
22
+ previews: (messageIds) => ["genesis-chat", "previews", [...messageIds].sort()],
23
+ /** One message's link previews (useLinkPreviews keeps one query per message). */
24
+ preview: (messageId) => ["genesis-chat", "preview", messageId]
25
+ };
26
+ var CATCH_UP_PAGE = 200;
27
+ var CATCH_UP_MAX_PAGES = 10;
28
+ function isMessagesData(v) {
29
+ return typeof v === "object" && v !== null && Array.isArray(v.messages) && "hasEarlier" in v;
30
+ }
31
+ function upsertInto(prev, incoming) {
32
+ return isMessagesData(prev) ? { ...prev, messages: upsertMessages(prev.messages, incoming) } : void 0;
33
+ }
34
+ function belongsTo(key, m) {
35
+ 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);
36
+ }
37
+ function hostedUploadFrom(videoUpload, options = {}) {
38
+ return async (conversationId, file, { signal, onProgress, resume }) => {
39
+ const minted = resume?.url ? { url: resume.url, providerId: resume.providerId, provider: resume.provider } : await videoUpload.create(conversationId);
40
+ const done = (async () => {
41
+ const body = typeof file.data === "string" ? await (options.readFile ?? (() => Promise.reject(new Error("This file can't be read on this device"))))(file.data) : file.data;
42
+ await sendBytes(minted.url, body, { method: "PUT", headers: { "Content-Type": file.contentType }, signal, onProgress, fetch: options.fetch });
43
+ })();
44
+ return { provider: minted.provider, providerId: minted.providerId, status: "pending", url: minted.url, done };
45
+ };
46
+ }
47
+ var ChatContext = createContext(null);
48
+ function ChatProvider({
49
+ client,
50
+ userId,
51
+ outboxStore,
52
+ readFile,
53
+ upload,
54
+ hostedUpload,
55
+ newId,
56
+ persistFile,
57
+ releasePreview,
58
+ loadPeople,
59
+ videoUpload,
60
+ attachmentKinds,
61
+ children
62
+ }) {
63
+ const queryClient = useQueryClient();
64
+ const [queue, setQueue] = useState([]);
65
+ const listeners = useRef(/* @__PURE__ */ new Set());
66
+ const live = useRef({ readFile, upload, hostedUpload, videoUpload, persistFile });
67
+ live.current = { readFile, upload, hostedUpload, videoUpload, persistFile };
68
+ const outbox = useMemo(
69
+ () => createOutbox({
70
+ client,
71
+ userId,
72
+ store: outboxStore,
73
+ readFile: readFile && ((uri) => (live.current.readFile ?? readFile)(uri)),
74
+ upload: upload && ((...args) => (live.current.upload ?? upload)(...args)),
75
+ hostedUpload: hostedUpload || videoUpload ? (...args) => {
76
+ const now = live.current;
77
+ const video = now.videoUpload ?? videoUpload;
78
+ const send = now.hostedUpload ?? (video ? hostedUploadFrom(video, { readFile: now.readFile ?? readFile }) : hostedUpload);
79
+ return send(...args);
80
+ } : void 0,
81
+ newId,
82
+ persistFile: persistFile && ((file) => (live.current.persistFile ?? persistFile)(file)),
83
+ releasePreview,
84
+ onChange: setQueue,
85
+ onSent: (message) => {
86
+ queryClient.setQueriesData(
87
+ { predicate: (q) => belongsTo(q.queryKey, message) },
88
+ (prev) => upsertInto(prev, [message]) ?? prev
89
+ );
90
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] });
91
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
92
+ }
93
+ }),
94
+ // A new person or client is a new outbox.
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ [client, userId]
97
+ );
98
+ useEffect(() => {
99
+ void outbox.load().then(() => outbox.retryAll()).catch(() => void 0);
100
+ const online = () => void outbox.retryAll().catch(() => void 0);
101
+ const target = typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null;
102
+ target?.addEventListener("online", online);
103
+ const sub = client.subscribeInbox(userId, (signal) => {
104
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
105
+ void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
106
+ for (const l of listeners.current) l(signal);
107
+ });
108
+ return () => {
109
+ sub.unsubscribe();
110
+ target?.removeEventListener("online", online);
111
+ };
112
+ }, [client, userId, outbox, queryClient]);
113
+ const value = useMemo(
114
+ () => ({
115
+ client,
116
+ userId,
117
+ outbox,
118
+ queue,
119
+ loadPeople,
120
+ videoUpload,
121
+ attachmentKinds,
122
+ retryAll: () => outbox.retryAll(),
123
+ clearOutbox: () => outbox.clear(),
124
+ onInbox: (l) => {
125
+ listeners.current.add(l);
126
+ return () => listeners.current.delete(l);
127
+ }
128
+ }),
129
+ [client, userId, outbox, queue, loadPeople, videoUpload, attachmentKinds]
130
+ );
131
+ return /* @__PURE__ */ jsx(ChatContext.Provider, { value, children });
132
+ }
133
+ function useChat() {
134
+ const ctx = useContext(ChatContext);
135
+ if (!ctx) throw new Error("Chat hooks need a <ChatProvider> above them.");
136
+ return ctx;
137
+ }
138
+ function useInbox(opts = {}) {
139
+ const { client } = useChat();
140
+ const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15e3 });
141
+ return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };
142
+ }
143
+ function useUnreadTotal(opts = {}) {
144
+ const { client } = useChat();
145
+ return useQuery({ queryKey: [...chatKeys.unread(), opts.excludeKinds ?? []], queryFn: () => client.unreadTotal(opts), staleTime: 15e3 }).data ?? 0;
146
+ }
147
+ function useConversation(conversationId, opts = {}) {
148
+ const { client, userId, outbox, queue } = useChat();
149
+ const queryClient = useQueryClient();
150
+ const id = conversationId ?? "";
151
+ const enabled = Boolean(conversationId);
152
+ const pageSize = opts.pageSize ?? 50;
153
+ const messagesKey = useMemo(
154
+ () => [...chatKeys.conversation(id), "messages", opts.threadRootId ?? null, opts.topic ?? null],
155
+ [id, opts.threadRootId, opts.topic]
156
+ );
157
+ const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });
158
+ const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 6e4, refetchOnMount: "always" });
159
+ const kindRules = useQuery({
160
+ queryKey: ["genesis-chat", "kind", conversation.data?.kind ?? ""],
161
+ queryFn: () => client.kind(conversation.data.kind),
162
+ enabled: Boolean(conversation.data?.kind),
163
+ staleTime: 10 * 6e4
164
+ });
165
+ const access = useQuery({
166
+ queryKey: chatKeys.role(id),
167
+ queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),
168
+ enabled,
169
+ staleTime: 6e4
170
+ });
171
+ const [loadingEarlier, setLoadingEarlier] = useState(false);
172
+ const fetchPage = useCallback(
173
+ () => client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic }),
174
+ [client, id, pageSize, opts.threadRootId, opts.topic]
175
+ );
176
+ const messages = useQuery({ queryKey: messagesKey, queryFn: fetchPage, enabled, staleTime: Infinity });
177
+ const hasEarlier = messages.data?.hasEarlier ?? false;
178
+ const [readWhenOpened, setReadWhenOpened] = useState(null);
179
+ const merge = useCallback(
180
+ (incoming, hasEarlierNow) => queryClient.setQueryData(messagesKey, (prev) => {
181
+ const base = prev ?? { messages: [], hasEarlier: false };
182
+ return { messages: upsertMessages(base.messages, incoming), hasEarlier: hasEarlierNow ?? base.hasEarlier };
183
+ }),
184
+ [queryClient, messagesKey]
185
+ );
186
+ const catchUp = useCallback(async () => {
187
+ const current = queryClient.getQueryData(messagesKey) ?? await queryClient.ensureQueryData({ queryKey: messagesKey, queryFn: fetchPage });
188
+ const list2 = current.messages;
189
+ let after = list2.length ? list2[list2.length - 1].seq : 0;
190
+ for (let pages = 0; ; ) {
191
+ const page = await client.messages(id, { afterSeq: after, limit: CATCH_UP_PAGE, threadRootId: opts.threadRootId, topic: opts.topic });
192
+ if (page.messages.length) merge(page.messages);
193
+ if (page.messages.length < CATCH_UP_PAGE) return;
194
+ after = Math.max(after, ...page.messages.map((m) => m.seq));
195
+ if (++pages >= CATCH_UP_MAX_PAGES) break;
196
+ }
197
+ const newest = await fetchPage();
198
+ queryClient.setQueryData(messagesKey, { messages: newest.messages, hasEarlier: true });
199
+ }, [client, fetchPage, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);
200
+ const [activity, setActivity] = useState([]);
201
+ const channel = useRef(null);
202
+ useEffect(() => {
203
+ if (!enabled) return;
204
+ const fetchOne = async (messageId) => {
205
+ const m = await client.message(messageId);
206
+ if (!m) return;
207
+ const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;
208
+ if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);
209
+ if (m.threadRootId && !opts.threadRootId) {
210
+ const root = await client.message(m.threadRootId);
211
+ if (root) merge([root]);
212
+ }
213
+ };
214
+ channel.current = client.subscribeConversation(id, userId, {
215
+ onSignal: (s) => {
216
+ if (s.type === "message") void fetchOne(s.id);
217
+ else if (s.type === "reaction") void fetchOne(s.messageId);
218
+ else if (s.type === "members") {
219
+ void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });
220
+ void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });
221
+ } else if (s.type === "conversation") {
222
+ void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id), exact: true });
223
+ void queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
224
+ void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });
225
+ }
226
+ },
227
+ onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),
228
+ onJoin: () => void catchUp().catch(() => void 0)
229
+ });
230
+ return () => {
231
+ channel.current?.unsubscribe();
232
+ channel.current = null;
233
+ };
234
+ }, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);
235
+ useEffect(() => {
236
+ if (activity.length === 0) return;
237
+ const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2e3);
238
+ return () => clearTimeout(t);
239
+ }, [activity]);
240
+ const list = useMemo(() => messages.data?.messages ?? [], [messages.data]);
241
+ const latestSeq = list.length ? list[list.length - 1].seq : 0;
242
+ const myLastRead = members.isFetchedAfterMount ? members.data?.find((m) => m.userId === userId)?.lastReadSeq : void 0;
243
+ useEffect(() => {
244
+ if (readWhenOpened === null && myLastRead !== void 0) setReadWhenOpened(myLastRead);
245
+ }, [myLastRead, readWhenOpened]);
246
+ const markRead = useCallback(async () => {
247
+ if (!enabled || latestSeq === 0) return;
248
+ await client.markRead(id, latestSeq);
249
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
250
+ void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
251
+ }, [client, enabled, id, latestSeq, queryClient]);
252
+ const shouldMark = opts.markRead !== false && !opts.threadRootId && !opts.topic;
253
+ useEffect(() => {
254
+ if (shouldMark && latestSeq > 0) void markRead().catch(() => void 0);
255
+ }, [shouldMark, latestSeq, markRead]);
256
+ const refetchConversation = useCallback(
257
+ () => Promise.all([
258
+ // The conversation and what hangs off it (topics, availability), never its message lists:
259
+ // a refetch would replace loaded history and merged live messages with one fresh page.
260
+ queryClient.refetchQueries({ queryKey: chatKeys.conversation(id), predicate: (q) => q.queryKey[3] !== "messages" }),
261
+ queryClient.refetchQueries({ queryKey: chatKeys.members(id) }),
262
+ queryClient.refetchQueries({ queryKey: chatKeys.role(id) }),
263
+ queryClient.refetchQueries({ queryKey: chatKeys.pins(id) }),
264
+ queryClient.refetchQueries({ queryKey: ["genesis-chat", "kind"] })
265
+ ]).then(() => void 0),
266
+ [queryClient, id]
267
+ );
268
+ const loadEarlier = useCallback(async () => {
269
+ const first = list[0];
270
+ if (!first || loadingEarlier) return;
271
+ setLoadingEarlier(true);
272
+ try {
273
+ const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
274
+ merge(page.messages, page.hasEarlier);
275
+ } finally {
276
+ setLoadingEarlier(false);
277
+ }
278
+ }, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);
279
+ const loadUntil = useCallback(
280
+ async (seq) => {
281
+ let current = queryClient.getQueryData(messagesKey);
282
+ for (let guard = 0; guard < 20 && current; guard++) {
283
+ const first = current.messages[0];
284
+ if (!first || first.seq <= seq || !current.hasEarlier) break;
285
+ const page = await client.messages(id, {
286
+ beforeSeq: first.seq,
287
+ limit: Math.min(200, first.seq - seq + 10),
288
+ threadRootId: opts.threadRootId,
289
+ topic: opts.topic
290
+ });
291
+ merge(page.messages, page.hasEarlier);
292
+ current = queryClient.getQueryData(messagesKey);
293
+ }
294
+ return Boolean(current?.messages.some((m) => m.seq === seq));
295
+ },
296
+ [client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]
297
+ );
298
+ const pending = useMemo(
299
+ () => queue.filter(
300
+ (q) => q.input.conversationId === id && (q.input.threadRootId ?? null) === (opts.threadRootId ?? null) && (!opts.topic || q.input.topic === opts.topic)
301
+ ).map((q) => ({ ...q, files: q.pendingFiles })),
302
+ [queue, id, opts.threadRootId, opts.topic]
303
+ );
304
+ const items = useMemo(() => mergeThread(list, pending), [list, pending]);
305
+ const signalTyping = useMemo(() => throttle(() => channel.current?.signal("typing")), []);
306
+ const signalRecording = useMemo(
307
+ () => throttle(() => channel.current?.signal("recording-voice")),
308
+ []
309
+ );
310
+ const signalRecordingVideo = useMemo(() => throttle(() => channel.current?.signal("recording-video")), []);
311
+ const send = useCallback(
312
+ (input, files = []) => outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),
313
+ [outbox, id, opts.threadRootId, opts.topic]
314
+ );
315
+ return {
316
+ conversation: conversation.data ?? null,
317
+ members: members.data ?? [],
318
+ role: access.data?.role ?? null,
319
+ canPost: access.data?.canPost ?? false,
320
+ /** Your role and posting rights are still loading: show the composer as waiting, not refused. */
321
+ accessLoading: enabled && access.isLoading,
322
+ /** The conversation itself is still loading (it settles to `null` when you can't see it). */
323
+ conversationLoading: conversation.isLoading,
324
+ /** Your role couldn't be loaded (offline): offer `refetchAccess`, don't say "can't post". */
325
+ accessError: access.error ?? null,
326
+ /** Retries everything this conversation loads (role, members, the conversation, topics, availability, pins), not only access. */
327
+ refetchAccess: refetchConversation,
328
+ /** Same as refetchAccess: every per-conversation lookup again, for a Retry after being offline. */
329
+ refetchConversation,
330
+ /** The kind's rules (max members, threads, who posts). */
331
+ kind: kindRules.data ?? null,
332
+ /** May you pin here: owners and admins, or anyone in a two-person conversation. */
333
+ canPin: access.data?.role === "owner" || access.data?.role === "admin" || access.data?.role === "member" && kindRules.data?.maxMembers === 2,
334
+ /** Gives a message whose files were lost on a relaunch its files again. */
335
+ reattach: outbox.reattach,
336
+ items,
337
+ messages: list,
338
+ isLoading: messages.isLoading || conversation.isLoading,
339
+ error: messages.error ?? conversation.error,
340
+ hasEarlier,
341
+ loadingEarlier,
342
+ loadEarlier,
343
+ loadUntil,
344
+ /** Where to draw the "New" line, from what you had read when it opened. */
345
+ firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),
346
+ activity: currentActivity(activity),
347
+ signalTyping,
348
+ signalRecording,
349
+ /** Tells the others you are recording a video (throttled). */
350
+ signalRecordingVideo,
351
+ markRead,
352
+ send,
353
+ retry: outbox.retry,
354
+ discard: outbox.discard,
355
+ refetch: catchUp
356
+ };
357
+ }
358
+ function useMessageActions() {
359
+ const { client } = useChat();
360
+ const queryClient = useQueryClient();
361
+ return useMemo(() => {
362
+ const refresh = async (conversationId, messageId) => {
363
+ const m = await client.message(messageId);
364
+ queryClient.setQueriesData(
365
+ { queryKey: chatKeys.conversation(conversationId) },
366
+ (prev) => m ? upsertInto(prev, [m]) ?? prev : prev
367
+ );
368
+ };
369
+ return {
370
+ edit: async (m, body, mentions = []) => {
371
+ await client.edit(m.id, body, mentions);
372
+ await refresh(m.conversationId, m.id);
373
+ },
374
+ remove: async (m) => {
375
+ await client.remove(m.id);
376
+ await refresh(m.conversationId, m.id);
377
+ },
378
+ react: async (m, emoji, on) => {
379
+ await client.react(m.id, emoji, on);
380
+ await refresh(m.conversationId, m.id);
381
+ },
382
+ pin: async (m, on) => {
383
+ await client.pin(m.id, on);
384
+ void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });
385
+ },
386
+ save: (m, on, note) => client.save(m.id, on, note),
387
+ report: (m, reason, note) => client.report(m.id, reason, note)
388
+ };
389
+ }, [client, queryClient]);
390
+ }
391
+ function usePeople(userIds) {
392
+ const { loadPeople } = useChat();
393
+ const ids = useMemo(() => [...new Set(userIds.filter((x) => Boolean(x)))].sort(), [userIds]);
394
+ return useQuery({
395
+ queryKey: ["genesis-chat", "people", ids],
396
+ queryFn: () => loadPeople(ids),
397
+ enabled: Boolean(loadPeople) && ids.length > 0,
398
+ staleTime: 5 * 6e4
399
+ }).data ?? {};
400
+ }
401
+ function usePerson(userId) {
402
+ const people = usePeople(useMemo(() => [userId], [userId]));
403
+ return userId ? people[userId] ?? null : null;
404
+ }
405
+ function usePins(conversationId) {
406
+ const { client } = useChat();
407
+ return useQuery({ queryKey: chatKeys.pins(conversationId ?? ""), queryFn: () => client.pins(conversationId), enabled: Boolean(conversationId) }).data ?? [];
408
+ }
409
+ function useFileUrls(paths) {
410
+ const { client } = useChat();
411
+ const list = useMemo(() => [...new Set(paths.filter((p) => Boolean(p)))], [paths]);
412
+ return useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 6e4 }).data ?? {};
413
+ }
414
+ function useStartConversation() {
415
+ const { client } = useChat();
416
+ const queryClient = useQueryClient();
417
+ return useCallback(
418
+ async (kind, memberIds, opts) => {
419
+ const id = await client.startConversation(kind, memberIds, opts);
420
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
421
+ return id;
422
+ },
423
+ [client, queryClient]
424
+ );
425
+ }
426
+ function useTopics(conversationId) {
427
+ const { client } = useChat();
428
+ const queryClient = useQueryClient();
429
+ const id = conversationId ?? "";
430
+ const query = useQuery({ queryKey: chatKeys.topics(id), queryFn: () => client.topics(id), enabled: Boolean(conversationId), staleTime: 6e4 });
431
+ const setTopic = useCallback(
432
+ async (topic, input = {}) => {
433
+ await client.setTopic(id, topic, input);
434
+ await queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
435
+ },
436
+ [client, id, queryClient]
437
+ );
438
+ const topics = query.data ?? [];
439
+ return { topics, isLoading: query.isLoading, error: query.error, setTopic, refetch: query.refetch };
440
+ }
441
+ function useScheduled(conversationId, opts = {}) {
442
+ const { client, outbox } = useChat();
443
+ const queryClient = useQueryClient();
444
+ const query = useQuery({
445
+ queryKey: chatKeys.scheduled(conversationId),
446
+ queryFn: () => client.scheduled(conversationId ?? void 0),
447
+ enabled: opts.enabled ?? true,
448
+ staleTime: 3e4
449
+ });
450
+ const refresh = useCallback(() => queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] }), [queryClient]);
451
+ const schedule = useCallback(
452
+ async (input, files = []) => {
453
+ const attachments = [...input.attachments ?? []];
454
+ for (const f of files) {
455
+ const path = await outbox.uploadFile(input.conversationId, f);
456
+ attachments.push({ ...f.meta, kind: f.meta?.kind ?? attachmentKindFor(f.contentType), path, name: f.meta?.name ?? f.name, mime: f.contentType });
457
+ }
458
+ const id = await client.schedule({ ...input, clientId: input.clientId ?? randomId(), attachments });
459
+ await refresh();
460
+ return id;
461
+ },
462
+ [client, outbox, refresh]
463
+ );
464
+ const cancel = useCallback(
465
+ async (id) => {
466
+ queryClient.setQueriesData({ queryKey: ["genesis-chat", "scheduled"] }, (prev) => Array.isArray(prev) ? prev.filter((s) => s.id !== id) : prev);
467
+ try {
468
+ await client.cancelScheduled(id);
469
+ } finally {
470
+ await refresh();
471
+ }
472
+ },
473
+ [client, queryClient, refresh]
474
+ );
475
+ const items = query.data ?? [];
476
+ return { items, isLoading: query.isLoading, error: query.error, schedule, cancel, refetch: query.refetch };
477
+ }
478
+ function useAvailability(conversationId) {
479
+ const { client, userId } = useChat();
480
+ const queryClient = useQueryClient();
481
+ const mine = useQuery({ queryKey: chatKeys.myAvailability(), queryFn: () => client.myAvailability(), staleTime: 5 * 6e4 });
482
+ const members = useQuery({
483
+ queryKey: chatKeys.availability(conversationId ?? ""),
484
+ queryFn: () => client.availabilityFor(conversationId),
485
+ enabled: Boolean(conversationId),
486
+ staleTime: 5 * 6e4
487
+ });
488
+ const setMine = useCallback(
489
+ async (input) => {
490
+ await client.setAvailability(input);
491
+ await queryClient.invalidateQueries({ queryKey: chatKeys.myAvailability() });
492
+ await queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === "genesis-chat" && q.queryKey[3] === "availability" });
493
+ },
494
+ [client, queryClient]
495
+ );
496
+ const list = members.data ?? [];
497
+ const forUser = useCallback((id) => list.find((a) => a.userId === id) ?? null, [list]);
498
+ return {
499
+ mine: mine.data ?? null,
500
+ mineLoading: mine.isLoading,
501
+ setMine,
502
+ members: list,
503
+ /** Everyone else's, without yours. */
504
+ others: list.filter((a) => a.userId !== userId),
505
+ forUser,
506
+ isLoading: mine.isLoading || Boolean(conversationId) && members.isLoading
507
+ };
508
+ }
509
+ function useBroadcast() {
510
+ const { client } = useChat();
511
+ const queryClient = useQueryClient();
512
+ const [sending, setSending] = useState(false);
513
+ const send = useCallback(
514
+ async (conversationIds, body, opts = {}) => {
515
+ setSending(true);
516
+ try {
517
+ const ids = await client.broadcast(conversationIds, opts.clientId ?? randomId(), body, { silent: opts.silent });
518
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
519
+ return ids;
520
+ } finally {
521
+ setSending(false);
522
+ }
523
+ },
524
+ [client, queryClient]
525
+ );
526
+ return { send, sending };
527
+ }
528
+ function useDebouncedValue(value, ms) {
529
+ const [v, setV] = useState(value);
530
+ useEffect(() => {
531
+ if (ms <= 0) {
532
+ setV(value);
533
+ return;
534
+ }
535
+ const t = setTimeout(() => setV(value), ms);
536
+ return () => clearTimeout(t);
537
+ }, [value, ms]);
538
+ return ms <= 0 ? value : v;
539
+ }
540
+ function useSearch(query, opts = {}) {
541
+ const { client } = useChat();
542
+ const q = useDebouncedValue(query.trim(), opts.debounceMs ?? 250);
543
+ const enabled = q.length >= 2;
544
+ const result = useQuery({
545
+ queryKey: chatKeys.search(q, opts.conversationId),
546
+ queryFn: () => client.search(q, opts.conversationId ?? null, { limit: opts.limit }),
547
+ enabled,
548
+ staleTime: 3e4
549
+ });
550
+ const hits = enabled ? result.data ?? [] : [];
551
+ return {
552
+ hits,
553
+ /** The query the hits are for. */
554
+ query: q,
555
+ /** Typing, or fetching: results are on their way. */
556
+ isSearching: query.trim() !== q || enabled && result.isFetching,
557
+ error: result.error
558
+ };
559
+ }
560
+ var LINK_PREVIEW_CONCURRENCY = 4;
561
+ var previewGates = /* @__PURE__ */ new WeakMap();
562
+ function gate(max) {
563
+ let active = 0;
564
+ const waiting = [];
565
+ return async (task) => {
566
+ if (active >= max) await new Promise((r) => waiting.push(r));
567
+ active += 1;
568
+ try {
569
+ return await task();
570
+ } finally {
571
+ active -= 1;
572
+ waiting.shift()?.();
573
+ }
574
+ };
575
+ }
576
+ function useLinkPreviews(messageIds) {
577
+ const { client } = useChat();
578
+ const list = useMemo(() => [...new Set(messageIds.filter((u) => Boolean(u)))].sort(), [messageIds]);
579
+ let run = previewGates.get(client);
580
+ if (!run) {
581
+ run = gate(LINK_PREVIEW_CONCURRENCY);
582
+ previewGates.set(client, run);
583
+ }
584
+ const limited = run;
585
+ const combine = useCallback(
586
+ (results) => {
587
+ let out = null;
588
+ results.forEach((r, i) => {
589
+ if (r.data) (out ??= {})[list[i]] = r.data;
590
+ });
591
+ return out ?? EMPTY_PREVIEWS;
592
+ },
593
+ [list]
594
+ );
595
+ return useQueries({
596
+ queries: list.map((id) => ({
597
+ queryKey: chatKeys.preview(id),
598
+ queryFn: () => limited(async () => (await client.linkPreviewsFor([id]))[id] ?? []),
599
+ staleTime: 6e4,
600
+ refetchInterval: (q) => !q.state.data?.length && q.state.dataUpdateCount < 4 ? 5e3 : false
601
+ })),
602
+ combine
603
+ });
604
+ }
605
+ var EMPTY_PREVIEWS = {};
606
+
607
+ export { CATCH_UP_MAX_PAGES, CATCH_UP_PAGE, ChatProvider, LINK_PREVIEW_CONCURRENCY, belongsTo, chatKeys, hostedUploadFrom, useAvailability, useBroadcast, useChat, useConversation, useDebouncedValue, useFileUrls, useInbox, useLinkPreviews, useMessageActions, usePeople, usePerson, usePins, useScheduled, useSearch, useStartConversation, useTopics, useUnreadTotal };
608
+ //# sourceMappingURL=chunk-JU2FAPBI.js.map
609
+ //# sourceMappingURL=chunk-JU2FAPBI.js.map