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