@marktiderman/genesis-chat 0.1.1 → 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,302 @@
1
+ /**
2
+ * @feat:chat
3
+ * The composer as plain state, so it is the same in every screen and tested
4
+ * without a phone: the text, the files waiting to go, what it replies to, why
5
+ * it can't post, what a send hands the outbox, and the draft kept per thread.
6
+ */
7
+ import { CHAT_LIMITS, attachmentKindFor, fileExtension, isAllowedExtension } from "../core/media";
8
+ import { parseMentions } from "../core/thread";
9
+ import type { LocalFile } from "../core/outbox";
10
+ import type { ChatConversation, ChatMessage, ChatRole } from "../core/types";
11
+ import { fill, type ChatNativeCopy } from "./copy";
12
+ import type { DraftStorageLike, PickedAssetLike } from "./modules";
13
+
14
+ /** A file in the composer, shown as a thumbnail until it is sent. */
15
+ export interface ComposerAttachment {
16
+ /** Stable for keys and removal. */
17
+ key: string;
18
+ file: LocalFile;
19
+ }
20
+
21
+ export interface ReplyTarget {
22
+ messageId: string;
23
+ /** Who wrote it, as shown ("Sam", "You"). */
24
+ name: string;
25
+ /** A one-line preview. */
26
+ preview: string;
27
+ }
28
+
29
+ export interface ComposerState {
30
+ text: string;
31
+ attachments: ComposerAttachment[];
32
+ replyTo: ReplyTarget | null;
33
+ /** The last thing that couldn't be added ("x.svg can't be sent"). Cleared by the next change. */
34
+ notice: string | null;
35
+ }
36
+
37
+ export const emptyComposer: ComposerState = { text: "", attachments: [], replyTo: null, notice: null };
38
+
39
+ export type ComposerAction =
40
+ | { type: "text"; text: string }
41
+ /** `tooMany` is what to say when the files don't all fit (copy.tooMany). */
42
+ | { type: "add"; attachments: ComposerAttachment[]; notice?: string | null; tooMany?: string }
43
+ | { type: "remove"; key: string }
44
+ | { type: "reply"; to: ReplyTarget | null }
45
+ | { type: "restore"; draft: ComposerDraft }
46
+ /** Another conversation (or thread) opened in the same composer: start empty. */
47
+ | { type: "reset" }
48
+ | { type: "sent" };
49
+
50
+ export function composerReducer(state: ComposerState, action: ComposerAction): ComposerState {
51
+ switch (action.type) {
52
+ case "text":
53
+ return { ...state, text: action.text.slice(0, CHAT_LIMITS.bodyChars), notice: null };
54
+ case "add": {
55
+ const room = CHAT_LIMITS.attachmentsPerMessage - state.attachments.length;
56
+ const added = action.attachments.slice(0, Math.max(0, room));
57
+ const overflow = action.attachments.length > added.length;
58
+ return {
59
+ ...state,
60
+ attachments: [...state.attachments, ...added],
61
+ notice: overflow ? (action.tooMany ?? action.notice ?? null) : (action.notice ?? null),
62
+ };
63
+ }
64
+ case "remove":
65
+ return { ...state, attachments: state.attachments.filter((a) => a.key !== action.key), notice: null };
66
+ case "reply":
67
+ return { ...state, replyTo: action.to };
68
+ case "restore":
69
+ // A draft never replaces what was typed meanwhile.
70
+ if (state.text || state.attachments.length) return state;
71
+ return { ...state, text: action.draft.text, replyTo: action.draft.replyTo ?? null };
72
+ case "reset":
73
+ case "sent":
74
+ return emptyComposer;
75
+ }
76
+ }
77
+
78
+ /** Something to send: text that isn't blank, or a file. */
79
+ export function canSend(state: ComposerState, canPost: boolean): boolean {
80
+ return canPost && (state.text.trim().length > 0 || state.attachments.length > 0);
81
+ }
82
+
83
+ /**
84
+ * Why this person can't post here, for the disabled composer; null when they can.
85
+ * `canPost` is the server's answer (null while it loads, which disables quietly).
86
+ */
87
+ export function composerDisabledReason(
88
+ canPost: boolean | null,
89
+ conversation: Pick<ChatConversation, "settings" | "status" | "archivedAt"> | null,
90
+ role: ChatRole | null,
91
+ copy: Pick<ChatNativeCopy, "cantPost" | "cantPostAdmins" | "cantPostClosed">,
92
+ ): string | null {
93
+ if (canPost !== false) return null;
94
+ if (conversation?.status === "closed" || conversation?.status === "solved" || conversation?.archivedAt) return copy.cantPostClosed;
95
+ if (conversation?.settings?.who_posts === "admins" && role !== "owner" && role !== "admin") return copy.cantPostAdmins;
96
+ return copy.cantPost;
97
+ }
98
+
99
+ /** What `useConversation().send` takes: the input and the files. */
100
+ export function toSend(state: ComposerState): { input: { body: string; replyToId: string | null; mentions: string[] }; files: LocalFile[] } {
101
+ const body = state.text.trim();
102
+ return {
103
+ input: { body, replyToId: state.replyTo?.messageId ?? null, mentions: parseMentions(body) },
104
+ files: state.attachments.map((a) => a.file),
105
+ };
106
+ }
107
+
108
+ /** A reply target from a message. */
109
+ export function replyTargetFor(message: ChatMessage, name: string, preview: string): ReplyTarget {
110
+ return { messageId: message.id, name, preview };
111
+ }
112
+
113
+ let seq = 0;
114
+ const nextKey = () => `a${Date.now().toString(36)}${(seq++).toString(36)}`;
115
+
116
+ /**
117
+ * Picked photos and videos as files to send, with what was refused and why:
118
+ * a type the bucket won't take, a file over the limit.
119
+ */
120
+ export function filesFromAssets(
121
+ assets: readonly PickedAssetLike[],
122
+ copy: Pick<ChatNativeCopy, "tooLarge" | "notAllowedType" | "photo" | "video">,
123
+ ): { attachments: ComposerAttachment[]; notice: string | null } {
124
+ const attachments: ComposerAttachment[] = [];
125
+ let notice: string | null = null;
126
+ for (const asset of assets) {
127
+ const video = asset.type === "video" || (asset.mimeType ?? "").startsWith("video/");
128
+ const contentType = asset.mimeType ?? (video ? "video/mp4" : "image/jpeg");
129
+ const fallbackName = `${video ? "video" : "photo"}.${video ? "mp4" : "jpg"}`;
130
+ const name = asset.fileName ?? (asset.uri.split("/").pop() || fallbackName);
131
+ const ext = fileExtension(name, contentType);
132
+ if (!isAllowedExtension(ext)) {
133
+ notice = fill(copy.notAllowedType, { name });
134
+ continue;
135
+ }
136
+ const limit = video ? CHAT_LIMITS.videoBytes : CHAT_LIMITS.fileBytes;
137
+ if (asset.fileSize != null && asset.fileSize > limit) {
138
+ notice = fill(copy.tooLarge, { name });
139
+ continue;
140
+ }
141
+ attachments.push({
142
+ key: nextKey(),
143
+ file: {
144
+ data: asset.uri,
145
+ name,
146
+ contentType,
147
+ previewUrl: asset.uri,
148
+ meta: {
149
+ kind: attachmentKindFor(contentType),
150
+ width: asset.width || undefined,
151
+ height: asset.height || undefined,
152
+ bytes: asset.fileSize ?? undefined,
153
+ durationMs: video && asset.duration ? Math.round(asset.duration) : undefined,
154
+ },
155
+ },
156
+ });
157
+ }
158
+ return { attachments, notice };
159
+ }
160
+
161
+ /** A pasted picture (a data URI from the clipboard) as a file to send. */
162
+ export function fileFromDataUri(dataUri: string, size?: { width: number; height: number }): ComposerAttachment | null {
163
+ const m = /^data:([^;,]+);base64,(.*)$/s.exec(dataUri);
164
+ if (!m) return null;
165
+ const contentType = m[1]!.toLowerCase();
166
+ const ext = fileExtension("", contentType);
167
+ if (!contentType.startsWith("image/") || !isAllowedExtension(ext)) return null;
168
+ const bytes = base64ToBytes(m[2]!);
169
+ return {
170
+ key: nextKey(),
171
+ file: {
172
+ data: bytes.buffer as ArrayBuffer,
173
+ name: `pasted.${ext}`,
174
+ contentType,
175
+ previewUrl: dataUri,
176
+ meta: { kind: "image", width: size?.width, height: size?.height, bytes: bytes.byteLength },
177
+ },
178
+ };
179
+ }
180
+
181
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
182
+
183
+ /** Base64 to bytes without `atob` (not on every Hermes build). */
184
+ export function base64ToBytes(b64: string): Uint8Array {
185
+ const clean = b64.replace(/[^A-Za-z0-9+/]/g, "");
186
+ const out = new Uint8Array(Math.floor((clean.length * 3) / 4));
187
+ let o = 0;
188
+ for (let i = 0; i < clean.length; i += 4) {
189
+ const n =
190
+ (B64.indexOf(clean[i]!) << 18) |
191
+ (B64.indexOf(clean[i + 1] ?? "A") << 12) |
192
+ ((i + 2 < clean.length ? B64.indexOf(clean[i + 2]!) : 0) << 6) |
193
+ (i + 3 < clean.length ? B64.indexOf(clean[i + 3]!) : 0);
194
+ if (o < out.length) out[o++] = (n >> 16) & 255;
195
+ if (i + 2 < clean.length && o < out.length) out[o++] = (n >> 8) & 255;
196
+ if (i + 3 < clean.length && o < out.length) out[o++] = n & 255;
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // ---- Drafts ---------------------------------------------------------------
202
+
203
+ /** What a draft keeps: the text and what it replies to (never the files, which may be gone by then). */
204
+ export interface ComposerDraft {
205
+ text: string;
206
+ replyTo?: ReplyTarget | null;
207
+ }
208
+
209
+ const DRAFT_PREFIX = "genesis-chat:drafts:";
210
+ /** Drafts saved before they were kept per person: no one's, so any sign-out clears them. */
211
+ const LEGACY_DRAFT_PREFIX = "genesis-chat:draft:";
212
+
213
+ /**
214
+ * One draft per signed-in person, per conversation, and per thread within it.
215
+ * The person comes first, so `clearDrafts` can find all of theirs on sign-out,
216
+ * and a second login on the phone never sees the first one's drafts.
217
+ */
218
+ export function draftKey(userId: string, conversationId: string, threadRootId?: string | null): string {
219
+ return `${DRAFT_PREFIX}${userId}:${conversationId}${threadRootId ? `:${threadRootId}` : ""}`;
220
+ }
221
+
222
+ /**
223
+ * Removes drafts from the phone: one person's (`clearDrafts(storage, userId)`,
224
+ * on sign-out), or everyone's but one (`{ except: userId }`, on sign-in, which
225
+ * also covers a session that ended while the app was closed). Drafts saved
226
+ * before drafts were kept per person go either way. Needs the storage's
227
+ * `getAllKeys` (AsyncStorage has it); without it, it reports that through
228
+ * `onError` and removes nothing. Never throws. Returns how many it removed.
229
+ */
230
+ export async function clearDrafts(storage: DraftStorageLike, who: string | { except: string }, onError?: (error: unknown) => void): Promise<number> {
231
+ try {
232
+ if (typeof storage.getAllKeys !== "function") {
233
+ onError?.(new Error("chat drafts: the draft storage has no getAllKeys, so drafts weren't cleared"));
234
+ return 0;
235
+ }
236
+ const goes = (k: string) =>
237
+ k.startsWith(LEGACY_DRAFT_PREFIX) ||
238
+ (k.startsWith(DRAFT_PREFIX) && (typeof who === "string" ? k.startsWith(`${DRAFT_PREFIX}${who}:`) : !k.startsWith(`${DRAFT_PREFIX}${who.except}:`)));
239
+ const keys = (await storage.getAllKeys()).filter(goes);
240
+ if (keys.length === 0) return 0;
241
+ if (typeof storage.multiRemove === "function") await storage.multiRemove(keys);
242
+ else await Promise.all(keys.map((k) => storage.removeItem(k)));
243
+ return keys.length;
244
+ } catch (error) {
245
+ onError?.(error);
246
+ return 0;
247
+ }
248
+ }
249
+
250
+ export function memoryDraftStorage(): DraftStorageLike {
251
+ const map = new Map<string, string>();
252
+ return {
253
+ getItem: async (k) => map.get(k) ?? null,
254
+ setItem: async (k, v) => void map.set(k, v),
255
+ removeItem: async (k) => void map.delete(k),
256
+ getAllKeys: async () => [...map.keys()],
257
+ };
258
+ }
259
+
260
+ export async function loadDraft(storage: DraftStorageLike, key: string): Promise<ComposerDraft | null> {
261
+ try {
262
+ const raw = await storage.getItem(key);
263
+ if (!raw) return null;
264
+ const parsed = JSON.parse(raw) as ComposerDraft;
265
+ return typeof parsed?.text === "string" ? parsed : null;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Saves the draft a moment after typing stops (and at once on `flush`); an
273
+ * empty composer removes it. Storage errors are swallowed: a draft is a nicety.
274
+ */
275
+ export function createDraftSaver(storage: DraftStorageLike, key: string, delayMs = 400) {
276
+ let timer: ReturnType<typeof setTimeout> | null = null;
277
+ let latest: ComposerDraft | null = null;
278
+ const write = () => {
279
+ timer = null;
280
+ const d = latest;
281
+ if (!d) return;
282
+ const p = d.text.trim() || d.replyTo ? storage.setItem(key, JSON.stringify(d)) : storage.removeItem(key);
283
+ void p.catch(() => undefined);
284
+ };
285
+ return {
286
+ save(draft: ComposerDraft) {
287
+ latest = draft;
288
+ if (timer) clearTimeout(timer);
289
+ timer = setTimeout(write, delayMs);
290
+ },
291
+ flush() {
292
+ if (timer) clearTimeout(timer);
293
+ write();
294
+ },
295
+ clear() {
296
+ if (timer) clearTimeout(timer);
297
+ timer = null;
298
+ latest = null;
299
+ void storage.removeItem(key).catch(() => undefined);
300
+ },
301
+ };
302
+ }