@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,714 @@
1
+ /**
2
+ * @feat:chat
3
+ * A chat client held in memory, for demos, stories and tests: the same shape
4
+ * as `createChatClient`, with no Supabase behind it. It also plays the other
5
+ * side (`receive`, `activity`) so a demo looks alive.
6
+ */
7
+ import { ChatError, type ChatClient, type ChatSupabase, type ConversationChannel, type ConversationHandlers, type MessagePage } from "../core/client";
8
+ import { extractLinks } from "../core/extras";
9
+ import type {
10
+ AvailabilityInput,
11
+ ChatActivity,
12
+ ChatAvailability,
13
+ ChatLinkPreview,
14
+ ChatSearchHit,
15
+ ChatTopic,
16
+ ScheduleInput,
17
+ ScheduledMessage,
18
+ TopicInput,
19
+ ChatAttachment,
20
+ ChatConversation,
21
+ ChatInboxItem,
22
+ ChatMember,
23
+ ChatMessage,
24
+ ChatRole,
25
+ ChatSignal,
26
+ SendInput,
27
+ } from "../core/types";
28
+
29
+ export interface MemoryConversationSeed {
30
+ id: string;
31
+ kind?: string;
32
+ title?: string | null;
33
+ settings?: ChatConversation["settings"];
34
+ members: { userId: string; role?: ChatRole; lastReadSeq?: number }[];
35
+ /** Your role when you aren't a member (an observer). */
36
+ myRole?: ChatRole | null;
37
+ canPost?: boolean;
38
+ muted?: boolean;
39
+ pinned?: boolean;
40
+ messages?: (Partial<Omit<ChatMessage, "attachments">> & { attachments?: Partial<ChatAttachment>[] })[];
41
+ pins?: string[];
42
+ /** Its topics, in order. */
43
+ topics?: { topic: string; title?: string; closed?: boolean }[];
44
+ }
45
+
46
+ export interface MemoryChatOptions {
47
+ /** The signed-in user. */
48
+ me: string;
49
+ conversations: MemoryConversationSeed[];
50
+ /** Signed URLs for attachment paths. */
51
+ files?: Record<string, string>;
52
+ /** Milliseconds each call waits, to show loading states. Default 0. */
53
+ latencyMs?: number;
54
+ /** Away messages and working hours, by user id. */
55
+ availability?: Record<string, Partial<Omit<ChatAvailability, "userId">>>;
56
+ /** Link previews the "server" has fetched. */
57
+ linkPreviews?: Partial<ChatLinkPreview>[];
58
+ }
59
+
60
+ interface Conv {
61
+ conversation: ChatConversation;
62
+ members: ChatMember[];
63
+ myRole: ChatRole | null;
64
+ canPost: boolean;
65
+ muted: boolean;
66
+ pinned: boolean;
67
+ messages: ChatMessage[];
68
+ pins: string[];
69
+ topics: ChatTopic[];
70
+ }
71
+
72
+ export type MemoryChatClient = ChatClient & {
73
+ /** Someone else posts (a demo's other side). */
74
+ receive(conversationId: string, senderId: string, body: string, extra?: Partial<ChatMessage>): ChatMessage;
75
+ /** Someone else starts typing or recording. */
76
+ activity(conversationId: string, userId: string, activity: ChatActivity["activity"]): void;
77
+ /** The next `count` sends fail as if the connection dropped. */
78
+ failNextSends(count?: number): void;
79
+ /** The next send waits until the returned function is called (a test watching the sending state). */
80
+ holdNextSend(): () => void;
81
+ /** Posts every scheduled message due by `now` (a demo's clock), as the server's runner does. */
82
+ sendDue(now?: Date): Promise<number>;
83
+ /** Moderation's verdict on a message (the server's side). */
84
+ moderate(messageId: string, result: "clean" | "flagged" | "hidden"): void;
85
+ /** Every call made, for tests. */
86
+ calls: { method: string; args: unknown[] }[];
87
+ };
88
+
89
+ let counter = 0;
90
+ const uuid = () =>
91
+ typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `00000000-0000-4000-8000-${String(++counter).padStart(12, "0")}`;
92
+
93
+ function attachment(messageId: string, a: Partial<ChatAttachment>, i: number): ChatAttachment {
94
+ return {
95
+ id: a.id ?? uuid(),
96
+ messageId,
97
+ position: a.position ?? i,
98
+ kind: a.kind ?? "file",
99
+ ref: a.ref ?? {},
100
+ provider: a.provider ?? "storage",
101
+ path: a.path ?? null,
102
+ providerId: a.providerId ?? null,
103
+ playbackId: a.playbackId ?? null,
104
+ url: a.url ?? null,
105
+ status: a.status ?? "ready",
106
+ name: a.name ?? null,
107
+ mime: a.mime ?? null,
108
+ bytes: a.bytes ?? null,
109
+ width: a.width ?? null,
110
+ height: a.height ?? null,
111
+ durationMs: a.durationMs ?? null,
112
+ waveform: a.waveform ?? null,
113
+ posterPath: a.posterPath ?? null,
114
+ thumbhash: a.thumbhash ?? null,
115
+ transcript: a.transcript ?? null,
116
+ transcriptStatus: a.transcriptStatus ?? null,
117
+ };
118
+ }
119
+
120
+ export function createMemoryChatClient(options: MemoryChatOptions): MemoryChatClient {
121
+ const { me } = options;
122
+ const files: Record<string, string> = { ...(options.files ?? {}) };
123
+ const convs = new Map<string, Conv>();
124
+ const convListeners = new Map<string, Set<{ onSignal: (s: ChatSignal) => void; onActivity?: (a: ChatActivity) => void }>>();
125
+ const inboxListeners = new Set<(s: ChatSignal) => void>();
126
+ const calls: { method: string; args: unknown[] }[] = [];
127
+ let failing = 0;
128
+ let held: Promise<void> | null = null;
129
+ const wait = () => new Promise<void>((r) => setTimeout(r, options.latencyMs ?? 0));
130
+
131
+ const makeMessage = (c: Conv, m: Partial<Omit<ChatMessage, "attachments">> & { attachments?: Partial<ChatAttachment>[] }): ChatMessage => {
132
+ const id = m.id ?? uuid();
133
+ const seq = ++c.conversation.lastSeq;
134
+ const createdAt = m.createdAt ?? new Date().toISOString();
135
+ c.conversation.lastMessageAt = createdAt;
136
+ return {
137
+ id,
138
+ conversationId: c.conversation.id,
139
+ seq,
140
+ clientId: m.clientId ?? id,
141
+ senderId: m.senderId ?? null,
142
+ senderRole: m.senderRole ?? (m.kind === "system" ? "system" : "member"),
143
+ kind: m.kind ?? "text",
144
+ body: m.body ?? "",
145
+ mentions: m.mentions ?? [],
146
+ replyToId: m.replyToId ?? null,
147
+ threadRootId: m.threadRootId ?? null,
148
+ threadCount: m.threadCount ?? 0,
149
+ threadLastAt: m.threadLastAt ?? null,
150
+ topic: m.topic ?? null,
151
+ visibility: m.visibility ?? "everyone",
152
+ silent: m.silent ?? false,
153
+ meta: m.meta ?? {},
154
+ createdAt,
155
+ editedAt: m.editedAt ?? null,
156
+ deletedAt: m.deletedAt ?? null,
157
+ moderation: m.moderation ?? null,
158
+ attachments: (m.attachments ?? []).map((a, i) => attachment(id, a, i)),
159
+ reactions: m.reactions ?? [],
160
+ };
161
+ };
162
+
163
+ for (const seed of options.conversations) {
164
+ const c: Conv = {
165
+ conversation: {
166
+ id: seed.id,
167
+ kind: seed.kind ?? (seed.members.length === 2 ? "direct" : "group"),
168
+ title: seed.title ?? null,
169
+ subjectType: null,
170
+ subjectId: null,
171
+ settings: seed.settings ?? {},
172
+ status: null,
173
+ assigneeId: null,
174
+ lastSeq: 0,
175
+ lastMessageAt: null,
176
+ createdAt: new Date().toISOString(),
177
+ archivedAt: null,
178
+ },
179
+ members: seed.members.map((m) => ({
180
+ conversationId: seed.id,
181
+ userId: m.userId,
182
+ role: m.role ?? "member",
183
+ lastReadSeq: m.lastReadSeq ?? 0,
184
+ joinedAt: new Date().toISOString(),
185
+ })),
186
+ myRole: seed.myRole !== undefined ? seed.myRole : (seed.members.find((m) => m.userId === me)?.role ?? "member"),
187
+ canPost: seed.canPost ?? true,
188
+ muted: seed.muted ?? false,
189
+ pinned: seed.pinned ?? false,
190
+ messages: [],
191
+ pins: seed.pins ?? [],
192
+ topics: (seed.topics ?? []).map((t, i) => ({
193
+ conversationId: seed.id,
194
+ topic: t.topic,
195
+ title: t.title ?? t.topic,
196
+ position: i,
197
+ closedAt: t.closed ? new Date().toISOString() : null,
198
+ createdBy: null,
199
+ createdAt: new Date().toISOString(),
200
+ })),
201
+ };
202
+ for (const m of seed.messages ?? []) c.messages.push(makeMessage(c, m));
203
+ convs.set(seed.id, c);
204
+ }
205
+
206
+ const get = (id: string) => {
207
+ const c = convs.get(id);
208
+ if (!c) throw new ChatError("Conversation not found", "42501", "conversation");
209
+ return c;
210
+ };
211
+ const findMessage = (id: string) => {
212
+ for (const c of convs.values()) {
213
+ const m = c.messages.find((x) => x.id === id);
214
+ if (m) return { c, m };
215
+ }
216
+ throw new ChatError("Message not found", "42501", "message");
217
+ };
218
+ const visible = (c: Conv, m: ChatMessage) =>
219
+ (m.visibility !== "observers" || c.myRole === "observer") && (m.moderation !== "hidden" || m.senderId === me || c.myRole === "observer");
220
+ const scheduledList: ScheduledMessage[] = [];
221
+ const availability = new Map<string, ChatAvailability>(
222
+ Object.entries(options.availability ?? {}).map(([userId, a]) => [
223
+ userId,
224
+ { userId, awayMessage: a.awayMessage ?? null, awayUntil: a.awayUntil ?? null, timeZone: a.timeZone ?? null, hours: a.hours ?? null },
225
+ ]),
226
+ );
227
+ const previews = new Map<string, ChatLinkPreview>(
228
+ (options.linkPreviews ?? []).filter((p) => p.url).map((p) => [
229
+ p.url!,
230
+ { url: p.url!, status: p.status ?? "ok", title: p.title ?? null, description: p.description ?? null, imageUrl: p.imageUrl ?? null, siteName: p.siteName ?? null, fetchedAt: p.fetchedAt ?? new Date().toISOString() },
231
+ ]),
232
+ );
233
+ const clone = (m: ChatMessage): ChatMessage => ({ ...m, attachments: m.attachments.map((a) => ({ ...a })), reactions: [...m.reactions] });
234
+ const emit = (conversationId: string, s: ChatSignal) => {
235
+ for (const l of convListeners.get(conversationId) ?? []) l.onSignal(s);
236
+ for (const l of inboxListeners) l({ type: "inbox", conversationId });
237
+ };
238
+ const log = (method: string, ...args: unknown[]) => calls.push({ method, args });
239
+
240
+ function inboxItem(c: Conv): ChatInboxItem {
241
+ const mine = c.members.find((m) => m.userId === me);
242
+ const lastRead = mine?.lastReadSeq ?? 0;
243
+ const seen = c.messages.filter((m) => visible(c, m));
244
+ const last = seen[seen.length - 1];
245
+ const others = c.members.filter((m) => m.userId !== me);
246
+ const unreadList = seen.filter((m) => m.seq > lastRead && m.senderId !== me && !m.deletedAt);
247
+ return {
248
+ conversationId: c.conversation.id,
249
+ kind: c.conversation.kind,
250
+ title: c.conversation.title,
251
+ subjectType: null,
252
+ subjectId: null,
253
+ status: null,
254
+ role: c.myRole ?? "member",
255
+ otherUserId: c.members.length === 2 && others.length === 1 ? others[0]!.userId : null,
256
+ memberCount: c.members.length,
257
+ lastSeq: c.conversation.lastSeq,
258
+ lastReadSeq: lastRead,
259
+ unread: Math.min(99, unreadList.length),
260
+ mentioned: unreadList.some((m) => m.mentions.includes(me)),
261
+ muted: c.muted,
262
+ pinned: c.pinned,
263
+ archived: false,
264
+ lastMessageAt: c.conversation.lastMessageAt,
265
+ lastMessage: last
266
+ ? {
267
+ id: last.id,
268
+ seq: last.seq,
269
+ senderId: last.senderId,
270
+ kind: last.kind,
271
+ body: last.body,
272
+ deleted: Boolean(last.deletedAt),
273
+ createdAt: last.createdAt,
274
+ attachment: last.attachments[0]?.kind ?? null,
275
+ }
276
+ : null,
277
+ };
278
+ }
279
+
280
+ const client: MemoryChatClient = {
281
+ supabase: null as unknown as ChatSupabase,
282
+ calls,
283
+
284
+ async inbox(opts: { kind?: string; kinds?: string[]; excludeKinds?: string[]; archived?: boolean; limit?: number; before?: string } = {}) {
285
+ log("inbox", opts);
286
+ await wait();
287
+ if (opts.archived) return [];
288
+ return [...convs.values()]
289
+ .filter((c) => keepKind(c.conversation.kind, opts))
290
+ .map(inboxItem)
291
+ .sort((a, b) => Number(b.pinned) - Number(a.pinned) || (b.lastMessageAt ?? "").localeCompare(a.lastMessageAt ?? ""));
292
+ },
293
+ async observed() {
294
+ return [];
295
+ },
296
+ async unreadTotal(opts: { excludeKinds?: string[] } = {}) {
297
+ return [...convs.values()].filter((c) => keepKind(c.conversation.kind, opts)).reduce((n, c) => n + inboxItem(c).unread, 0);
298
+ },
299
+ async conversation(id: string) {
300
+ await wait();
301
+ return convs.has(id) ? { ...get(id).conversation } : null;
302
+ },
303
+ async kind(kind: string) {
304
+ return { kind, label: kind, maxMembers: kind === "direct" ? 2 : null, whoPosts: "members" as const, membership: "admins" as const, threads: true, observersPost: false };
305
+ },
306
+
307
+ async myRole(id: string) {
308
+ return convs.get(id)?.myRole ?? null;
309
+ },
310
+ async canPost(id: string) {
311
+ return convs.get(id)?.canPost ?? false;
312
+ },
313
+ async members(id: string) {
314
+ return get(id).members.map((m) => ({ ...m }));
315
+ },
316
+ async messages(id: string, opts: { beforeSeq?: number; afterSeq?: number; limit?: number; threadRootId?: string; topic?: string } = {}): Promise<MessagePage> {
317
+ log("messages", id, opts);
318
+ await wait();
319
+ const c = get(id);
320
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
321
+ let list = c.messages.filter((m) => visible(c, m) && (opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null));
322
+ if (opts.topic) list = list.filter((m) => m.topic === opts.topic);
323
+ if (opts.afterSeq != null) return { messages: list.filter((m) => m.seq > opts.afterSeq!).slice(0, limit).map(clone), hasEarlier: false };
324
+ if (opts.beforeSeq != null) list = list.filter((m) => m.seq < opts.beforeSeq!);
325
+ const page = list.slice(-limit);
326
+ return { messages: page.map(clone), hasEarlier: list.length > page.length };
327
+ },
328
+ async message(id: string) {
329
+ try {
330
+ const { c, m } = findMessage(id);
331
+ return visible(c, m) ? clone(m) : null;
332
+ } catch {
333
+ return null;
334
+ }
335
+ },
336
+ async startConversation(kind: string, memberIds: string[], opts: { title?: string } = {}) {
337
+ const id = uuid();
338
+ convs.set(id, {
339
+ conversation: {
340
+ id,
341
+ kind,
342
+ title: opts.title ?? null,
343
+ subjectType: null,
344
+ subjectId: null,
345
+ settings: {},
346
+ status: null,
347
+ assigneeId: null,
348
+ lastSeq: 0,
349
+ lastMessageAt: null,
350
+ createdAt: new Date().toISOString(),
351
+ archivedAt: null,
352
+ },
353
+ members: [me, ...memberIds.filter((m) => m !== me)].map((userId) => ({ conversationId: id, userId, role: userId === me ? "owner" : "member", lastReadSeq: 0, joinedAt: new Date().toISOString() })),
354
+ myRole: "owner",
355
+ canPost: true,
356
+ muted: false,
357
+ pinned: false,
358
+ messages: [],
359
+ pins: [],
360
+ topics: [],
361
+ });
362
+ for (const l of inboxListeners) l({ type: "inbox", conversationId: id });
363
+ return id;
364
+ },
365
+ async send(input: SendInput) {
366
+ log("send", input);
367
+ await wait();
368
+ if (held) {
369
+ const hold = held;
370
+ held = null;
371
+ await hold;
372
+ }
373
+ if (failing > 0) {
374
+ failing -= 1;
375
+ throw new ChatError("Failed to fetch", undefined, "send_message");
376
+ }
377
+ const c = get(input.conversationId);
378
+ if (!c.canPost) throw new ChatError("You can't post here", "42501", "send_message");
379
+ const topic = input.topic ? c.topics.find((t) => t.topic === input.topic) : undefined;
380
+ if (topic?.closedAt && !["owner", "admin", "observer"].includes(c.myRole ?? "member")) {
381
+ throw new ChatError("This topic is closed", "42501", "send_message");
382
+ }
383
+ const existing = c.messages.find((m) => m.clientId === input.clientId);
384
+ if (existing) return clone(existing);
385
+ const m = makeMessage(c, {
386
+ clientId: input.clientId,
387
+ senderId: me,
388
+ senderRole: c.myRole ?? "member",
389
+ body: input.body ?? "",
390
+ mentions: input.mentions ?? [],
391
+ replyToId: input.replyToId ?? null,
392
+ threadRootId: input.threadRootId ?? null,
393
+ topic: input.topic ?? null,
394
+ visibility: input.visibility ?? "everyone",
395
+ silent: input.silent ?? false,
396
+ attachments: (input.attachments ?? []).map((a) => ({ ...a, durationMs: a.durationMs ?? null, transcriptStatus: a.kind === "voice" ? "pending" : null })) as Partial<ChatAttachment>[],
397
+ });
398
+ c.messages.push(m);
399
+ const mine = c.members.find((x) => x.userId === me);
400
+ if (mine) mine.lastReadSeq = m.seq;
401
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, seq: m.seq, op: "insert" });
402
+ return clone(m);
403
+ },
404
+ async edit(messageId: string, body: string, mentions: string[] = []) {
405
+ log("edit", messageId, body);
406
+ const { c, m } = findMessage(messageId);
407
+ if (m.senderId !== me) throw new ChatError("You can't edit this message", "42501", "edit_message");
408
+ Object.assign(m, { body, mentions, editedAt: new Date().toISOString() });
409
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, op: "update" });
410
+ return clone(m);
411
+ },
412
+ async remove(messageId: string) {
413
+ log("remove", messageId);
414
+ const { c, m } = findMessage(messageId);
415
+ Object.assign(m, { body: "", attachments: [], reactions: [], deletedAt: new Date().toISOString() });
416
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, op: "update" });
417
+ },
418
+ async react(messageId: string, emoji: string, on = true) {
419
+ log("react", messageId, emoji, on);
420
+ const { c, m } = findMessage(messageId);
421
+ m.reactions = m.reactions.filter((r) => !(r.emoji === emoji && r.userId === me));
422
+ if (on) m.reactions.push({ emoji, userId: me });
423
+ emit(c.conversation.id, { type: "reaction", conversationId: c.conversation.id, messageId });
424
+ },
425
+ async markRead(conversationId: string, seq?: number) {
426
+ const c = get(conversationId);
427
+ const mine = c.members.find((x) => x.userId === me);
428
+ if (mine) mine.lastReadSeq = Math.max(mine.lastReadSeq, seq ?? c.conversation.lastSeq);
429
+ return mine?.lastReadSeq ?? 0;
430
+ },
431
+ async setMySettings(conversationId: string, s: { mutedUntil?: string | null; archived?: boolean; pinned?: boolean }) {
432
+ const c = get(conversationId);
433
+ if (s.mutedUntil !== undefined) c.muted = s.mutedUntil !== null;
434
+ if (s.pinned !== undefined) c.pinned = s.pinned;
435
+ },
436
+ async updateConversation(conversationId: string, s: { title?: string; settings?: Record<string, unknown> }) {
437
+ const c = get(conversationId);
438
+ if (s.title !== undefined) c.conversation.title = s.title;
439
+ if (s.settings) c.conversation.settings = { ...c.conversation.settings, ...s.settings };
440
+ },
441
+ async addMembers(conversationId: string, userIds: string[]) {
442
+ const c = get(conversationId);
443
+ for (const userId of userIds) if (!c.members.some((m) => m.userId === userId)) c.members.push({ conversationId, userId, role: "member", lastReadSeq: 0, joinedAt: new Date().toISOString() });
444
+ },
445
+ async removeMember(conversationId: string, userId?: string) {
446
+ const c = get(conversationId);
447
+ c.members = c.members.filter((m) => m.userId !== (userId ?? me));
448
+ },
449
+ async setStatus(conversationId: string, status: ChatConversation["status"], assigneeId?: string | null) {
450
+ const c = get(conversationId);
451
+ c.conversation.status = status;
452
+ c.conversation.assigneeId = assigneeId ?? null;
453
+ },
454
+ async pin(messageId: string, on = true) {
455
+ log("pin", messageId, on);
456
+ const { c } = findMessage(messageId);
457
+ c.pins = c.pins.filter((p) => p !== messageId);
458
+ if (on) c.pins.push(messageId);
459
+ emit(c.conversation.id, { type: "conversation", conversationId: c.conversation.id });
460
+ },
461
+ async pins(conversationId: string) {
462
+ return [...(convs.get(conversationId)?.pins ?? [])];
463
+ },
464
+ async save(messageId: string, on = true, note?: string) {
465
+ log("save", messageId, on, note);
466
+ },
467
+ async report(messageId: string, reason: "spam" | "harassment" | "safety" | "other", note?: string) {
468
+ log("report", messageId, reason, note);
469
+ return uuid();
470
+ },
471
+ async upload(
472
+ conversationId: string,
473
+ userId: string,
474
+ file: Blob | ArrayBuffer,
475
+ opts: { name: string; contentType: string; id?: string; onProgress?: (sent: number, total: number) => void; signal?: AbortSignal },
476
+ ) {
477
+ log("upload", conversationId, opts.name);
478
+ await wait();
479
+ const total = file instanceof Blob ? file.size : file.byteLength;
480
+ opts.onProgress?.(total, total);
481
+ const path = `${conversationId}/${userId}/${opts.id ?? uuid()}-${opts.name}`;
482
+ const blob = file instanceof Blob ? file : new Blob([file], { type: opts.contentType });
483
+ files[path] = typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(blob) : `memory://${path}`;
484
+ return path;
485
+ },
486
+ async fileUrls(paths: string[]) {
487
+ const out: Record<string, string> = {};
488
+ for (const p of paths) if (files[p]) out[p] = files[p]!;
489
+ return out;
490
+ },
491
+ subscribeInbox(_userId: string, onSignal: (s: ChatSignal) => void) {
492
+ inboxListeners.add(onSignal);
493
+ return { unsubscribe: () => void inboxListeners.delete(onSignal) };
494
+ },
495
+ subscribeConversation(conversationId: string, _me: string, handlers: ConversationHandlers): ConversationChannel {
496
+ const set = convListeners.get(conversationId) ?? new Set();
497
+ convListeners.set(conversationId, set);
498
+ const entry = { onSignal: handlers.onSignal, onActivity: handlers.onActivity };
499
+ set.add(entry);
500
+ // Joins at once, as a live channel does once it is subscribed.
501
+ setTimeout(() => {
502
+ if (set.has(entry)) handlers.onJoin?.();
503
+ }, 0);
504
+ return {
505
+ signal(activity) {
506
+ log("signal", conversationId, activity);
507
+ },
508
+ unsubscribe: () => void set.delete(entry),
509
+ };
510
+ },
511
+
512
+ // ── 0002 ──
513
+ async topics(conversationId: string) {
514
+ log("topics", conversationId);
515
+ await wait();
516
+ return [...(convs.get(conversationId)?.topics ?? [])].sort((a, b) => a.position - b.position || a.topic.localeCompare(b.topic)).map((t) => ({ ...t }));
517
+ },
518
+ async setTopic(conversationId: string, topic: string, input: TopicInput = {}) {
519
+ log("setTopic", conversationId, topic, input);
520
+ const c = get(conversationId);
521
+ if (c.myRole !== "owner" && c.myRole !== "admin") throw new ChatError("Only an owner or admin manages topics", "42501", "set_topic");
522
+ const key = topic.trim();
523
+ let t = c.topics.find((x) => x.topic === key);
524
+ if (!t) {
525
+ t = { conversationId, topic: key, title: input.title?.trim() || key, position: input.position ?? c.topics.length, closedAt: null, createdBy: me, createdAt: new Date().toISOString() };
526
+ c.topics.push(t);
527
+ } else {
528
+ if (input.title?.trim()) t.title = input.title.trim();
529
+ if (input.position != null) t.position = input.position;
530
+ }
531
+ if (input.closed === true) t.closedAt ??= new Date().toISOString();
532
+ if (input.closed === false) t.closedAt = null;
533
+ emit(conversationId, { type: "conversation", conversationId });
534
+ },
535
+ async schedule(input: ScheduleInput) {
536
+ log("schedule", input);
537
+ await wait();
538
+ const c = get(input.conversationId);
539
+ if (!c.canPost) throw new ChatError("You can't post in this conversation", "42501", "schedule_message");
540
+ const sendAt = typeof input.sendAt === "string" ? input.sendAt : input.sendAt.toISOString();
541
+ const t = Date.parse(sendAt);
542
+ if (!(t >= Date.now() + 60_000 && t <= Date.now() + 365 * 86_400_000)) throw new ChatError("Schedule between a minute and a year from now", "22023", "schedule_message");
543
+ if (!(input.body ?? "").trim() && !(input.attachments ?? []).length) throw new ChatError("A message needs text or an attachment", "22023", "schedule_message");
544
+ const existing = scheduledList.find((s) => s.clientId === input.clientId);
545
+ if (existing) {
546
+ existing.sendAt = sendAt;
547
+ return existing.id;
548
+ }
549
+ const s: ScheduledMessage = {
550
+ id: uuid(),
551
+ conversationId: input.conversationId,
552
+ clientId: input.clientId,
553
+ body: input.body ?? "",
554
+ attachments: input.attachments ?? [],
555
+ replyToId: input.replyToId ?? null,
556
+ topic: input.topic ?? null,
557
+ mentions: input.mentions ?? [],
558
+ silent: input.silent ?? false,
559
+ sendAt,
560
+ sentMessageId: null,
561
+ cancelledAt: null,
562
+ error: null,
563
+ createdAt: new Date().toISOString(),
564
+ status: "scheduled",
565
+ };
566
+ scheduledList.push(s);
567
+ return s.id;
568
+ },
569
+ async scheduled(conversationId?: string) {
570
+ log("scheduled", conversationId);
571
+ await wait();
572
+ return scheduledList
573
+ .filter((s) => (s.status === "scheduled" || s.status === "failed") && (!conversationId || s.conversationId === conversationId))
574
+ .sort((a, b) => a.sendAt.localeCompare(b.sendAt))
575
+ .map((s) => ({ ...s }));
576
+ },
577
+ async cancelScheduled(id: string) {
578
+ log("cancelScheduled", id);
579
+ // As cancel_scheduled: anything not sent and not cancelled, a failed one included.
580
+ const s = scheduledList.find((x) => x.id === id && x.status !== "sent" && x.status !== "cancelled");
581
+ if (!s) throw new ChatError("Nothing to cancel", "22023", "cancel_scheduled");
582
+ s.cancelledAt = new Date().toISOString();
583
+ s.status = "cancelled";
584
+ },
585
+ async availabilityFor(conversationId: string) {
586
+ log("availabilityFor", conversationId);
587
+ const c = get(conversationId);
588
+ return c.members.filter((m) => m.role !== "observer" && availability.has(m.userId)).map((m) => ({ ...availability.get(m.userId)! }));
589
+ },
590
+ async myAvailability() {
591
+ const a = availability.get(me);
592
+ return a ? { ...a } : null;
593
+ },
594
+ async setAvailability(input: AvailabilityInput) {
595
+ log("setAvailability", input);
596
+ availability.set(me, {
597
+ userId: me,
598
+ awayMessage: input.awayMessage?.trim() || null,
599
+ awayUntil: input.awayUntil ?? null,
600
+ timeZone: input.timeZone ?? null,
601
+ hours: input.hours ?? null,
602
+ });
603
+ },
604
+ async broadcast(conversationIds: string[], clientId: string, body: string, opts: { silent?: boolean } = {}) {
605
+ log("broadcast", conversationIds, clientId, body);
606
+ await wait();
607
+ const ids: string[] = [];
608
+ for (const id of new Set(conversationIds)) {
609
+ const c = convs.get(id);
610
+ if (!c?.canPost) continue;
611
+ const m = await client.send({ conversationId: id, clientId: `${clientId}:${id}`, body, silent: opts.silent });
612
+ ids.push(m.id);
613
+ }
614
+ return ids;
615
+ },
616
+ async search(query: string, conversationId?: string | null) {
617
+ log("search", query, conversationId);
618
+ await wait();
619
+ const q = query.trim().toLowerCase();
620
+ if (q.length < 2) return [];
621
+ const hits: ChatSearchHit[] = [];
622
+ // As search_messages does: the text's own « and » become ‹ and ›, so only real matches are marked.
623
+ const mark = (raw: string) => {
624
+ const text = raw.replace(/«/g, "‹").replace(/»/g, "›");
625
+ const i = text.toLowerCase().indexOf(q);
626
+ const start = Math.max(0, i - 40);
627
+ return `${start > 0 ? "…" : ""}${text.slice(start, i)}«${text.slice(i, i + q.length)}»${text.slice(i + q.length, i + q.length + 60)}`;
628
+ };
629
+ for (const c of convs.values()) {
630
+ if (conversationId && c.conversation.id !== conversationId) continue;
631
+ for (const m of c.messages) {
632
+ if (m.deletedAt || !visible(c, m)) continue;
633
+ const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt, threadRootId: m.threadRootId ?? null };
634
+ if (m.body.toLowerCase().includes(q)) hits.push({ ...base, snippet: mark(m.body), inTranscript: false });
635
+ else {
636
+ const t = m.attachments.find((a) => a.transcript?.toLowerCase().includes(q))?.transcript;
637
+ if (t) hits.push({ ...base, snippet: mark(t), inTranscript: true });
638
+ }
639
+ }
640
+ }
641
+ return hits.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 30);
642
+ },
643
+ async linkPreviewsFor(messageIds: string[]) {
644
+ log("linkPreviewsFor", messageIds);
645
+ const out: Record<string, ChatLinkPreview[]> = {};
646
+ for (const id of new Set(messageIds)) {
647
+ let found: { c: Conv; m: ChatMessage } | null = null;
648
+ try {
649
+ found = findMessage(id);
650
+ } catch {
651
+ found = null;
652
+ }
653
+ // Only for a message you can read, as link_previews_for answers.
654
+ if (!found || found.m.deletedAt || !visible(found.c, found.m)) {
655
+ out[id] = [];
656
+ continue;
657
+ }
658
+ out[id] = extractLinks(found.m.body)
659
+ .map((u) => previews.get(u))
660
+ .filter((p): p is ChatLinkPreview => Boolean(p && p.status === "ok"))
661
+ .map((p) => ({ ...p }));
662
+ }
663
+ return out;
664
+ },
665
+ async sendDue(now = new Date()) {
666
+ let n = 0;
667
+ for (const s of scheduledList) {
668
+ if (s.status !== "scheduled" || Date.parse(s.sendAt) > now.getTime()) continue;
669
+ try {
670
+ const m = await client.send({ conversationId: s.conversationId, clientId: s.clientId, body: s.body, attachments: s.attachments, replyToId: s.replyToId, topic: s.topic, mentions: s.mentions, silent: s.silent });
671
+ s.sentMessageId = m.id;
672
+ s.status = "sent";
673
+ n += 1;
674
+ } catch (e) {
675
+ s.error = e instanceof Error ? e.message : String(e);
676
+ s.status = "failed";
677
+ }
678
+ }
679
+ return n;
680
+ },
681
+ moderate(messageId: string, result: "clean" | "flagged" | "hidden") {
682
+ if (result === "clean") return;
683
+ const { c, m } = findMessage(messageId);
684
+ m.moderation = result;
685
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, seq: m.seq, op: "update" });
686
+ },
687
+
688
+ receive(conversationId: string, senderId: string, body: string, extra: Partial<ChatMessage> = {}) {
689
+ const c = get(conversationId);
690
+ const m = makeMessage(c, { senderId, body, ...extra } as Partial<ChatMessage>);
691
+ c.messages.push(m);
692
+ emit(conversationId, { type: "message", conversationId, id: m.id, seq: m.seq, op: "insert" });
693
+ return clone(m);
694
+ },
695
+ activity(conversationId: string, userId: string, activity: ChatActivity["activity"]) {
696
+ for (const l of convListeners.get(conversationId) ?? []) l.onActivity?.({ userId, activity, at: Date.now() });
697
+ },
698
+ failNextSends(count = 1) {
699
+ failing = count;
700
+ },
701
+ holdNextSend() {
702
+ let release!: () => void;
703
+ held = new Promise<void>((r) => (release = r));
704
+ return () => release();
705
+ },
706
+ };
707
+ return client;
708
+ }
709
+
710
+ function keepKind(kind: string, opts: { kind?: string; kinds?: string[]; excludeKinds?: string[] }): boolean {
711
+ if (opts.kind && kind !== opts.kind) return false;
712
+ if (opts.kinds?.length && !opts.kinds.includes(kind)) return false;
713
+ return !opts.excludeKinds?.includes(kind);
714
+ }