@marktiderman/genesis-chat 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,115 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ /**
4
+ * Slide up to lock, then tap Send. The mic stays one view through the lock: swapping in a new
5
+ * button while the finger that locked was still down left that touch's responder unmounted on
6
+ * iOS, and the next tap anywhere did nothing (seen in the simulator: the first Send tap after
7
+ * locking was lost).
8
+ */
9
+ import { act, cleanup, render, screen } from "@testing-library/react";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { ChatComposer } from "../composer";
12
+ import { ChatNativeProvider } from "../context";
13
+ import type { ChatNativeModules, ExpoAudioLike, RecorderStateLike } from "../modules";
14
+ import { useChatComposer } from "../use-composer";
15
+
16
+ type Handlers = {
17
+ onPanResponderGrant: () => void;
18
+ onPanResponderMove: (e: unknown, g: { dx: number; dy: number }) => void;
19
+ onPanResponderRelease: () => void;
20
+ };
21
+ // The shared React Native shim's PanResponder does nothing: this one keeps what the mic gave it.
22
+ const pan = vi.hoisted(() => ({ created: [] as Handlers[] }));
23
+ vi.mock("react-native", async (original) => ({
24
+ ...(await original<object>()),
25
+ AppState: { addEventListener: () => ({ remove: () => undefined }) },
26
+ PanResponder: {
27
+ create: (config: Handlers) => {
28
+ pan.created.push(config);
29
+ return { panHandlers: {} };
30
+ },
31
+ },
32
+ }));
33
+
34
+ function fakeAudio() {
35
+ const status: RecorderStateLike = { isRecording: false, durationMillis: 0, metering: -30, url: null };
36
+ const recorder = {
37
+ uri: null as string | null,
38
+ prepareToRecordAsync: vi.fn(async () => undefined),
39
+ record: vi.fn(() => {
40
+ status.isRecording = true;
41
+ }),
42
+ stop: vi.fn(async () => {
43
+ status.isRecording = false;
44
+ recorder.uri = "file:///rec/voice.m4a";
45
+ }),
46
+ getStatus: vi.fn(() => ({ ...status })),
47
+ };
48
+ const audio = {
49
+ useAudioRecorder: vi.fn(() => recorder),
50
+ useAudioPlayer: vi.fn(),
51
+ useAudioPlayerStatus: vi.fn(),
52
+ RecordingPresets: { HIGH_QUALITY: { extension: ".m4a" } },
53
+ requestRecordingPermissionsAsync: vi.fn(async () => ({ granted: true })),
54
+ setAudioModeAsync: vi.fn(async () => undefined),
55
+ } as unknown as ExpoAudioLike;
56
+ return { audio, recorder, status };
57
+ }
58
+
59
+ function Harness({ onSend }: { onSend: (s: unknown) => void }) {
60
+ const composer = useChatComposer("c1", null, "u1");
61
+ return <ChatComposer composer={composer} canPost disabledReason={null} role="member" onSend={onSend} testID="cmp" />;
62
+ }
63
+
64
+ describe("ChatComposer mic: slide up to lock, then tap Send", () => {
65
+ beforeEach(() => {
66
+ vi.useFakeTimers();
67
+ pan.created.length = 0;
68
+ });
69
+ afterEach(() => {
70
+ cleanup();
71
+ vi.useRealTimers();
72
+ });
73
+
74
+ it("keeps the same view when it locks, and the first tap on Send sends", async () => {
75
+ const { audio, recorder, status } = fakeAudio();
76
+ const onSend = vi.fn();
77
+ render(
78
+ <ChatNativeProvider modules={{ audio } as unknown as ChatNativeModules}>
79
+ <Harness onSend={onSend} />
80
+ </ChatNativeProvider>,
81
+ );
82
+ const mic = screen.getByTestId("cmp-mic");
83
+ const handlers = pan.created.at(-1)!;
84
+
85
+ // Hold, start recording, slide up past the lock, let go.
86
+ await act(async () => {
87
+ handlers.onPanResponderGrant();
88
+ await vi.advanceTimersByTimeAsync(0);
89
+ });
90
+ status.durationMillis = 2000;
91
+ await act(async () => void (await vi.advanceTimersByTimeAsync(200)));
92
+ act(() => handlers.onPanResponderMove({}, { dx: 0, dy: -100 }));
93
+ const send = screen.getByTestId("cmp-voice-send");
94
+ expect(send).toBe(mic);
95
+ expect(send.getAttribute("aria-label")).toBe("Send voice message");
96
+ await act(async () => {
97
+ handlers.onPanResponderRelease();
98
+ await vi.advanceTimersByTimeAsync(0);
99
+ });
100
+ // Letting go of the finger that locked doesn't send: recording goes on hands-free.
101
+ expect(recorder.stop).not.toHaveBeenCalled();
102
+ expect(screen.getByTestId("cmp-voice-send")).toBe(mic);
103
+
104
+ // One tap on Send.
105
+ await act(async () => {
106
+ handlers.onPanResponderGrant();
107
+ handlers.onPanResponderRelease();
108
+ await vi.advanceTimersByTimeAsync(0);
109
+ });
110
+ expect(recorder.stop).toHaveBeenCalledTimes(1);
111
+ await act(async () => void (await vi.advanceTimersByTimeAsync(50)));
112
+ expect(onSend).toHaveBeenCalledTimes(1);
113
+ expect(screen.getByTestId("cmp-mic")).toBe(mic);
114
+ });
115
+ });
@@ -0,0 +1,188 @@
1
+ // @feat:chat
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { CHAT_LIMITS } from "../../core/media";
4
+ import {
5
+ base64ToBytes,
6
+ canSend,
7
+ composerDisabledReason,
8
+ composerReducer,
9
+ createDraftSaver,
10
+ draftKey,
11
+ clearDrafts,
12
+ emptyComposer,
13
+ fileFromDataUri,
14
+ filesFromAssets,
15
+ loadDraft,
16
+ memoryDraftStorage,
17
+ toSend,
18
+ type ComposerAttachment,
19
+ } from "../composer-state";
20
+ import { chatNativeCopy as copy } from "../copy";
21
+
22
+ const file = (key: string): ComposerAttachment => ({ key, file: { data: `file:///${key}.jpg`, name: `${key}.jpg`, contentType: "image/jpeg" } });
23
+
24
+ describe("composerReducer", () => {
25
+ it("types, attaches, removes, replies and empties on send", () => {
26
+ let s = composerReducer(emptyComposer, { type: "text", text: "hi " });
27
+ s = composerReducer(s, { type: "add", attachments: [file("a"), file("b")] });
28
+ s = composerReducer(s, { type: "remove", key: "a" });
29
+ s = composerReducer(s, { type: "reply", to: { messageId: "m1", name: "Sam", preview: "yo" } });
30
+ expect(s.attachments.map((a) => a.key)).toEqual(["b"]);
31
+ expect(s.replyTo?.messageId).toBe("m1");
32
+ expect(composerReducer(s, { type: "sent" })).toEqual(emptyComposer);
33
+ });
34
+
35
+ it("keeps at most the per-message limit of files, and says so", () => {
36
+ const many = Array.from({ length: CHAT_LIMITS.attachmentsPerMessage + 3 }, (_, i) => file(`f${i}`));
37
+ const s = composerReducer(emptyComposer, { type: "add", attachments: many, tooMany: "Too many" });
38
+ expect(s.attachments).toHaveLength(CHAT_LIMITS.attachmentsPerMessage);
39
+ expect(s.notice).toBe("Too many");
40
+ });
41
+
42
+ it("caps the text at the body limit", () => {
43
+ const s = composerReducer(emptyComposer, { type: "text", text: "x".repeat(CHAT_LIMITS.bodyChars + 10) });
44
+ expect(s.text).toHaveLength(CHAT_LIMITS.bodyChars);
45
+ });
46
+
47
+ it("restores a draft only into an empty composer", () => {
48
+ const draft = { text: "draft", replyTo: { messageId: "m", name: "Lee", preview: "p" } };
49
+ expect(composerReducer(emptyComposer, { type: "restore", draft }).text).toBe("draft");
50
+ const typing = composerReducer(emptyComposer, { type: "text", text: "new" });
51
+ expect(composerReducer(typing, { type: "restore", draft }).text).toBe("new");
52
+ });
53
+ });
54
+
55
+ describe("canSend and the disabled reason", () => {
56
+ it("needs text that isn't blank, or a file, and permission", () => {
57
+ expect(canSend({ ...emptyComposer, text: " " }, true)).toBe(false);
58
+ expect(canSend({ ...emptyComposer, text: "a" }, true)).toBe(true);
59
+ expect(canSend({ ...emptyComposer, attachments: [file("a")] }, true)).toBe(true);
60
+ expect(canSend({ ...emptyComposer, text: "a" }, false)).toBe(false);
61
+ });
62
+
63
+ it("explains why: closed, admins only, or not allowed; nothing while loading or allowed", () => {
64
+ const conv = { settings: {}, status: null, archivedAt: null };
65
+ expect(composerDisabledReason(null, conv, null, copy)).toBeNull();
66
+ expect(composerDisabledReason(true, conv, "member", copy)).toBeNull();
67
+ expect(composerDisabledReason(false, { ...conv, status: "closed" as const }, "member", copy)).toBe(copy.cantPostClosed);
68
+ expect(composerDisabledReason(false, { ...conv, settings: { who_posts: "admins" as const } }, "member", copy)).toBe(copy.cantPostAdmins);
69
+ expect(composerDisabledReason(false, conv, "observer", copy)).toBe(copy.cantPost);
70
+ });
71
+ });
72
+
73
+ describe("toSend", () => {
74
+ it("trims the body, carries the reply and the mentions, and the files", () => {
75
+ const id = "0b5f3c3e-7f53-4a5e-9f43-6a8d2c1e9b10";
76
+ const s = { ...emptyComposer, text: ` hi @[Sam](user:${id}) `, attachments: [file("a")], replyTo: { messageId: "m1", name: "Sam", preview: "" } };
77
+ const { input, files } = toSend(s);
78
+ expect(input).toEqual({ body: `hi @[Sam](user:${id})`, replyToId: "m1", mentions: [id] });
79
+ expect(files).toHaveLength(1);
80
+ });
81
+ });
82
+
83
+ describe("filesFromAssets", () => {
84
+ it("makes photos and videos into files with their size and kind", () => {
85
+ const { attachments, notice } = filesFromAssets(
86
+ [
87
+ { uri: "file:///p/IMG_1.HEIC", fileName: "IMG_1.HEIC", mimeType: "image/heic", width: 4032, height: 3024, fileSize: 2_000_000, type: "image" },
88
+ { uri: "file:///p/v.mov", fileName: "v.mov", mimeType: "video/quicktime", duration: 4200, type: "video" },
89
+ ],
90
+ copy,
91
+ );
92
+ expect(notice).toBeNull();
93
+ expect(attachments[0]!.file).toMatchObject({ data: "file:///p/IMG_1.HEIC", contentType: "image/heic", meta: { kind: "image", width: 4032, height: 3024, bytes: 2_000_000 } });
94
+ expect(attachments[1]!.file.meta).toMatchObject({ kind: "video", durationMs: 4200 });
95
+ });
96
+
97
+ it("refuses a type the bucket won't take, and a file over the limit", () => {
98
+ const { attachments, notice } = filesFromAssets([{ uri: "file:///x.svg", fileName: "x.svg", mimeType: "image/svg+xml" }], copy);
99
+ expect(attachments).toHaveLength(0);
100
+ expect(notice).toBe("x.svg can't be sent.");
101
+ const big = filesFromAssets([{ uri: "file:///b.jpg", fileName: "b.jpg", mimeType: "image/jpeg", fileSize: CHAT_LIMITS.fileBytes + 1 }], copy);
102
+ expect(big.notice).toBe("b.jpg is too large to send.");
103
+ });
104
+ });
105
+
106
+ describe("pasting a picture", () => {
107
+ it("decodes a data URI into bytes", () => {
108
+ expect([...base64ToBytes("aGk=")]).toEqual([104, 105]);
109
+ expect([...base64ToBytes("aGVsbG8=")]).toEqual([...new TextEncoder().encode("hello")]);
110
+ const a = fileFromDataUri("data:image/png;base64,aGVsbG8=", { width: 2, height: 3 })!;
111
+ expect(a.file).toMatchObject({ name: "pasted.png", contentType: "image/png", meta: { kind: "image", width: 2, height: 3, bytes: 5 } });
112
+ expect(a.file.data).toBeInstanceOf(ArrayBuffer);
113
+ });
114
+
115
+ it("ignores what isn't a picture", () => {
116
+ expect(fileFromDataUri("data:text/html;base64,PGI+")).toBeNull();
117
+ expect(fileFromDataUri("nope")).toBeNull();
118
+ });
119
+ });
120
+
121
+ describe("drafts", () => {
122
+ afterEach(() => vi.useRealTimers());
123
+
124
+ it("has one key per person, conversation and thread", () => {
125
+ expect(draftKey("u1", "c1")).toBe("genesis-chat:drafts:u1:c1");
126
+ expect(draftKey("u1", "c1", "r1")).toBe("genesis-chat:drafts:u1:c1:r1");
127
+ expect(draftKey("u2", "c1")).not.toBe(draftKey("u1", "c1"));
128
+ });
129
+
130
+ it("clears one person's drafts on sign-out and leaves everything else", async () => {
131
+ const storage = memoryDraftStorage();
132
+ await storage.setItem(draftKey("u1", "c1"), "{}");
133
+ await storage.setItem(draftKey("u1", "c2", "r1"), "{}");
134
+ await storage.setItem(draftKey("u10", "c1"), "{}");
135
+ await storage.setItem("other-app-key", "x");
136
+ // Saved before drafts were per person: no one's, so it goes too.
137
+ await storage.setItem("genesis-chat:draft:c9", "{}");
138
+ expect(await clearDrafts(storage, "u1")).toBe(3);
139
+ expect(await storage.getItem("genesis-chat:draft:c9")).toBeNull();
140
+ expect(await storage.getItem(draftKey("u1", "c1"))).toBeNull();
141
+ expect(await storage.getItem(draftKey("u1", "c2", "r1"))).toBeNull();
142
+ expect(await storage.getItem(draftKey("u10", "c1"))).toBe("{}");
143
+ expect(await storage.getItem("other-app-key")).toBe("x");
144
+ });
145
+
146
+ it("on sign-in clears everyone's drafts but the person's own (a session that ended while the app was closed)", async () => {
147
+ const storage = memoryDraftStorage();
148
+ await storage.setItem(draftKey("u1", "c1"), "{}");
149
+ await storage.setItem(draftKey("u2", "c1"), "{}");
150
+ await storage.setItem("genesis-chat:draft:c9", "{}");
151
+ await storage.setItem("other-app-key", "x");
152
+ expect(await clearDrafts(storage, { except: "u2" })).toBe(2);
153
+ expect(await storage.getItem(draftKey("u2", "c1"))).toBe("{}");
154
+ expect(await storage.getItem("other-app-key")).toBe("x");
155
+ });
156
+
157
+ it("says so, and clears nothing, when the storage can't list its keys", async () => {
158
+ const onError = vi.fn();
159
+ const { getAllKeys: _, ...noList } = memoryDraftStorage();
160
+ expect(await clearDrafts(noList, "u1", onError)).toBe(0);
161
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
162
+ });
163
+
164
+ it("saves after typing stops, flushes at once, removes when empty", async () => {
165
+ vi.useFakeTimers();
166
+ const storage = memoryDraftStorage();
167
+ const saver = createDraftSaver(storage, "k", 400);
168
+ saver.save({ text: "h" });
169
+ saver.save({ text: "hello" });
170
+ expect(await storage.getItem("k")).toBeNull();
171
+ await vi.advanceTimersByTimeAsync(400);
172
+ expect(await loadDraft(storage, "k")).toEqual({ text: "hello" });
173
+ saver.save({ text: " " });
174
+ saver.flush();
175
+ await Promise.resolve();
176
+ expect(await storage.getItem("k")).toBeNull();
177
+ saver.save({ text: "again" });
178
+ saver.clear();
179
+ await vi.advanceTimersByTimeAsync(1000);
180
+ expect(await storage.getItem("k")).toBeNull();
181
+ });
182
+
183
+ it("reads a broken draft as none", async () => {
184
+ const storage = memoryDraftStorage();
185
+ await storage.setItem("k", "{oops");
186
+ expect(await loadDraft(storage, "k")).toBeNull();
187
+ });
188
+ });
@@ -0,0 +1,133 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { ChatComposer } from "../composer";
7
+ import { ChatNativeProvider } from "../context";
8
+ import type { ChatNativeModules } from "../modules";
9
+ import { useChatComposer } from "../use-composer";
10
+ import { memoryDraftStorage, draftKey } from "../composer-state";
11
+
12
+ afterEach(cleanup);
13
+
14
+ function Harness({ canPost = true, onSend = vi.fn(), reason = null as string | null }) {
15
+ const composer = useChatComposer("c1", null, "u1");
16
+ return <ChatComposer composer={composer} canPost={canPost} disabledReason={reason} role="member" onSend={onSend} testID="cmp" />;
17
+ }
18
+
19
+ describe("ChatComposer", () => {
20
+ it("says why when the person can't post, instead of taking input", () => {
21
+ render(
22
+ <ChatNativeProvider>
23
+ <Harness canPost={false} reason="Only admins can post here." />
24
+ </ChatNativeProvider>,
25
+ );
26
+ expect(screen.getByTestId("cmp-disabled").textContent).toBe("Only admins can post here.");
27
+ expect(screen.queryByTestId("cmp-input")).toBeNull();
28
+ });
29
+
30
+ it("sends what was typed, with a haptic, and empties", async () => {
31
+ const onSend = vi.fn();
32
+ const haptics = { impactAsync: vi.fn(async () => undefined), selectionAsync: vi.fn(), notificationAsync: vi.fn(), ImpactFeedbackStyle: { Light: "l", Medium: "m" }, NotificationFeedbackType: { Success: 1, Warning: 2, Error: 3 } };
33
+ render(
34
+ <ChatNativeProvider modules={{ haptics } as unknown as ChatNativeModules}>
35
+ <Harness onSend={onSend} />
36
+ </ChatNativeProvider>,
37
+ );
38
+ expect(screen.getByTestId("cmp-send").getAttribute("aria-disabled")).toBe("true");
39
+ fireEvent.change(screen.getByTestId("cmp-input"), { target: { value: "hello" } });
40
+ // Two quick taps send once.
41
+ fireEvent.click(screen.getByTestId("cmp-send"));
42
+ fireEvent.click(screen.getByTestId("cmp-send"));
43
+ expect(onSend).toHaveBeenCalledTimes(1);
44
+ expect(onSend).toHaveBeenCalledWith({ input: { body: "hello", replyToId: null, mentions: [] }, files: [] });
45
+ expect(haptics.impactAsync).toHaveBeenCalledWith("l");
46
+ await waitFor(() => expect((screen.getByTestId("cmp-input") as HTMLInputElement).value).toBe(""));
47
+ });
48
+
49
+ it("restores the thread's draft", async () => {
50
+ const drafts = memoryDraftStorage();
51
+ await drafts.setItem(draftKey("u1", "c1"), JSON.stringify({ text: "half written" }));
52
+ render(
53
+ <ChatNativeProvider modules={{ drafts }}>
54
+ <Harness />
55
+ </ChatNativeProvider>,
56
+ );
57
+ await waitFor(() => expect((screen.getByTestId("cmp-input") as HTMLInputElement).value).toBe("half written"));
58
+ });
59
+
60
+ it("attaches photos from the library as thumbnails (straight to the picker when it is the only source)", async () => {
61
+ const imagePicker = {
62
+ launchImageLibraryAsync: vi.fn(async () => ({ canceled: false, assets: [{ uri: "file:///a.jpg", fileName: "a.jpg", mimeType: "image/jpeg", width: 10, height: 10 }] })),
63
+ };
64
+ const onSend = vi.fn();
65
+ render(
66
+ <ChatNativeProvider modules={{ imagePicker }}>
67
+ <Harness onSend={onSend} />
68
+ </ChatNativeProvider>,
69
+ );
70
+ // The library is the only source: the picker opens with no sheet in between.
71
+ fireEvent.click(screen.getByTestId("cmp-attach"));
72
+ await waitFor(() => expect(screen.getByTestId("cmp-attachments")).toBeTruthy());
73
+ expect(imagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(expect.objectContaining({ mediaTypes: ["images", "videos"], allowsMultipleSelection: true }));
74
+ fireEvent.click(screen.getByTestId("cmp-send"));
75
+ expect(onSend.mock.calls[0]![0].files[0]).toMatchObject({ data: "file:///a.jpg", contentType: "image/jpeg" });
76
+ });
77
+
78
+ it("shows the mic (with its VoiceOver hint) only when expo-audio is given", () => {
79
+ const audio = {
80
+ useAudioRecorder: () => ({ prepareToRecordAsync: vi.fn(), record: vi.fn(), stop: vi.fn(), getStatus: vi.fn(), uri: null }),
81
+ useAudioPlayer: vi.fn(),
82
+ useAudioPlayerStatus: vi.fn(),
83
+ RecordingPresets: { HIGH_QUALITY: {} },
84
+ requestRecordingPermissionsAsync: vi.fn(),
85
+ setAudioModeAsync: vi.fn(),
86
+ };
87
+ const { unmount } = render(
88
+ <ChatNativeProvider>
89
+ <Harness />
90
+ </ChatNativeProvider>,
91
+ );
92
+ expect(screen.queryByTestId("cmp-mic")).toBeNull();
93
+ unmount();
94
+ render(
95
+ <ChatNativeProvider modules={{ audio } as unknown as ChatNativeModules}>
96
+ <Harness />
97
+ </ChatNativeProvider>,
98
+ );
99
+ expect(screen.getByTestId("cmp-mic").getAttribute("aria-label")).toBe("Record a voice message");
100
+ });
101
+ });
102
+
103
+ describe("useChatComposer across conversations", () => {
104
+ const wrapper = (drafts: ReturnType<typeof memoryDraftStorage>) =>
105
+ function Wrapper({ children }: { children: ReactNode }) {
106
+ return <ChatNativeProvider modules={{ drafts }}>{children}</ChatNativeProvider>;
107
+ };
108
+
109
+ it("starts the next conversation empty, keeps the first one's draft, and restores the next one's", async () => {
110
+ const drafts = memoryDraftStorage();
111
+ await drafts.setItem(draftKey("u1", "c2"), JSON.stringify({ text: "for c2" }));
112
+ const hook = renderHook(({ id }) => useChatComposer(id, null, "u1"), { initialProps: { id: "c1" }, wrapper: wrapper(drafts) });
113
+ await act(async () => undefined);
114
+ act(() => hook.result.current.dispatch({ type: "text", text: "for c1" }));
115
+ act(() => hook.result.current.setReply({ messageId: "m1", name: "Sam", preview: "hi" }));
116
+ hook.rerender({ id: "c2" });
117
+ // Never c1's text or reply in c2, not even for one render.
118
+ expect(hook.result.current.state.text).not.toBe("for c1");
119
+ expect(hook.result.current.state.replyTo).toBeNull();
120
+ await waitFor(() => expect(hook.result.current.state.text).toBe("for c2"));
121
+ expect(JSON.parse((await drafts.getItem(draftKey("u1", "c1")))!)).toMatchObject({ text: "for c1", replyTo: { messageId: "m1" } });
122
+ // c1's text was never saved under c2.
123
+ expect(JSON.parse((await drafts.getItem(draftKey("u1", "c2")))!)).toEqual({ text: "for c2" });
124
+ });
125
+
126
+ it("keeps each signed-in person's drafts apart", async () => {
127
+ const drafts = memoryDraftStorage();
128
+ await drafts.setItem(draftKey("u1", "c1"), JSON.stringify({ text: "u1's words" }));
129
+ const hook = renderHook(() => useChatComposer("c1", null, "u2"), { wrapper: wrapper(drafts) });
130
+ await act(async () => undefined);
131
+ expect(hook.result.current.state.text).toBe("");
132
+ });
133
+ });
@@ -0,0 +1,238 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ /// <reference types="vite/client" />
4
+ import pickerSource from "../emoji-picker.tsx?raw";
5
+ import dataSource from "../emoji-data.ts?raw";
6
+ import emojiSource from "../emoji.ts?raw";
7
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import type { ChatMessage } from "../../core/types";
10
+ import { ChatComposer } from "../composer";
11
+ import { clearDrafts, memoryDraftStorage } from "../composer-state";
12
+ import { ChatNativeProvider } from "../context";
13
+ import { ChatEmojiPicker } from "../emoji-picker";
14
+ import { EMOJI_CATEGORIES, emojiEntry, emojiInCategory, insertAtSelection, loadRecentEmoji, pushRecentEmoji, recentEmojiKey, RECENT_EMOJI_MAX, saveRecentEmoji, searchEmoji } from "../emoji";
15
+ import { QUICK_REACTIONS } from "../actions";
16
+ import { MessageActionsSheet } from "../message-actions-sheet";
17
+ import { useChatComposer } from "../use-composer";
18
+
19
+ // The grids' FlatList props, as the picker gave them.
20
+ const lists = vi.hoisted(() => [] as Record<string, unknown>[]);
21
+ vi.mock("react-native", async (original) => {
22
+ const rn = await original<Record<string, unknown> & { FlatList: (p: Record<string, unknown>) => unknown }>();
23
+ return {
24
+ ...rn,
25
+ useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
26
+ FlatList: (props: Record<string, unknown>) => {
27
+ lists.push(props);
28
+ return rn.FlatList(props);
29
+ },
30
+ };
31
+ });
32
+
33
+ afterEach(cleanup);
34
+
35
+ describe("the bundled emoji", () => {
36
+ it("ship in the app: nothing in the picker or its data fetches or names a URL", () => {
37
+ for (const src of [pickerSource, emojiSource, dataSource.split("\n").slice(9).join("\n")]) {
38
+ expect(src).not.toMatch(/https?:\/\//);
39
+ expect(src).not.toMatch(/\bfetch\(|emojibaseUrl|jsdelivr|frimousse/);
40
+ }
41
+ });
42
+
43
+ it("are every category's base emoji, kept modest", () => {
44
+ const all = EMOJI_CATEGORIES.flatMap((c) => emojiInCategory(c.id));
45
+ expect(all.length).toBeGreaterThan(1500);
46
+ expect(all.length).toBeLessThan(2200);
47
+ expect(dataSource.length).toBeLessThan(100_000);
48
+ expect(new Set(all.map((e) => e.emoji)).size).toBe(all.length);
49
+ expect(emojiInCategory("smileys")[0]).toMatchObject({ emoji: "😀", label: "grinning face" });
50
+ // No skin-tone variants: those are the base emoji's, chosen elsewhere.
51
+ expect(all.some((e) => /\u{1F3FB}|\u{1F3FF}/u.test(e.emoji))).toBe(false);
52
+ });
53
+
54
+ it("are found by words of their label or tags", () => {
55
+ expect(searchEmoji("thumbs up")[0]?.emoji).toBe("👍");
56
+ expect(searchEmoji("gri").map((e) => e.emoji)).toContain("😀");
57
+ expect(searchEmoji("red heart").map((e) => e.emoji)).toContain("❤️");
58
+ expect(searchEmoji(" ")).toEqual([]);
59
+ expect(searchEmoji("zzzqqq")).toEqual([]);
60
+ expect(emojiEntry("👍").label).toBe("thumbs up");
61
+ });
62
+
63
+ it("are written as keyboards type them, so a picked reaction counts with the same quick one", () => {
64
+ for (const quick of QUICK_REACTIONS) expect(emojiEntry(quick).label, quick).not.toBe(quick);
65
+ const all = EMOJI_CATEGORIES.flatMap((c) => emojiInCategory(c.id));
66
+ expect(all.some((e) => /\p{Emoji_Presentation}\uFE0F/u.test(e.emoji))).toBe(false);
67
+ expect(all.map((e) => e.emoji)).toContain("❤️");
68
+ });
69
+ });
70
+
71
+ describe("recent emoji", () => {
72
+ it("put the newest first, once, and keep at most the limit", () => {
73
+ expect(pushRecentEmoji(["😀", "👍"], "👍")).toEqual(["👍", "😀"]);
74
+ const many = Array.from({ length: RECENT_EMOJI_MAX }, (_, i) => String(i));
75
+ expect(pushRecentEmoji(many, "x")).toHaveLength(RECENT_EMOJI_MAX);
76
+ expect(pushRecentEmoji(many, "x")[0]).toBe("x");
77
+ });
78
+
79
+ it("are kept per person with their drafts, so signing out clears them", async () => {
80
+ const storage = memoryDraftStorage();
81
+ await saveRecentEmoji(storage, "u1", ["🎉"]);
82
+ await saveRecentEmoji(storage, "u2", ["🙂"]);
83
+ expect(await loadRecentEmoji(storage, "u1")).toEqual(["🎉"]);
84
+ await clearDrafts(storage, "u1");
85
+ expect(await storage.getItem(recentEmojiKey("u1"))).toBeNull();
86
+ expect(await loadRecentEmoji(storage, "u2")).toEqual(["🙂"]);
87
+ });
88
+
89
+ it("survive a corrupt entry as none", async () => {
90
+ const storage = memoryDraftStorage();
91
+ await storage.setItem(recentEmojiKey("u1"), "{not json");
92
+ expect(await loadRecentEmoji(storage, "u1")).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("insertAtSelection", () => {
97
+ it("types at the cursor, over a selection, or at the end", () => {
98
+ expect(insertAtSelection("hello world", { start: 5, end: 5 }, "🙂")).toEqual({ text: "hello🙂 world", cursor: 7 });
99
+ expect(insertAtSelection("hello world", { start: 6, end: 11 }, "🌍")).toEqual({ text: "hello 🌍", cursor: 8 });
100
+ expect(insertAtSelection("hi", null, "👋")).toEqual({ text: "hi👋", cursor: 4 });
101
+ expect(insertAtSelection("hi", { start: 40, end: 50 }, "!")).toEqual({ text: "hi!", cursor: 3 });
102
+ });
103
+ });
104
+
105
+ const ME = "u1";
106
+ function textMessage(): ChatMessage {
107
+ return {
108
+ id: "m1", conversationId: "c1", seq: 1, clientId: "c", senderId: "u2", senderRole: "member", kind: "text", body: "hi",
109
+ mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone",
110
+ silent: false, meta: {}, createdAt: "2026-10-25T10:00:00Z", editedAt: null, deletedAt: null, attachments: [], reactions: [],
111
+ } as ChatMessage;
112
+ }
113
+
114
+ describe("the long-press sheet's +", () => {
115
+ const context = { me: ME, role: "member" as const, canPost: true, pinned: false, canPin: false, canCopy: false };
116
+
117
+ it("opens every emoji, searchable, and reacts with the one picked", async () => {
118
+ const onReact = vi.fn();
119
+ const onClose = vi.fn();
120
+ const drafts = memoryDraftStorage();
121
+ render(
122
+ <ChatNativeProvider modules={{ drafts }}>
123
+ <MessageActionsSheet message={textMessage()} context={context} onClose={onClose} onReact={onReact} onAction={vi.fn()} testID="acts" />
124
+ </ChatNativeProvider>,
125
+ );
126
+ const more = await screen.findByTestId("acts-react-more");
127
+ expect(more.getAttribute("aria-label")).toBe("More reactions");
128
+ fireEvent.click(more);
129
+ expect(screen.getByTestId("acts-picker")).toBeTruthy();
130
+ // Categories are tabs, each named for screen readers.
131
+ expect(screen.getByTestId("acts-picker-tab-food").getAttribute("aria-label")).toBe("Food and drink");
132
+ fireEvent.change(screen.getByTestId("acts-picker-search").querySelector("input")!, { target: { value: "party popper" } });
133
+ const party = await screen.findByTestId("acts-picker-emoji-🎉");
134
+ expect(party.getAttribute("aria-label")).toBe("party popper");
135
+ fireEvent.click(party);
136
+ expect(onReact).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" }), "🎉", true);
137
+ expect(onClose).toHaveBeenCalled();
138
+ // Kept as a recent one for this person.
139
+ await waitFor(async () => expect(await loadRecentEmoji(drafts, ME)).toEqual(["🎉"]));
140
+ });
141
+
142
+ it("shows the person's recent emoji first, under Recent", async () => {
143
+ const drafts = memoryDraftStorage();
144
+ await saveRecentEmoji(drafts, "u-recent", ["🦄"]);
145
+ render(
146
+ <ChatNativeProvider modules={{ drafts }}>
147
+ <MessageActionsSheet message={textMessage()} context={{ ...context, me: "u-recent" }} onClose={vi.fn()} onReact={vi.fn()} onAction={vi.fn()} testID="acts" />
148
+ </ChatNativeProvider>,
149
+ );
150
+ fireEvent.click(await screen.findByTestId("acts-react-more"));
151
+ await screen.findByTestId("acts-picker-tab-recent");
152
+ expect(screen.getByTestId("acts-picker-tab-recent").getAttribute("aria-selected")).toBe("true");
153
+ expect(screen.getByTestId("acts-picker-emoji-🦄")).toBeTruthy();
154
+ });
155
+ });
156
+
157
+ function ComposerHarness() {
158
+ const composer = useChatComposer("c1", null, ME);
159
+ return <ChatComposer composer={composer} canPost disabledReason={null} role="member" onSend={vi.fn()} userId="u-composer" testID="cmp" />;
160
+ }
161
+
162
+ describe("the composer's emoji button", () => {
163
+ it("opens the picker, which types at the cursor", async () => {
164
+ render(
165
+ <ChatNativeProvider>
166
+ <ComposerHarness />
167
+ </ChatNativeProvider>,
168
+ );
169
+ const input = screen.getByTestId("cmp-input") as HTMLInputElement;
170
+ fireEvent.change(input, { target: { value: "see you" } });
171
+ const button = screen.getByTestId("cmp-emoji");
172
+ expect(button.getAttribute("aria-label")).toBe("Add emoji");
173
+ await act(async () => fireEvent.click(button));
174
+ fireEvent.click(await screen.findByTestId("cmp-emoji-sheet-picker-emoji-😀"));
175
+ await waitFor(() => expect(input.value).toBe("see you😀"));
176
+ fireEvent.click(screen.getByTestId("cmp-emoji-sheet-done"));
177
+ await waitFor(() => expect(screen.queryByTestId("cmp-emoji-sheet-picker")).toBeNull());
178
+ });
179
+ });
180
+
181
+ describe("the picker's grid", () => {
182
+ it("lays out rows by row index (FlatList with numColumns asks per row), so every row scrolls into place", () => {
183
+ lists.length = 0;
184
+ render(
185
+ <ChatNativeProvider>
186
+ <ChatEmojiPicker onSelect={vi.fn()} testID="pk" />
187
+ </ChatNativeProvider>,
188
+ );
189
+ const grid = lists.find((p) => p.testID === "pk-grid")!;
190
+ const columns = grid.numColumns as number;
191
+ expect(columns).toBe(8);
192
+ const layout = grid.getItemLayout as (d: unknown, i: number) => { length: number; offset: number };
193
+ expect(layout(null, 5)).toEqual({ length: 44, offset: 5 * 44, index: 5 });
194
+ const rows = Math.ceil((grid.data as unknown[]).length / columns);
195
+ expect(layout(null, rows - 1).offset).toBe((rows - 1) * 44);
196
+ });
197
+
198
+ it("keeps an emoji picked while the stored recents were still loading", async () => {
199
+ const drafts = memoryDraftStorage();
200
+ await saveRecentEmoji(drafts, "u-race", ["🍕", "🎸"]);
201
+ let release!: () => void;
202
+ const gate = new Promise<void>((r) => (release = r));
203
+ // Read when asked, answered late: as AsyncStorage does with a write queued behind the read.
204
+ const slow = {
205
+ ...drafts,
206
+ getItem: async (k: string) => {
207
+ const value = await drafts.getItem(k);
208
+ await gate;
209
+ return value;
210
+ },
211
+ };
212
+ render(
213
+ <ChatNativeProvider modules={{ drafts: slow }}>
214
+ <ChatEmojiPicker onSelect={vi.fn()} userId="u-race" testID="pk" />
215
+ </ChatNativeProvider>,
216
+ );
217
+ fireEvent.click(screen.getByTestId("pk-emoji-😀"));
218
+ await act(async () => release());
219
+ await waitFor(async () => expect(await loadRecentEmoji(drafts, "u-race")).toEqual(["😀", "🍕", "🎸"]));
220
+ });
221
+ });
222
+
223
+ describe("the composer's emoji button, picked twice quickly", () => {
224
+ it("types both", async () => {
225
+ render(
226
+ <ChatNativeProvider modules={{ drafts: memoryDraftStorage() }}>
227
+ <ComposerHarness />
228
+ </ChatNativeProvider>,
229
+ );
230
+ await act(async () => fireEvent.click(screen.getByTestId("cmp-emoji")));
231
+ const smile = await screen.findByTestId("cmp-emoji-sheet-picker-emoji-😀");
232
+ act(() => {
233
+ fireEvent.click(smile);
234
+ fireEvent.click(smile);
235
+ });
236
+ await waitFor(() => expect((screen.getByTestId("cmp-input") as HTMLInputElement).value).toBe("😀😀"));
237
+ });
238
+ });