@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,894 @@
1
+ /**
2
+ * @feat:chat
3
+ * React hooks for chat, the same on web and native: the inbox, one
4
+ * conversation (its messages, members, your role, who is typing) kept live by
5
+ * Realtime hints, sending through the outbox, and message actions. Server
6
+ * state is TanStack Query's; the renderers in /web and /native only draw.
7
+ */
8
+ import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
10
+ import type { ChatClient, ConversationChannel, MessagePage } from "../core/client";
11
+ import {
12
+ createOutbox,
13
+ type HostedUpload,
14
+ type LocalFile,
15
+ type Outbox,
16
+ type OutboxStore,
17
+ type QueuedMessage,
18
+ type StorageUpload,
19
+ } from "../core/outbox";
20
+ import { sendBytes } from "../core/transfer";
21
+ import { currentActivity, firstUnreadId, mergeThread, throttle, upsertMessages, type ThreadItem } from "../core/thread";
22
+ import { attachmentKindFor, randomId } from "../core/media";
23
+ import type {
24
+ AvailabilityInput,
25
+ ChatActivity,
26
+ ChatAttachment,
27
+ ChatAvailability,
28
+ ChatLinkPreview,
29
+ ChatMessage,
30
+ ChatSearchHit,
31
+ ChatSignal,
32
+ ChatTopic,
33
+ ScheduleInput,
34
+ ScheduledMessage,
35
+ SendInput,
36
+ TopicInput,
37
+ } from "../core/types";
38
+
39
+ export const chatKeys = {
40
+ all: ["genesis-chat"] as const,
41
+ inbox: (opts?: object) => ["genesis-chat", "inbox", opts ?? {}] as const,
42
+ unread: () => ["genesis-chat", "unread"] as const,
43
+ conversation: (id: string) => ["genesis-chat", "conversation", id] as const,
44
+ members: (id: string) => ["genesis-chat", "members", id] as const,
45
+ role: (id: string) => ["genesis-chat", "role", id] as const,
46
+ pins: (id: string) => ["genesis-chat", "pins", id] as const,
47
+ files: (paths: string[]) => ["genesis-chat", "files", [...paths].sort()] as const,
48
+ /** Under the conversation's key, so its `conversation` hint (set_topic broadcasts one) refreshes them. */
49
+ topics: (id: string) => ["genesis-chat", "conversation", id, "topics"] as const,
50
+ scheduled: (conversationId?: string | null) => ["genesis-chat", "scheduled", conversationId ?? null] as const,
51
+ availability: (conversationId: string) => ["genesis-chat", "conversation", conversationId, "availability"] as const,
52
+ myAvailability: () => ["genesis-chat", "my-availability"] as const,
53
+ search: (query: string, conversationId?: string | null) => ["genesis-chat", "search", conversationId ?? null, query] as const,
54
+ previews: (messageIds: string[]) => ["genesis-chat", "previews", [...messageIds].sort()] as const,
55
+ /** One message's link previews (useLinkPreviews keeps one query per message). */
56
+ preview: (messageId: string) => ["genesis-chat", "preview", messageId] as const,
57
+ };
58
+
59
+ /** How many messages a catch-up asks for at a time, and how many pages before it gives up and jumps to the newest. */
60
+ export const CATCH_UP_PAGE = 200;
61
+ export const CATCH_UP_MAX_PAGES = 10;
62
+
63
+ /** What a messages list holds in the query cache: the page, and whether older messages exist. */
64
+ export type MessagesData = MessagePage;
65
+
66
+ function isMessagesData(v: unknown): v is MessagesData {
67
+ return typeof v === "object" && v !== null && Array.isArray((v as MessagesData).messages) && "hasEarlier" in v;
68
+ }
69
+
70
+ /** Adds or replaces messages in a cached list, keeping `hasEarlier`. */
71
+ function upsertInto(prev: unknown, incoming: ChatMessage[]): MessagesData | undefined {
72
+ return isMessagesData(prev) ? { ...prev, messages: upsertMessages(prev.messages, incoming) } : undefined;
73
+ }
74
+
75
+ /** Does a messages list (`[genesis-chat, conversation, id, messages, threadRootId, topic]`) hold this message? */
76
+ export function belongsTo(key: readonly unknown[], m: ChatMessage): boolean {
77
+ return (
78
+ key[0] === "genesis-chat" &&
79
+ key[1] === "conversation" &&
80
+ key[2] === m.conversationId &&
81
+ key[3] === "messages" &&
82
+ (key[4] ?? null) === (m.threadRootId ?? null) &&
83
+ (key[5] == null || key[5] === m.topic)
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Hosted video uploads (Mux direct uploads): your server makes an upload and returns its URL
89
+ * (`createVideoUploadHandler` in /server). A video the composer marks `hosted` is sent through
90
+ * the outbox at once, in order, with `{ kind: "video", provider, providerId, status: "pending" }`,
91
+ * while its bytes are PUT to that URL (see `hostedUploadFrom`). Without it, videos go to storage
92
+ * as files.
93
+ */
94
+ export interface ChatVideoUpload {
95
+ create(conversationId: string): Promise<{ url: string; providerId: string; provider: "mux" }>;
96
+ }
97
+
98
+ /**
99
+ * The outbox's `hostedUpload` from a `ChatVideoUpload`: mints the upload (or, resuming, reuses
100
+ * the one minted before), resolves at once with its id, and PUTs the whole file to its URL in
101
+ * the background (XMLHttpRequest where there is one, for progress). A native file URI is read
102
+ * with `readFile`. Native apps with chunked, resumable uploads pass their own `hostedUpload`.
103
+ */
104
+ export function hostedUploadFrom(
105
+ videoUpload: ChatVideoUpload,
106
+ options: { readFile?: (uri: string) => Promise<Blob | ArrayBuffer>; fetch?: typeof fetch } = {},
107
+ ): HostedUpload {
108
+ return async (conversationId, file, { signal, onProgress, resume }) => {
109
+ const minted = resume?.url
110
+ ? { url: resume.url, providerId: resume.providerId, provider: resume.provider }
111
+ : await videoUpload.create(conversationId);
112
+ const done = (async () => {
113
+ const body =
114
+ typeof file.data === "string"
115
+ ? await (options.readFile ?? (() => Promise.reject(new Error("This file can't be read on this device"))))(file.data)
116
+ : file.data;
117
+ await sendBytes(minted.url, body, { method: "PUT", headers: { "Content-Type": file.contentType }, signal, onProgress, fetch: options.fetch });
118
+ })();
119
+ return { provider: minted.provider, providerId: minted.providerId, status: "pending", url: minted.url, done };
120
+ };
121
+ }
122
+
123
+ /** What a kind's `title`, `subtitle` and `action` are told besides the attachment. */
124
+ export interface ChatCardContext {
125
+ /** The card is on a message you sent (your own check-in reads "Sent", not "Answer"). */
126
+ mine: boolean;
127
+ }
128
+
129
+ /** What an app attachment kind's card shows (a form, a check-in): plain data, drawn by each renderer. */
130
+ export interface ChatAttachmentKind {
131
+ title(attachment: ChatAttachment, ctx?: ChatCardContext): string;
132
+ subtitle?(attachment: ChatAttachment, ctx?: ChatCardContext): string | null | undefined;
133
+ /** The card's button: a link (`href`) or a handler (`onPress`). */
134
+ action?(attachment: ChatAttachment, ctx?: ChatCardContext): { label: string; href?: string; onPress?: () => void } | null | undefined;
135
+ /** What previews call it (the inbox's last message, "Check-in"); the title by default. */
136
+ label?: string;
137
+ }
138
+
139
+ interface ChatContextValue {
140
+ client: ChatClient;
141
+ userId: string;
142
+ outbox: Outbox;
143
+ queue: QueuedMessage[];
144
+ loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;
145
+ videoUpload?: ChatVideoUpload;
146
+ attachmentKinds?: Record<string, ChatAttachmentKind>;
147
+ /** Retries every failed message (call it when the connection comes back: AppState, NetInfo). */
148
+ retryAll(): Promise<void>;
149
+ /** Forgets every unsent message, here and in the outbox store (call it on sign-out). */
150
+ clearOutbox(): Promise<void>;
151
+ /** Subscribe to inbox hints (one channel for the app, fanned out here). */
152
+ onInbox(listener: (s: ChatSignal) => void): () => void;
153
+ }
154
+
155
+ const ChatContext = createContext<ChatContextValue | null>(null);
156
+
157
+ export interface ChatProviderProps {
158
+ client: ChatClient;
159
+ /** The signed-in user's id (auth.users). */
160
+ userId: string;
161
+ /** Where unsent messages wait between launches (localStorage, MMKV…). Memory by default. */
162
+ outboxStore?: OutboxStore;
163
+ /** Reads a native file URI for upload. */
164
+ readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;
165
+ /**
166
+ * Replaces the plain upload (resumable uploads on native). `signal` aborts when the message is
167
+ * discarded or the outbox cleared; `onProgress(sent, total)` fills the pending bubble's bar.
168
+ */
169
+ upload?: StorageUpload;
170
+ /**
171
+ * Starts a hosted upload for files marked `hosted` (see HostedUpload): resolve with
172
+ * `{ provider, providerId, status: "pending", url?, done }` once the upload id exists. Default:
173
+ * `hostedUploadFrom(videoUpload)` when `videoUpload` is given.
174
+ */
175
+ hostedUpload?: HostedUpload;
176
+ /** Makes client ids (default: a random UUID that works without crypto.randomUUID). */
177
+ newId?: () => string;
178
+ /** Keeps a byte file (a pasted image, a recording) across launches: write it, return a URI. */
179
+ persistFile?: (file: LocalFile) => Promise<string>;
180
+ /** Lets go of a sent file's preview (default: revokes `blob:` object URLs only). */
181
+ releasePreview?: (url: string) => void;
182
+ /** Names and avatars for user ids, fetched in batches (usePeople, usePerson). */
183
+ loadPeople?: (userIds: string[]) => Promise<Record<string, ChatPerson>>;
184
+ /** Hosted video uploads (Mux); videos go to storage without it. */
185
+ videoUpload?: ChatVideoUpload;
186
+ /** Cards for your own attachment kinds (`{ form: { title, subtitle, action } }`). */
187
+ attachmentKinds?: Record<string, ChatAttachmentKind>;
188
+ children: ReactNode;
189
+ }
190
+
191
+ /** How the app shows a person. */
192
+ export interface ChatPerson {
193
+ name: string;
194
+ avatarUrl?: string | null;
195
+ }
196
+
197
+ /** Puts chat in reach of every hook below it. Needs a TanStack QueryClientProvider above it. */
198
+ export function ChatProvider({
199
+ client,
200
+ userId,
201
+ outboxStore,
202
+ readFile,
203
+ upload,
204
+ hostedUpload,
205
+ newId,
206
+ persistFile,
207
+ releasePreview,
208
+ loadPeople,
209
+ videoUpload,
210
+ attachmentKinds,
211
+ children,
212
+ }: ChatProviderProps) {
213
+ const queryClient = useQueryClient();
214
+ const [queue, setQueue] = useState<QueuedMessage[]>([]);
215
+ const listeners = useRef(new Set<(s: ChatSignal) => void>());
216
+ // The outbox lives as long as the client and user, so it reads the app's functions here at call
217
+ // time: an inline `videoUpload.create` closing over a refreshed token keeps working.
218
+ const live = useRef({ readFile, upload, hostedUpload, videoUpload, persistFile });
219
+ live.current = { readFile, upload, hostedUpload, videoUpload, persistFile };
220
+
221
+ const outbox = useMemo(
222
+ () =>
223
+ createOutbox({
224
+ client,
225
+ userId,
226
+ store: outboxStore,
227
+ readFile: readFile && ((uri) => (live.current.readFile ?? readFile)(uri)),
228
+ upload: upload && ((...args) => (live.current.upload ?? upload)(...args)),
229
+ hostedUpload:
230
+ hostedUpload || videoUpload
231
+ ? (...args) => {
232
+ const now = live.current;
233
+ const video = now.videoUpload ?? videoUpload;
234
+ const send = now.hostedUpload ?? (video ? hostedUploadFrom(video, { readFile: now.readFile ?? readFile }) : hostedUpload!);
235
+ return send(...args);
236
+ }
237
+ : undefined,
238
+ newId,
239
+ persistFile: persistFile && ((file) => (live.current.persistFile ?? persistFile)(file)),
240
+ releasePreview,
241
+ onChange: setQueue,
242
+ onSent: (message) => {
243
+ // Into every loaded list it belongs to (the main timeline, its thread, its topic), so
244
+ // the bubble never disappears between the send landing and the live hint.
245
+ queryClient.setQueriesData<MessagesData>(
246
+ { predicate: (q) => belongsTo(q.queryKey, message) },
247
+ (prev) => upsertInto(prev, [message]) ?? prev,
248
+ );
249
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] });
250
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
251
+ },
252
+ }),
253
+ // A new person or client is a new outbox.
254
+ // eslint-disable-next-line react-hooks/exhaustive-deps
255
+ [client, userId],
256
+ );
257
+
258
+ useEffect(() => {
259
+ // What a relaunch left unsent is retried once (messages waiting for a lost file stay put),
260
+ // and again whenever the browser comes back online.
261
+ void outbox.load().then(() => outbox.retryAll()).catch(() => undefined);
262
+ const online = () => void outbox.retryAll().catch(() => undefined);
263
+ const target = typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null;
264
+ target?.addEventListener("online", online);
265
+ const sub = client.subscribeInbox(userId, (signal) => {
266
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
267
+ void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
268
+ for (const l of listeners.current) l(signal);
269
+ });
270
+ return () => {
271
+ sub.unsubscribe();
272
+ target?.removeEventListener("online", online);
273
+ };
274
+ }, [client, userId, outbox, queryClient]);
275
+
276
+ const value = useMemo<ChatContextValue>(
277
+ () => ({
278
+ client,
279
+ userId,
280
+ outbox,
281
+ queue,
282
+ loadPeople,
283
+ videoUpload,
284
+ attachmentKinds,
285
+ retryAll: () => outbox.retryAll(),
286
+ clearOutbox: () => outbox.clear(),
287
+ onInbox: (l) => {
288
+ listeners.current.add(l);
289
+ return () => listeners.current.delete(l);
290
+ },
291
+ }),
292
+ [client, userId, outbox, queue, loadPeople, videoUpload, attachmentKinds],
293
+ );
294
+ return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
295
+ }
296
+
297
+ export function useChat(): ChatContextValue {
298
+ const ctx = useContext(ChatContext);
299
+ if (!ctx) throw new Error("Chat hooks need a <ChatProvider> above them.");
300
+ return ctx;
301
+ }
302
+
303
+ /** Your conversations, live. `kind` narrows to one kind; `archived` shows the archived ones. */
304
+ export function useInbox(opts: { kind?: string; kinds?: string[]; excludeKinds?: string[]; archived?: boolean; limit?: number } = {}) {
305
+ const { client } = useChat();
306
+ const query = useQuery({ queryKey: chatKeys.inbox(opts), queryFn: () => client.inbox(opts), staleTime: 15_000 });
307
+ return { items: query.data ?? [], isLoading: query.isLoading, error: query.error, refetch: query.refetch };
308
+ }
309
+
310
+ /** How many messages wait for you everywhere (for a badge), live. */
311
+ export function useUnreadTotal(opts: { excludeKinds?: string[] } = {}): number {
312
+ const { client } = useChat();
313
+ return (
314
+ useQuery({ queryKey: [...chatKeys.unread(), opts.excludeKinds ?? []], queryFn: () => client.unreadTotal(opts), staleTime: 15_000 }).data ??
315
+ 0
316
+ );
317
+ }
318
+
319
+ export interface UseConversationOptions {
320
+ /** Mark messages read as they arrive while this is true (the thread is on screen). Default true. */
321
+ markRead?: boolean;
322
+ /** One thread's replies instead of the main timeline. */
323
+ threadRootId?: string;
324
+ /** One topic's messages. */
325
+ topic?: string;
326
+ pageSize?: number;
327
+ }
328
+
329
+ /** One conversation: its messages (with what you are still sending), members, your role, and who is typing. */
330
+ export function useConversation(conversationId: string | null | undefined, opts: UseConversationOptions = {}) {
331
+ const { client, userId, outbox, queue } = useChat();
332
+ const queryClient = useQueryClient();
333
+ const id = conversationId ?? "";
334
+ const enabled = Boolean(conversationId);
335
+ const pageSize = opts.pageSize ?? 50;
336
+ const messagesKey = useMemo(
337
+ () => [...chatKeys.conversation(id), "messages", opts.threadRootId ?? null, opts.topic ?? null] as const,
338
+ [id, opts.threadRootId, opts.topic],
339
+ );
340
+
341
+ const conversation = useQuery({ queryKey: chatKeys.conversation(id), queryFn: () => client.conversation(id), enabled });
342
+ const members = useQuery({ queryKey: chatKeys.members(id), queryFn: () => client.members(id), enabled, staleTime: 60_000, refetchOnMount: "always" });
343
+ const kindRules = useQuery({
344
+ queryKey: ["genesis-chat", "kind", conversation.data?.kind ?? ""],
345
+ queryFn: () => client.kind(conversation.data!.kind),
346
+ enabled: Boolean(conversation.data?.kind),
347
+ staleTime: 10 * 60_000,
348
+ });
349
+ const access = useQuery({
350
+ queryKey: chatKeys.role(id),
351
+ queryFn: async () => ({ role: await client.myRole(id), canPost: await client.canPost(id) }),
352
+ enabled,
353
+ staleTime: 60_000,
354
+ });
355
+
356
+ const [loadingEarlier, setLoadingEarlier] = useState(false);
357
+ const fetchPage = useCallback(
358
+ () => client.messages(id, { limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic }),
359
+ [client, id, pageSize, opts.threadRootId, opts.topic],
360
+ );
361
+ // Kept fresh by live hints and the catch-up on every join, so never refetched on its own.
362
+ const messages = useQuery<MessagesData>({ queryKey: messagesKey, queryFn: fetchPage, enabled, staleTime: Infinity });
363
+ const hasEarlier = messages.data?.hasEarlier ?? false;
364
+ // Where "New" goes: what you had read when the conversation opened.
365
+ const [readWhenOpened, setReadWhenOpened] = useState<number | null>(null);
366
+
367
+ const merge = useCallback(
368
+ (incoming: ChatMessage[], hasEarlierNow?: boolean) =>
369
+ queryClient.setQueryData<MessagesData>(messagesKey, (prev) => {
370
+ const base = prev ?? { messages: [], hasEarlier: false };
371
+ return { messages: upsertMessages(base.messages, incoming), hasEarlier: hasEarlierNow ?? base.hasEarlier };
372
+ }),
373
+ [queryClient, messagesKey],
374
+ );
375
+
376
+ /**
377
+ * Fetches what came after the newest message held (everything, for an empty list). Runs on
378
+ * every join of the live channel, the first included, so messages that arrived while the
379
+ * thread was closed, or between the first fetch and the subscription, always show. Pages
380
+ * until a short one; after CATCH_UP_MAX_PAGES full pages (a long time away from a busy group)
381
+ * it shows the newest page instead, with the rest behind "load earlier".
382
+ */
383
+ const catchUp = useCallback(async () => {
384
+ const current = queryClient.getQueryData<MessagesData>(messagesKey) ?? (await queryClient.ensureQueryData<MessagesData>({ queryKey: messagesKey, queryFn: fetchPage }));
385
+ const list = current.messages;
386
+ let after = list.length ? list[list.length - 1]!.seq : 0;
387
+ for (let pages = 0; ; ) {
388
+ const page = await client.messages(id, { afterSeq: after, limit: CATCH_UP_PAGE, threadRootId: opts.threadRootId, topic: opts.topic });
389
+ if (page.messages.length) merge(page.messages);
390
+ if (page.messages.length < CATCH_UP_PAGE) return;
391
+ after = Math.max(after, ...page.messages.map((m) => m.seq));
392
+ if (++pages >= CATCH_UP_MAX_PAGES) break;
393
+ }
394
+ const newest = await fetchPage();
395
+ queryClient.setQueryData<MessagesData>(messagesKey, { messages: newest.messages, hasEarlier: true });
396
+ }, [client, fetchPage, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient]);
397
+
398
+ const [activity, setActivity] = useState<ChatActivity[]>([]);
399
+ const channel = useRef<ConversationChannel | null>(null);
400
+ useEffect(() => {
401
+ if (!enabled) return;
402
+ const fetchOne = async (messageId: string) => {
403
+ const m = await client.message(messageId);
404
+ if (!m) return;
405
+ const belongs = opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null;
406
+ if (belongs && (!opts.topic || m.topic === opts.topic)) merge([m]);
407
+ if (m.threadRootId && !opts.threadRootId) {
408
+ const root = await client.message(m.threadRootId);
409
+ if (root) merge([root]);
410
+ }
411
+ };
412
+ channel.current = client.subscribeConversation(id, userId, {
413
+ onSignal: (s) => {
414
+ if (s.type === "message") void fetchOne(s.id);
415
+ else if (s.type === "reaction") void fetchOne(s.messageId);
416
+ else if (s.type === "members") {
417
+ void queryClient.invalidateQueries({ queryKey: chatKeys.members(id) });
418
+ void queryClient.invalidateQueries({ queryKey: chatKeys.role(id) });
419
+ } else if (s.type === "conversation") {
420
+ // The conversation row, its topics and availability (not its messages, kept by hints).
421
+ void queryClient.invalidateQueries({ queryKey: chatKeys.conversation(id), exact: true });
422
+ void queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
423
+ void queryClient.invalidateQueries({ queryKey: chatKeys.pins(id) });
424
+ }
425
+ },
426
+ onActivity: (a) => setActivity((prev) => currentActivity([...prev, a])),
427
+ onJoin: () => void catchUp().catch(() => undefined),
428
+ });
429
+ return () => {
430
+ channel.current?.unsubscribe();
431
+ channel.current = null;
432
+ };
433
+ }, [client, enabled, id, userId, merge, catchUp, queryClient, opts.threadRootId, opts.topic]);
434
+
435
+ // Activity fades after a few seconds without news.
436
+ useEffect(() => {
437
+ if (activity.length === 0) return;
438
+ const t = setTimeout(() => setActivity((prev) => currentActivity(prev)), 2000);
439
+ return () => clearTimeout(t);
440
+ }, [activity]);
441
+
442
+ const list = useMemo(() => messages.data?.messages ?? [], [messages.data]);
443
+ const latestSeq = list.length ? list[list.length - 1]!.seq : 0;
444
+ // Where "New" goes comes from your read place as fetched for this opening, never a cached
445
+ // copy from an earlier visit (which would draw the line above messages already read).
446
+ const myLastRead = members.isFetchedAfterMount ? members.data?.find((m) => m.userId === userId)?.lastReadSeq : undefined;
447
+ useEffect(() => {
448
+ if (readWhenOpened === null && myLastRead !== undefined) setReadWhenOpened(myLastRead);
449
+ }, [myLastRead, readWhenOpened]);
450
+
451
+ const markRead = useCallback(async () => {
452
+ if (!enabled || latestSeq === 0) return;
453
+ await client.markRead(id, latestSeq);
454
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
455
+ void queryClient.invalidateQueries({ queryKey: chatKeys.unread() });
456
+ }, [client, enabled, id, latestSeq, queryClient]);
457
+
458
+ // Your read place is one number for the whole conversation, so only the whole timeline moves
459
+ // it: a topic (or a thread) shows a slice, and marking up to its newest message would also
460
+ // mark unseen messages in other topics read.
461
+ const shouldMark = opts.markRead !== false && !opts.threadRootId && !opts.topic;
462
+ useEffect(() => {
463
+ if (shouldMark && latestSeq > 0) void markRead().catch(() => undefined);
464
+ }, [shouldMark, latestSeq, markRead]);
465
+
466
+ const refetchConversation = useCallback(
467
+ () =>
468
+ Promise.all([
469
+ // The conversation and what hangs off it (topics, availability), never its message lists:
470
+ // a refetch would replace loaded history and merged live messages with one fresh page.
471
+ queryClient.refetchQueries({ queryKey: chatKeys.conversation(id), predicate: (q) => q.queryKey[3] !== "messages" }),
472
+ queryClient.refetchQueries({ queryKey: chatKeys.members(id) }),
473
+ queryClient.refetchQueries({ queryKey: chatKeys.role(id) }),
474
+ queryClient.refetchQueries({ queryKey: chatKeys.pins(id) }),
475
+ queryClient.refetchQueries({ queryKey: ["genesis-chat", "kind"] }),
476
+ ]).then(() => undefined),
477
+ [queryClient, id],
478
+ );
479
+
480
+ const loadEarlier = useCallback(async () => {
481
+ const first = list[0];
482
+ if (!first || loadingEarlier) return;
483
+ setLoadingEarlier(true);
484
+ try {
485
+ const page = await client.messages(id, { beforeSeq: first.seq, limit: pageSize, threadRootId: opts.threadRootId, topic: opts.topic });
486
+ merge(page.messages, page.hasEarlier);
487
+ } finally {
488
+ setLoadingEarlier(false);
489
+ }
490
+ }, [client, id, list, loadingEarlier, merge, opts.threadRootId, opts.topic, pageSize]);
491
+
492
+ /** Loads earlier messages until the one with this seq is held (a search result, a pinned message). True when it is. */
493
+ const loadUntil = useCallback(
494
+ async (seq: number) => {
495
+ let current = queryClient.getQueryData<MessagesData>(messagesKey);
496
+ for (let guard = 0; guard < 20 && current; guard++) {
497
+ const first = current.messages[0];
498
+ if (!first || first.seq <= seq || !current.hasEarlier) break;
499
+ const page = await client.messages(id, {
500
+ beforeSeq: first.seq,
501
+ limit: Math.min(200, first.seq - seq + 10),
502
+ threadRootId: opts.threadRootId,
503
+ topic: opts.topic,
504
+ });
505
+ merge(page.messages, page.hasEarlier);
506
+ current = queryClient.getQueryData<MessagesData>(messagesKey);
507
+ }
508
+ return Boolean(current?.messages.some((m) => m.seq === seq));
509
+ },
510
+ [client, id, merge, messagesKey, opts.threadRootId, opts.topic, queryClient],
511
+ );
512
+
513
+ const pending = useMemo(
514
+ () =>
515
+ queue.filter(
516
+ (q) =>
517
+ q.input.conversationId === id &&
518
+ (q.input.threadRootId ?? null) === (opts.threadRootId ?? null) &&
519
+ (!opts.topic || q.input.topic === opts.topic),
520
+ ).map((q) => ({ ...q, files: q.pendingFiles })),
521
+ [queue, id, opts.threadRootId, opts.topic],
522
+ );
523
+ const items: ThreadItem[] = useMemo(() => mergeThread(list, pending), [list, pending]);
524
+
525
+ const signalTyping = useMemo(() => throttle(() => channel.current?.signal("typing")), []);
526
+ const signalRecording = useMemo(
527
+ () => throttle(() => channel.current?.signal("recording-voice")),
528
+ [],
529
+ );
530
+ const signalRecordingVideo = useMemo(() => throttle(() => channel.current?.signal("recording-video")), []);
531
+
532
+ /** Sends text and files to this conversation (through the outbox: shown at once, retried safely). */
533
+ const send = useCallback(
534
+ (input: Omit<SendInput, "conversationId" | "clientId">, files: LocalFile[] = []) =>
535
+ outbox.send({ ...input, conversationId: id, threadRootId: input.threadRootId ?? opts.threadRootId, topic: input.topic ?? opts.topic }, files),
536
+ [outbox, id, opts.threadRootId, opts.topic],
537
+ );
538
+
539
+ return {
540
+ conversation: conversation.data ?? null,
541
+ members: members.data ?? [],
542
+ role: access.data?.role ?? null,
543
+ canPost: access.data?.canPost ?? false,
544
+ /** Your role and posting rights are still loading: show the composer as waiting, not refused. */
545
+ accessLoading: enabled && access.isLoading,
546
+ /** The conversation itself is still loading (it settles to `null` when you can't see it). */
547
+ conversationLoading: conversation.isLoading,
548
+ /** Your role couldn't be loaded (offline): offer `refetchAccess`, don't say "can't post". */
549
+ accessError: access.error ?? null,
550
+ /** Retries everything this conversation loads (role, members, the conversation, topics, availability, pins), not only access. */
551
+ refetchAccess: refetchConversation,
552
+ /** Same as refetchAccess: every per-conversation lookup again, for a Retry after being offline. */
553
+ refetchConversation,
554
+ /** The kind's rules (max members, threads, who posts). */
555
+ kind: kindRules.data ?? null,
556
+ /** May you pin here: owners and admins, or anyone in a two-person conversation. */
557
+ canPin:
558
+ access.data?.role === "owner" ||
559
+ access.data?.role === "admin" ||
560
+ (access.data?.role === "member" && kindRules.data?.maxMembers === 2),
561
+ /** Gives a message whose files were lost on a relaunch its files again. */
562
+ reattach: outbox.reattach,
563
+ items,
564
+ messages: list,
565
+ isLoading: messages.isLoading || conversation.isLoading,
566
+ error: messages.error ?? conversation.error,
567
+ hasEarlier,
568
+ loadingEarlier,
569
+ loadEarlier,
570
+ loadUntil,
571
+ /** Where to draw the "New" line, from what you had read when it opened. */
572
+ firstUnreadId: readWhenOpened === null ? null : firstUnreadId(list, readWhenOpened, userId),
573
+ activity: currentActivity(activity),
574
+ signalTyping,
575
+ signalRecording,
576
+ /** Tells the others you are recording a video (throttled). */
577
+ signalRecordingVideo,
578
+ markRead,
579
+ send,
580
+ retry: outbox.retry,
581
+ discard: outbox.discard,
582
+ refetch: catchUp,
583
+ };
584
+ }
585
+
586
+ export type ConversationState = ReturnType<typeof useConversation>;
587
+
588
+ /** Edit, delete, react, pin, save and report, refreshing what they change. */
589
+ export function useMessageActions() {
590
+ const { client } = useChat();
591
+ const queryClient = useQueryClient();
592
+ return useMemo(() => {
593
+ const refresh = async (conversationId: string, messageId: string) => {
594
+ const m = await client.message(messageId);
595
+ queryClient.setQueriesData<MessagesData>({ queryKey: chatKeys.conversation(conversationId) }, (prev) =>
596
+ m ? (upsertInto(prev, [m]) ?? prev) : prev,
597
+ );
598
+ };
599
+ return {
600
+ edit: async (m: ChatMessage, body: string, mentions: string[] = []) => {
601
+ await client.edit(m.id, body, mentions);
602
+ await refresh(m.conversationId, m.id);
603
+ },
604
+ remove: async (m: ChatMessage) => {
605
+ await client.remove(m.id);
606
+ await refresh(m.conversationId, m.id);
607
+ },
608
+ react: async (m: ChatMessage, emoji: string, on: boolean) => {
609
+ await client.react(m.id, emoji, on);
610
+ await refresh(m.conversationId, m.id);
611
+ },
612
+ pin: async (m: ChatMessage, on: boolean) => {
613
+ await client.pin(m.id, on);
614
+ void queryClient.invalidateQueries({ queryKey: chatKeys.pins(m.conversationId) });
615
+ },
616
+ save: (m: ChatMessage, on: boolean, note?: string) => client.save(m.id, on, note),
617
+ report: (m: ChatMessage, reason: "spam" | "harassment" | "safety" | "other", note?: string) => client.report(m.id, reason, note),
618
+ };
619
+ }, [client, queryClient]);
620
+ }
621
+
622
+ /**
623
+ * Names and avatars for these people, from the provider's `loadPeople`, fetched as one
624
+ * batch per set of ids and cached. Unknown ids are left out.
625
+ */
626
+ export function usePeople(userIds: (string | null | undefined)[]): Record<string, ChatPerson> {
627
+ const { loadPeople } = useChat();
628
+ const ids = useMemo(() => [...new Set(userIds.filter((x): x is string => Boolean(x)))].sort(), [userIds]);
629
+ return (
630
+ useQuery({
631
+ queryKey: ["genesis-chat", "people", ids],
632
+ queryFn: () => loadPeople!(ids),
633
+ enabled: Boolean(loadPeople) && ids.length > 0,
634
+ staleTime: 5 * 60_000,
635
+ }).data ?? {}
636
+ );
637
+ }
638
+
639
+ /** One person's name and avatar (see usePeople). */
640
+ export function usePerson(userId: string | null | undefined): ChatPerson | null {
641
+ const people = usePeople(useMemo(() => [userId], [userId]));
642
+ return userId ? (people[userId] ?? null) : null;
643
+ }
644
+
645
+ /** The pinned message ids of a conversation. */
646
+ export function usePins(conversationId: string | null | undefined): string[] {
647
+ const { client } = useChat();
648
+ return (
649
+ useQuery({ queryKey: chatKeys.pins(conversationId ?? ""), queryFn: () => client.pins(conversationId!), enabled: Boolean(conversationId) }).data ?? []
650
+ );
651
+ }
652
+
653
+ /** Signed links for files (cached by the client until shortly before they expire). */
654
+ export function useFileUrls(paths: (string | null | undefined)[]): Record<string, string> {
655
+ const { client } = useChat();
656
+ const list = useMemo(() => [...new Set(paths.filter((p): p is string => Boolean(p)))], [paths]);
657
+ return (
658
+ useQuery({ queryKey: chatKeys.files(list), queryFn: () => client.fileUrls(list), enabled: list.length > 0, staleTime: 50 * 60_000 }).data ?? {}
659
+ );
660
+ }
661
+
662
+ /** Starts (or finds again) a conversation, then refreshes the inbox. */
663
+ export function useStartConversation() {
664
+ const { client } = useChat();
665
+ const queryClient = useQueryClient();
666
+ return useCallback(
667
+ async (kind: string, memberIds: string[], opts?: { title?: string; subjectType?: string; subjectId?: string }) => {
668
+ const id = await client.startConversation(kind, memberIds, opts);
669
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
670
+ return id;
671
+ },
672
+ [client, queryClient],
673
+ );
674
+ }
675
+
676
+ // ── Migration 0002: topics, scheduled send, availability, broadcast, search, previews ──
677
+
678
+ /**
679
+ * A conversation's topics, live (set_topic's hint refreshes them), and `setTopic` for owners
680
+ * and admins: add one (`setTopic("week-1", { title: "Week 1" })`), close or reopen it.
681
+ */
682
+ export function useTopics(conversationId: string | null | undefined) {
683
+ const { client } = useChat();
684
+ const queryClient = useQueryClient();
685
+ const id = conversationId ?? "";
686
+ const query = useQuery({ queryKey: chatKeys.topics(id), queryFn: () => client.topics(id), enabled: Boolean(conversationId), staleTime: 60_000 });
687
+ const setTopic = useCallback(
688
+ async (topic: string, input: TopicInput = {}) => {
689
+ await client.setTopic(id, topic, input);
690
+ await queryClient.invalidateQueries({ queryKey: chatKeys.topics(id) });
691
+ },
692
+ [client, id, queryClient],
693
+ );
694
+ const topics: ChatTopic[] = query.data ?? [];
695
+ return { topics, isLoading: query.isLoading, error: query.error, setTopic, refetch: query.refetch };
696
+ }
697
+
698
+ /**
699
+ * Your scheduled messages (in one conversation, or all), soonest first, with `schedule` and
700
+ * `cancel` (which also removes one that failed). `schedule` uploads its files to storage first,
701
+ * the way `send` does (the provider's `upload` hook, or a native URI read through `readFile`),
702
+ * then schedules them with the message. A file marked `hosted` goes to storage too.
703
+ */
704
+ export function useScheduled(conversationId?: string | null, opts: { enabled?: boolean } = {}) {
705
+ const { client, outbox } = useChat();
706
+ const queryClient = useQueryClient();
707
+ const query = useQuery({
708
+ queryKey: chatKeys.scheduled(conversationId),
709
+ queryFn: () => client.scheduled(conversationId ?? undefined),
710
+ enabled: opts.enabled ?? true,
711
+ staleTime: 30_000,
712
+ });
713
+ const refresh = useCallback(() => queryClient.invalidateQueries({ queryKey: ["genesis-chat", "scheduled"] }), [queryClient]);
714
+ const schedule = useCallback(
715
+ async (input: Omit<ScheduleInput, "clientId"> & { clientId?: string }, files: LocalFile[] = []) => {
716
+ const attachments = [...(input.attachments ?? [])];
717
+ for (const f of files) {
718
+ const path = await outbox.uploadFile(input.conversationId, f);
719
+ attachments.push({ ...f.meta, kind: f.meta?.kind ?? attachmentKindFor(f.contentType), path, name: f.meta?.name ?? f.name, mime: f.contentType });
720
+ }
721
+ const id = await client.schedule({ ...input, clientId: input.clientId ?? randomId(), attachments });
722
+ await refresh();
723
+ return id;
724
+ },
725
+ [client, outbox, refresh],
726
+ );
727
+ const cancel = useCallback(
728
+ async (id: string) => {
729
+ queryClient.setQueriesData<ScheduledMessage[]>({ queryKey: ["genesis-chat", "scheduled"] }, (prev) => (Array.isArray(prev) ? prev.filter((s) => s.id !== id) : prev));
730
+ try {
731
+ await client.cancelScheduled(id);
732
+ } finally {
733
+ await refresh();
734
+ }
735
+ },
736
+ [client, queryClient, refresh],
737
+ );
738
+ const items: ScheduledMessage[] = query.data ?? [];
739
+ return { items, isLoading: query.isLoading, error: query.error, schedule, cancel, refetch: query.refetch };
740
+ }
741
+
742
+ /**
743
+ * Availability: yours (`mine`, `setMine`) and, given a conversation, its members' (`members`,
744
+ * `forUser`). Pair with `awayLine` and `isWithinWorkingHours` from the core.
745
+ */
746
+ export function useAvailability(conversationId?: string | null) {
747
+ const { client, userId } = useChat();
748
+ const queryClient = useQueryClient();
749
+ const mine = useQuery({ queryKey: chatKeys.myAvailability(), queryFn: () => client.myAvailability(), staleTime: 5 * 60_000 });
750
+ const members = useQuery({
751
+ queryKey: chatKeys.availability(conversationId ?? ""),
752
+ queryFn: () => client.availabilityFor(conversationId!),
753
+ enabled: Boolean(conversationId),
754
+ staleTime: 5 * 60_000,
755
+ });
756
+ const setMine = useCallback(
757
+ async (input: AvailabilityInput) => {
758
+ await client.setAvailability(input);
759
+ await queryClient.invalidateQueries({ queryKey: chatKeys.myAvailability() });
760
+ await queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === "genesis-chat" && q.queryKey[3] === "availability" });
761
+ },
762
+ [client, queryClient],
763
+ );
764
+ const list: ChatAvailability[] = members.data ?? [];
765
+ const forUser = useCallback((id: string) => list.find((a) => a.userId === id) ?? null, [list]);
766
+ return {
767
+ mine: mine.data ?? null,
768
+ mineLoading: mine.isLoading,
769
+ setMine,
770
+ members: list,
771
+ /** Everyone else's, without yours. */
772
+ others: list.filter((a) => a.userId !== userId),
773
+ forUser,
774
+ isLoading: mine.isLoading || (Boolean(conversationId) && members.isLoading),
775
+ };
776
+ }
777
+
778
+ /** Sends one message into many conversations (replies come back privately in each). */
779
+ export function useBroadcast() {
780
+ const { client } = useChat();
781
+ const queryClient = useQueryClient();
782
+ const [sending, setSending] = useState(false);
783
+ const send = useCallback(
784
+ async (conversationIds: string[], body: string, opts: { silent?: boolean; clientId?: string } = {}) => {
785
+ setSending(true);
786
+ try {
787
+ const ids = await client.broadcast(conversationIds, opts.clientId ?? randomId(), body, { silent: opts.silent });
788
+ void queryClient.invalidateQueries({ queryKey: ["genesis-chat", "inbox"] });
789
+ return ids;
790
+ } finally {
791
+ setSending(false);
792
+ }
793
+ },
794
+ [client, queryClient],
795
+ );
796
+ return { send, sending };
797
+ }
798
+
799
+ /** The value, once it has stopped changing for `ms`. */
800
+ export function useDebouncedValue<T>(value: T, ms: number): T {
801
+ const [v, setV] = useState(value);
802
+ useEffect(() => {
803
+ if (ms <= 0) {
804
+ setV(value);
805
+ return;
806
+ }
807
+ const t = setTimeout(() => setV(value), ms);
808
+ return () => clearTimeout(t);
809
+ }, [value, ms]);
810
+ return ms <= 0 ? value : v;
811
+ }
812
+
813
+ /** Search, debounced (250 ms by default), in one conversation or all of yours. Queries under two characters return nothing. */
814
+ export function useSearch(query: string, opts: { conversationId?: string | null; debounceMs?: number; limit?: number } = {}) {
815
+ const { client } = useChat();
816
+ const q = useDebouncedValue(query.trim(), opts.debounceMs ?? 250);
817
+ const enabled = q.length >= 2;
818
+ const result = useQuery({
819
+ queryKey: chatKeys.search(q, opts.conversationId),
820
+ queryFn: () => client.search(q, opts.conversationId ?? null, { limit: opts.limit }),
821
+ enabled,
822
+ staleTime: 30_000,
823
+ });
824
+ const hits: ChatSearchHit[] = enabled ? (result.data ?? []) : [];
825
+ return {
826
+ hits,
827
+ /** The query the hits are for. */
828
+ query: q,
829
+ /** Typing, or fetching: results are on their way. */
830
+ isSearching: query.trim() !== q || (enabled && result.isFetching),
831
+ error: result.error,
832
+ };
833
+ }
834
+
835
+ /** At most this many link-preview reads at once, per client. */
836
+ export const LINK_PREVIEW_CONCURRENCY = 4;
837
+ const previewGates = new WeakMap<object, <T>(task: () => Promise<T>) => Promise<T>>();
838
+
839
+ /** A gate that lets `max` tasks run at once and queues the rest. */
840
+ function gate(max: number) {
841
+ let active = 0;
842
+ const waiting: (() => void)[] = [];
843
+ return async <T,>(task: () => Promise<T>): Promise<T> => {
844
+ if (active >= max) await new Promise<void>((r) => waiting.push(r));
845
+ active += 1;
846
+ try {
847
+ return await task();
848
+ } finally {
849
+ active -= 1;
850
+ waiting.shift()?.();
851
+ }
852
+ };
853
+ }
854
+
855
+ /**
856
+ * Link previews for these messages, by message id, in link order (only those the server has
857
+ * fetched). Pass the messages whose text has links (`extractLinks(body).length > 0`). One query
858
+ * per message, so a new message asks only for its own, and at most LINK_PREVIEW_CONCURRENCY
859
+ * run at once. One with none yet is asked again a few times, since the runner fills them
860
+ * moments after it lands.
861
+ */
862
+ export function useLinkPreviews(messageIds: (string | null | undefined)[]): Record<string, ChatLinkPreview[]> {
863
+ const { client } = useChat();
864
+ const list = useMemo(() => [...new Set(messageIds.filter((u): u is string => Boolean(u)))].sort(), [messageIds]);
865
+ let run = previewGates.get(client);
866
+ if (!run) {
867
+ run = gate(LINK_PREVIEW_CONCURRENCY);
868
+ previewGates.set(client, run);
869
+ }
870
+ const limited = run;
871
+ const combine = useCallback(
872
+ (results: { data?: ChatLinkPreview[] }[]) => {
873
+ let out: Record<string, ChatLinkPreview[]> | null = null;
874
+ results.forEach((r, i) => {
875
+ if (r.data) (out ??= {})[list[i]!] = r.data;
876
+ });
877
+ return out ?? EMPTY_PREVIEWS;
878
+ },
879
+ [list],
880
+ );
881
+ return useQueries({
882
+ queries: list.map((id) => ({
883
+ queryKey: chatKeys.preview(id),
884
+ queryFn: () => limited(async () => (await client.linkPreviewsFor([id]))[id] ?? []),
885
+ staleTime: 60_000,
886
+ refetchInterval: (q: { state: { data?: ChatLinkPreview[]; dataUpdateCount: number } }) =>
887
+ !q.state.data?.length && q.state.dataUpdateCount < 4 ? 5_000 : false,
888
+ })),
889
+ combine,
890
+ });
891
+ }
892
+ const EMPTY_PREVIEWS: Record<string, ChatLinkPreview[]> = {};
893
+
894
+ export type { ThreadItem, LocalFile, QueuedMessage };