@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,128 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { act, cleanup, render, waitFor } from "@testing-library/react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { ChatClient } from "../../core/client";
7
+ import type { OutboxStore, QueuedMessage } from "../../core/outbox";
8
+ import { ChatProvider } from "../../react/index";
9
+ import { ChatAutoRetry, isOnline, type AppStateLike } from "../auto-retry";
10
+ import { ChatNativeProvider } from "../context";
11
+ import type { NetInfoLike } from "../modules";
12
+
13
+ afterEach(cleanup);
14
+
15
+ function fakes() {
16
+ let appListener: ((s: string) => void) | null = null;
17
+ let netListener: ((s: { isConnected: boolean | null; isInternetReachable?: boolean | null }) => void) | null = null;
18
+ const appState: AppStateLike = {
19
+ currentState: "active",
20
+ addEventListener: (_t, l) => {
21
+ appListener = l;
22
+ return { remove: () => (appListener = null) };
23
+ },
24
+ };
25
+ const netInfo: NetInfoLike = {
26
+ addEventListener: (l) => {
27
+ netListener = l;
28
+ // NetInfo reports the current state as soon as you subscribe.
29
+ l({ isConnected: true, isInternetReachable: true });
30
+ return () => (netListener = null);
31
+ },
32
+ };
33
+ return {
34
+ appState,
35
+ netInfo,
36
+ app: (s: string) => appListener?.(s),
37
+ net: (online: boolean) => netListener?.({ isConnected: online, isInternetReachable: online }),
38
+ unsubscribed: () => appListener === null && netListener === null,
39
+ };
40
+ }
41
+
42
+ /** One message that failed to send, waiting in the saved outbox; the server keeps refusing it. */
43
+ function failing(extra: Partial<QueuedMessage> = {}) {
44
+ const queued: QueuedMessage = {
45
+ clientId: "q1",
46
+ input: { conversationId: "c1", clientId: "q1", body: "hello", replyToId: null, mentions: [] } as QueuedMessage["input"],
47
+ senderId: "u1",
48
+ createdAt: "2026-10-25T10:00:00Z",
49
+ status: "failed",
50
+ files: [],
51
+ attempts: 1,
52
+ ...extra,
53
+ };
54
+ let saved = [queued];
55
+ const store: OutboxStore = { load: async () => saved, save: async (q) => void (saved = q) };
56
+ const send = vi.fn(async () => {
57
+ throw new Error("offline");
58
+ });
59
+ const client = { send, subscribeInbox: () => ({ unsubscribe: () => undefined }) } as unknown as ChatClient;
60
+ return { store, client, send };
61
+ }
62
+
63
+ function mount(f: ReturnType<typeof fakes>, o: ReturnType<typeof failing>) {
64
+ return render(
65
+ <QueryClientProvider client={new QueryClient()}>
66
+ <ChatProvider client={o.client} userId="u1" outboxStore={o.store}>
67
+ <ChatNativeProvider modules={{ netInfo: f.netInfo }}>
68
+ <ChatAutoRetry appState={f.appState} />
69
+ </ChatNativeProvider>
70
+ </ChatProvider>
71
+ </QueryClientProvider>,
72
+ );
73
+ }
74
+
75
+ describe("ChatAutoRetry", () => {
76
+ it("retries unsent messages when the app comes back to the front", async () => {
77
+ const f = fakes();
78
+ const o = failing();
79
+ mount(f, o);
80
+ // The provider's own retry of what the last launch left.
81
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(1));
82
+ await act(async () => f.app("active"));
83
+ expect(o.send).toHaveBeenCalledTimes(1);
84
+ await act(async () => f.app("background"));
85
+ await act(async () => f.app("active"));
86
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(2));
87
+ });
88
+
89
+ it("retries when the connection comes back, not on NetInfo's first report", async () => {
90
+ const f = fakes();
91
+ const o = failing();
92
+ mount(f, o);
93
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(1));
94
+ await act(async () => f.net(true));
95
+ expect(o.send).toHaveBeenCalledTimes(1);
96
+ await act(async () => f.net(false));
97
+ await act(async () => f.net(true));
98
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(2));
99
+ });
100
+
101
+ it("leaves alone a message the server refused for good", async () => {
102
+ const f = fakes();
103
+ const o = failing({ permanent: true, error: "You can't post here" });
104
+ mount(f, o);
105
+ await act(async () => f.app("background"));
106
+ await act(async () => f.app("active"));
107
+ await act(async () => f.net(false));
108
+ await act(async () => f.net(true));
109
+ expect(o.send).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("stops listening when unmounted", async () => {
113
+ const f = fakes();
114
+ const o = failing();
115
+ const view = mount(f, o);
116
+ view.unmount();
117
+ expect(f.unsubscribed()).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe("isOnline", () => {
122
+ it("counts unknown as online, so only a real offline waits", () => {
123
+ expect(isOnline({ isConnected: true, isInternetReachable: true })).toBe(true);
124
+ expect(isOnline({ isConnected: null, isInternetReachable: null })).toBe(true);
125
+ expect(isOnline({ isConnected: false })).toBe(false);
126
+ expect(isOnline({ isConnected: true, isInternetReachable: false })).toBe(false);
127
+ });
128
+ });
@@ -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
+ });