@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,209 @@
1
+ // @feat:chat
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ CHAT_PUSH_CATEGORY,
5
+ chatNotificationActions,
6
+ chatPath,
7
+ chatPushCategory,
8
+ chatThreadMatch,
9
+ conversationIdFromData,
10
+ handleChatPushResponse,
11
+ registerChatNotificationActions,
12
+ replyClientId,
13
+ replyClientIdFor,
14
+ type ChatPushResponseLike,
15
+ } from "../push";
16
+ import { createOutbox, memoryOutboxStore } from "../../core/outbox";
17
+ import type { ChatClient } from "../../core/client";
18
+
19
+ const CONV = "6f1d2c3b-4a59-4e8f-9a7b-1c2d3e4f5a6b";
20
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
21
+
22
+ function response(action: string, extra: Partial<ChatPushResponseLike> = {}, data: Record<string, unknown> = { url: chatPath(CONV), seq: 7 }): ChatPushResponseLike {
23
+ return { actionIdentifier: action, notification: { request: { identifier: "push-123", content: { data } } }, ...extra };
24
+ }
25
+
26
+ function fakeClient() {
27
+ return { send: vi.fn(async () => ({}) as never), markRead: vi.fn(async () => 7) };
28
+ }
29
+
30
+ describe("the messages category", () => {
31
+ it("is one preferences category and two actions, neither opening the app", async () => {
32
+ expect(chatPushCategory()).toMatchObject({ id: "messages", title: "Messages" });
33
+ const actions = chatNotificationActions();
34
+ expect(actions.map((a) => a.identifier)).toEqual(["reply", "mark-read"]);
35
+ expect(actions[0]!.textInput).toEqual({ submitButtonTitle: "Send", placeholder: "Message" });
36
+ expect(actions.every((a) => a.options.opensAppToForeground === false)).toBe(true);
37
+ const setNotificationCategoryAsync = vi.fn(async () => undefined);
38
+ await registerChatNotificationActions({ setNotificationCategoryAsync });
39
+ expect(setNotificationCategoryAsync).toHaveBeenCalledWith(CHAT_PUSH_CATEGORY, actions);
40
+ });
41
+
42
+ it("registering never throws: a module without categories is reported, not fatal", async () => {
43
+ const onError = vi.fn();
44
+ await expect(registerChatNotificationActions({} as never, undefined, onError)).resolves.toBeUndefined();
45
+ expect(onError).toHaveBeenCalled();
46
+ const failing = { setNotificationCategoryAsync: () => Promise.reject(new Error("no")) };
47
+ await expect(registerChatNotificationActions(failing, undefined, onError)).resolves.toBeUndefined();
48
+ });
49
+
50
+ it("finds the conversation in data.conversationId or data.url, nowhere else", () => {
51
+ expect(conversationIdFromData({ url: `/messages/${CONV}` })).toBe(CONV);
52
+ expect(conversationIdFromData({ url: `/messages/${CONV}?x=1` })).toBe(CONV);
53
+ expect(conversationIdFromData({ conversationId: CONV.toUpperCase() })).toBe(CONV);
54
+ expect(conversationIdFromData({ url: "https://evil.example/messages/" + CONV })).toBeNull();
55
+ expect(conversationIdFromData({ url: "/messages/not-an-id" })).toBeNull();
56
+ expect(conversationIdFromData(null)).toBeNull();
57
+ });
58
+ });
59
+
60
+ describe("handleChatPushResponse", () => {
61
+ it("Reply sends the typed text with a client id from the notification, and marks read", async () => {
62
+ const client = fakeClient();
63
+ const dismissNotificationAsync = vi.fn(async () => undefined);
64
+ const outcome = await handleChatPushResponse(response("reply", { userText: " on my way " }), { client, notifications: { dismissNotificationAsync } });
65
+ expect(outcome).toBe("replied");
66
+ expect(client.send).toHaveBeenCalledWith({ conversationId: CONV, clientId: replyClientIdFor("push-123", { url: chatPath(CONV), seq: 7 }, "on my way"), body: "on my way" });
67
+ expect(client.markRead).toHaveBeenCalledWith(CONV);
68
+ expect(dismissNotificationAsync).toHaveBeenCalledWith("push-123");
69
+ });
70
+
71
+ it("replying twice from the same notification uses the same client id (the server posts it once)", async () => {
72
+ const client = fakeClient();
73
+ await handleChatPushResponse(response("reply", { userText: "yes" }), { client });
74
+ await handleChatPushResponse(response("reply", { userText: "yes" }), { client });
75
+ const ids = client.send.mock.calls.map((c) => (c as unknown as [{ clientId: string }])[0].clientId);
76
+ expect(ids[0]).toBe(ids[1]);
77
+ expect(ids[0]).toMatch(UUID);
78
+ });
79
+
80
+ it("Mark read marks up to the pushed message", async () => {
81
+ const client = fakeClient();
82
+ expect(await handleChatPushResponse(response("mark-read"), { client })).toBe("marked-read");
83
+ expect(client.markRead).toHaveBeenCalledWith(CONV, 7);
84
+ expect(client.send).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("reads the data createChatRunner sends: conversationId and seq, and a seq that is null", async () => {
88
+ const client = fakeClient();
89
+ const data = { url: chatPath(CONV), conversationId: CONV, messageId: "m1", seq: 12 };
90
+ expect(await handleChatPushResponse(response("mark-read", {}, data), { client })).toBe("marked-read");
91
+ expect(client.markRead).toHaveBeenLastCalledWith(CONV, 12);
92
+ await handleChatPushResponse(response("mark-read", {}, { conversationId: CONV, seq: null }), { client });
93
+ expect(client.markRead).toHaveBeenLastCalledWith(CONV, undefined);
94
+ });
95
+
96
+ it("leaves taps, empty replies and other pushes alone", async () => {
97
+ const client = fakeClient();
98
+ expect(await handleChatPushResponse(response("expo.modules.notifications.actions.DEFAULT"), { client })).toBe("ignored");
99
+ expect(await handleChatPushResponse(response("reply", { userText: " " }), { client })).toBe("ignored");
100
+ expect(await handleChatPushResponse(response("reply", { userText: "hi" }, { url: "/settings" }), { client })).toBe("ignored");
101
+ expect(client.send).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("two replies under one notification id (iOS collapses pushes) get two client ids", async () => {
105
+ const client = fakeClient();
106
+ await handleChatPushResponse(response("reply", { userText: "yes" }, { conversationId: CONV, messageId: "m1" }), { client });
107
+ await handleChatPushResponse(response("reply", { userText: "yes" }, { conversationId: CONV, messageId: "m2" }), { client });
108
+ // No messageId: the text tells them apart.
109
+ await handleChatPushResponse(response("reply", { userText: "one" }, { conversationId: CONV }), { client });
110
+ await handleChatPushResponse(response("reply", { userText: "two" }, { conversationId: CONV }), { client });
111
+ const ids = client.send.mock.calls.map((c) => (c as unknown as [{ clientId: string }])[0].clientId);
112
+ expect(new Set(ids).size).toBe(4);
113
+ for (const id of ids) expect(id).toMatch(UUID);
114
+ });
115
+
116
+ it("with the live outbox, the reply is queued there, once, and an offline one waits failed instead of being lost", async () => {
117
+ const client = fakeClient();
118
+ client.send.mockRejectedValue(new Error("Network request failed"));
119
+ const store = memoryOutboxStore();
120
+ const outbox = createOutbox({ client: client as unknown as ChatClient, userId: "u1", store });
121
+ const r = response("reply", { userText: "on my way" }, { conversationId: CONV, messageId: "m1" });
122
+ expect(await handleChatPushResponse(r, { client, outbox })).toBe("queued");
123
+ expect(await handleChatPushResponse(r, { client, outbox: () => outbox })).toBe("queued");
124
+ expect(client.markRead).not.toHaveBeenCalled();
125
+ expect(outbox.pending(CONV)[0]?.status).toBe("failed");
126
+ expect(outbox.pending(CONV)).toHaveLength(1);
127
+ expect(outbox.pending(CONV)[0]).toMatchObject({ input: { body: "on my way", clientId: replyClientIdFor("push-123", r.notification.request.content.data, "on my way") } });
128
+ expect(await store.load()).toHaveLength(1);
129
+ });
130
+
131
+ it("with the live outbox online, waits for the reply to go, then marks read", async () => {
132
+ const client = fakeClient();
133
+ const outbox = createOutbox({ client: client as unknown as ChatClient, userId: "u1", store: memoryOutboxStore() });
134
+ const r = response("reply", { userText: "on my way" }, { conversationId: CONV, messageId: "m1" });
135
+ expect(await handleChatPushResponse(r, { client, outbox })).toBe("replied");
136
+ expect(client.send).toHaveBeenCalledTimes(1);
137
+ expect(client.markRead).toHaveBeenCalledWith(CONV);
138
+ expect(outbox.pending(CONV)).toHaveLength(0);
139
+ });
140
+
141
+ it("when the chat screens open while a direct send fails, the reply goes to their outbox, not behind its back", async () => {
142
+ const client = fakeClient();
143
+ const store = memoryOutboxStore();
144
+ const outbox = createOutbox({ client: client as unknown as ChatClient, userId: "u1", store });
145
+ let live: typeof outbox | null = null;
146
+ client.send.mockImplementationOnce(async () => {
147
+ live = outbox; // Messages mounted meanwhile.
148
+ throw new Error("Network request failed");
149
+ });
150
+ client.send.mockRejectedValue(new Error("Network request failed"));
151
+ const r = response("reply", { userText: "on my way" }, { conversationId: CONV });
152
+ expect(await handleChatPushResponse(r, { client, outbox: () => live, store, userId: "u1" })).toBe("queued");
153
+ expect(outbox.pending(CONV)).toHaveLength(1);
154
+ expect(await store.load()).toHaveLength(1);
155
+ });
156
+
157
+ it("without the live outbox, a reply that can't be sent is added to the saved outbox, once, for Retry", async () => {
158
+ const client = fakeClient();
159
+ client.send.mockRejectedValue(new Error("Network request failed"));
160
+ const store = memoryOutboxStore();
161
+ await store.save([]);
162
+ const r = response("reply", { userText: "on my way" }, { conversationId: CONV, messageId: "m1" });
163
+ const onError = vi.fn();
164
+ expect(await handleChatPushResponse(r, { client, store, userId: "u1", onError })).toBe("queued");
165
+ expect(await handleChatPushResponse(r, { client, store, userId: "u1" })).toBe("queued");
166
+ const saved = await store.load();
167
+ expect(saved).toHaveLength(1);
168
+ expect(saved[0]).toMatchObject({ status: "failed", senderId: "u1", input: { conversationId: CONV, body: "on my way" } });
169
+ expect(client.markRead).not.toHaveBeenCalled();
170
+ expect(onError).toHaveBeenCalled();
171
+ // The outbox the chat screens open later shows it, and Retry sends it with the same client id.
172
+ client.send.mockResolvedValue({} as never);
173
+ const outbox = createOutbox({ client: client as unknown as ChatClient, userId: "u1", store });
174
+ await outbox.load();
175
+ expect(outbox.pending(CONV)).toHaveLength(1);
176
+ await outbox.retry(saved[0]!.clientId);
177
+ await vi.waitFor(() => expect(outbox.pending(CONV)).toHaveLength(0));
178
+ expect(client.send).toHaveBeenLastCalledWith(expect.objectContaining({ clientId: saved[0]!.clientId, body: "on my way" }));
179
+ });
180
+
181
+ it("never throws: a failed send is the outcome, reported", async () => {
182
+ const client = fakeClient();
183
+ client.send.mockRejectedValueOnce(new Error("offline"));
184
+ const onError = vi.fn();
185
+ expect(await handleChatPushResponse(response("reply", { userText: "hi" }), { client, onError })).toBe("failed");
186
+ expect(onError).toHaveBeenCalled();
187
+ });
188
+ });
189
+
190
+ describe("replyClientId", () => {
191
+ it("keeps a uuid, and hashes anything else into a stable valid one", () => {
192
+ expect(replyClientId(CONV.toUpperCase())).toBe(CONV);
193
+ const a = replyClientId("0:1729845612:com.example/notif-42");
194
+ expect(a).toMatch(UUID);
195
+ expect(a[14]).toBe("8");
196
+ expect(["8", "9", "a", "b"]).toContain(a[19]);
197
+ expect(replyClientId("0:1729845612:com.example/notif-42")).toBe(a);
198
+ expect(replyClientId("0:1729845612:com.example/notif-43")).not.toBe(a);
199
+ });
200
+ });
201
+
202
+ describe("chatThreadMatch", () => {
203
+ it("matches only the open conversation's pushes", () => {
204
+ const match = chatThreadMatch(CONV)!;
205
+ expect(match({ url: chatPath(CONV) })).toBe(true);
206
+ expect(match({ url: chatPath("7f1d2c3b-4a59-4e8f-9a7b-1c2d3e4f5a6b") })).toBe(false);
207
+ expect(chatThreadMatch(null)).toBeNull();
208
+ });
209
+ });
@@ -0,0 +1,96 @@
1
+ // @feat:chat
2
+ import { describe, expect, it } from "vitest";
3
+ import { idleRecorder, liveBars, recorderStep, type RecorderEvent, type RecorderState } from "../recorder-machine";
4
+
5
+ const config = { limitMs: 10_000 };
6
+
7
+ function run(events: RecorderEvent[], from: RecorderState = idleRecorder) {
8
+ let state = from;
9
+ const effects: string[] = [];
10
+ for (const e of events) {
11
+ const step = recorderStep(state, e, config);
12
+ state = step.state;
13
+ effects.push(...step.effects.map((x) => (x.type === "haptic" ? `haptic:${x.event}` : x.type)));
14
+ }
15
+ return { state, effects };
16
+ }
17
+
18
+ describe("recorderStep", () => {
19
+ it("hold, record, release: sends", () => {
20
+ const { state, effects } = run([
21
+ { type: "press" },
22
+ { type: "ready" },
23
+ { type: "tick", durationMs: 500, meteringDb: -20 },
24
+ { type: "tick", durationMs: 1500, meteringDb: -10 },
25
+ { type: "release" },
26
+ ]);
27
+ expect(effects).toEqual(["prepare", "record", "haptic:record-start", "signal", "signal", "signal", "finish", "haptic:send"]);
28
+ expect(state.phase).toBe("finishing");
29
+ expect(state.levels).toEqual([-20, -10]);
30
+ expect(run([{ type: "finished" }], state).state).toEqual(idleRecorder);
31
+ });
32
+
33
+ it("a tap is too short: dropped, with a hint", () => {
34
+ const { state, effects } = run([{ type: "press" }, { type: "ready" }, { type: "tick", durationMs: 200 }, { type: "release" }]);
35
+ expect(effects.at(-1)).toBe("discard");
36
+ expect(state).toMatchObject({ phase: "idle", notice: "too-short" });
37
+ });
38
+
39
+ it("let go before the microphone was ready: nothing records", () => {
40
+ const { state, effects } = run([{ type: "press" }, { type: "release" }, { type: "ready" }]);
41
+ expect(effects).toEqual(["prepare", "discard"]);
42
+ expect(state).toMatchObject({ phase: "idle", notice: "too-short" });
43
+ });
44
+
45
+ it("slide left: arms at half way, cancels past the line", () => {
46
+ let r = run([{ type: "press" }, { type: "ready" }, { type: "tick", durationMs: 2000 }, { type: "move", dx: -70, dy: 0 }]);
47
+ expect(r.state.cancelArmed).toBe(true);
48
+ r = run([{ type: "release" }], r.state);
49
+ expect(r.effects).toEqual(["discard", "haptic:record-cancel"]);
50
+ expect(r.state.phase).toBe("idle");
51
+ const past = run([{ type: "press" }, { type: "ready" }, { type: "move", dx: -130, dy: 0 }]);
52
+ expect(past.state.phase).toBe("idle");
53
+ expect(past.effects.slice(-2)).toEqual(["discard", "haptic:record-cancel"]);
54
+ });
55
+
56
+ it("slide up: locks; letting go keeps recording; send or delete ends it", () => {
57
+ let r = run([{ type: "press" }, { type: "ready" }, { type: "move", dx: 0, dy: -90 }]);
58
+ expect(r.state.locked).toBe(true);
59
+ expect(r.effects.at(-1)).toBe("haptic:record-lock");
60
+ r = run([{ type: "release" }, { type: "move", dx: -500, dy: 0 }, { type: "tick", durationMs: 3000 }], r.state);
61
+ expect(r.state.phase).toBe("recording");
62
+ const sent = run([{ type: "send" }], r.state);
63
+ expect(sent.state.phase).toBe("finishing");
64
+ expect(sent.effects).toEqual(["finish", "haptic:send"]);
65
+ const deleted = run([{ type: "cancel" }], r.state);
66
+ expect(deleted.state.phase).toBe("idle");
67
+ expect(deleted.effects).toEqual(["discard", "haptic:record-cancel"]);
68
+ });
69
+
70
+ it("reaching the limit sends", () => {
71
+ const { state, effects } = run([{ type: "press" }, { type: "ready" }, { type: "tick", durationMs: 10_000 }]);
72
+ expect(state.phase).toBe("finishing");
73
+ expect(effects.slice(-2)).toEqual(["finish", "haptic:send"]);
74
+ });
75
+
76
+ it("no microphone permission: back to idle, saying so", () => {
77
+ const { state, effects } = run([{ type: "press" }, { type: "failed", reason: "denied" }]);
78
+ expect(state).toMatchObject({ phase: "idle", notice: "denied" });
79
+ expect(effects).toEqual(["prepare", "discard", "haptic:error"]);
80
+ });
81
+
82
+ it("ignores events that don't apply", () => {
83
+ expect(recorderStep(idleRecorder, { type: "release" }, config).state).toBe(idleRecorder);
84
+ expect(recorderStep(idleRecorder, { type: "tick", durationMs: 9 }, config).effects).toEqual([]);
85
+ const finishing = { ...idleRecorder, phase: "finishing" as const };
86
+ expect(recorderStep(finishing, { type: "cancel" }, config).state).toBe(finishing);
87
+ expect(recorderStep(finishing, { type: "press" }, config).state).toBe(finishing);
88
+ });
89
+ });
90
+
91
+ describe("liveBars", () => {
92
+ it("pads on the left and maps dB to 0..1", () => {
93
+ expect(liveBars([-60, 0, -30], 5)).toEqual([0, 0, 0, 1, 0.5]);
94
+ expect(liveBars([-Infinity], 1)).toEqual([0]);
95
+ });
96
+ });
@@ -0,0 +1,76 @@
1
+ // @feat:chat
2
+ import { describe, expect, it } from "vitest";
3
+ import type { ThreadItem } from "../../core/thread";
4
+ import type { ChatMessage } from "../../core/types";
5
+ import { messageActionsFor, canReactTo } from "../actions";
6
+ import { buildThreadRows, invertedRows } from "../rows";
7
+
8
+ const ME = "me";
9
+ let n = 0;
10
+ function msg(sender: string, at: string, extra: Partial<ChatMessage> = {}): ChatMessage {
11
+ n++;
12
+ return {
13
+ id: `m${n}`, conversationId: "c", seq: n, clientId: `cl${n}`, senderId: sender, senderRole: "member", kind: "text", body: `b${n}`,
14
+ mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone",
15
+ silent: false, meta: {}, createdAt: at, editedAt: null, deletedAt: null, attachments: [], reactions: [], ...extra,
16
+ };
17
+ }
18
+ const item = (m: ChatMessage): ThreadItem => ({ type: "message", message: m });
19
+
20
+ describe("thread rows", () => {
21
+ const a = msg("sam", "2026-10-24T09:00:00");
22
+ const b = msg("sam", "2026-10-24T09:01:00");
23
+ const c = msg(ME, "2026-10-24T09:02:00");
24
+ const d = msg("lee", "2026-10-25T08:00:00");
25
+ const items = [a, b, c, d].map(item);
26
+
27
+ it("puts a day separator before each day and the New line before the first unread", () => {
28
+ const rows = buildThreadRows(items, { me: ME, firstUnreadId: d.id, group: true, now: new Date("2026-10-25T12:00:00") });
29
+ expect(rows.map((r) => r.type)).toEqual(["day", "message", "message", "message", "day", "unread", "message"]);
30
+ expect(rows[0]).toMatchObject({ label: "Yesterday" });
31
+ expect(rows[4]).toMatchObject({ label: "Today" });
32
+ });
33
+
34
+ it("in a group: the name over the first of a run, the avatar beside the last, never on mine", () => {
35
+ const rows = buildThreadRows(items, { me: ME, group: true }).filter((r) => r.type === "message");
36
+ expect(rows.map((r) => [r.showName, r.showAvatar])).toEqual([
37
+ [true, false],
38
+ [false, true],
39
+ [false, false],
40
+ [true, true],
41
+ ]);
42
+ const direct = buildThreadRows(items, { me: ME, group: false }).filter((r) => r.type === "message");
43
+ expect(direct.every((r) => !r.showName && !r.showAvatar)).toBe(true);
44
+ });
45
+
46
+ it("is newest first for the inverted list, keyed by client id (a send that lands keeps its row)", () => {
47
+ const rows = invertedRows(items, { me: ME, group: false });
48
+ expect(rows[0]).toMatchObject({ type: "message", key: `c:${d.clientId}` });
49
+ const pending: ThreadItem = { type: "pending", pending: { clientId: d.clientId, input: { conversationId: "c", clientId: d.clientId }, senderId: ME, createdAt: d.createdAt, status: "sending" } };
50
+ expect(invertedRows([pending], { me: ME, group: false }).find((r) => r.type === "message")!.key).toBe(`c:${d.clientId}`);
51
+ });
52
+ });
53
+
54
+ describe("messageActionsFor", () => {
55
+ const base = { me: ME, role: "member" as const, canPost: true, pinned: false, canPin: false, canCopy: true };
56
+
57
+ it("a member's own message: reply, copy, delete; no pin where the kind doesn't allow it", () => {
58
+ expect(messageActionsFor(msg(ME, "2026-10-24T09:00:00"), base)).toEqual(["reply", "copy", "delete"]);
59
+ });
60
+
61
+ it("someone else's: no delete for a member; an admin may pin and delete", () => {
62
+ const theirs = msg("sam", "2026-10-24T09:00:00");
63
+ expect(messageActionsFor(theirs, base)).toEqual(["reply", "copy"]);
64
+ expect(messageActionsFor(theirs, { ...base, role: "admin", canPin: true, pinned: true })).toEqual(["reply", "copy", "unpin", "delete"]);
65
+ expect(messageActionsFor(theirs, { ...base, canPin: true })).toEqual(["reply", "copy", "pin"]);
66
+ });
67
+
68
+ it("a read-only viewer can only copy; deleted and system messages offer nothing", () => {
69
+ const theirs = msg("sam", "2026-10-24T09:00:00");
70
+ expect(messageActionsFor(theirs, { ...base, role: "observer", canPost: false })).toEqual(["copy"]);
71
+ expect(canReactTo(theirs, { canPost: false })).toBe(false);
72
+ expect(messageActionsFor(msg(ME, "2026-10-24T09:00:00", { deletedAt: "x" }), base)).toEqual([]);
73
+ expect(messageActionsFor(msg("sam", "2026-10-24T09:00:00", { kind: "system" }), { ...base, role: "owner" })).toEqual([]);
74
+ expect(messageActionsFor(msg(ME, "2026-10-24T09:00:00", { body: "" }), { ...base, canCopy: true })).toEqual(["reply", "delete"]);
75
+ });
76
+ });
@@ -0,0 +1,41 @@
1
+ // @feat:chat
2
+ import { describe, expect, it } from "vitest";
3
+ import { chatThreadStackOptions } from "../stack-options";
4
+ import { barsThatFit, waveformBars } from "../voice-player";
5
+
6
+ describe("chatThreadStackOptions", () => {
7
+ it("keeps the bar opaque, so the inverted list never sits under iOS's see-through bar", () => {
8
+ const opts = chatThreadStackOptions({ headerTitle: "Lin" }, "#f2f2f7");
9
+ expect(opts).toMatchObject({ title: "Lin", headerTransparent: false, headerStyle: { backgroundColor: "#f2f2f7" } });
10
+ expect(opts.headerLargeTitle).toBeUndefined();
11
+ });
12
+
13
+ it("never turns on a large title, which would collapse into the flipped list", () => {
14
+ expect(chatThreadStackOptions({ headerTitle: "Lin", large: true }, "#fff").headerLargeTitle).toBeUndefined();
15
+ });
16
+
17
+ it("lets the app's own header options win", () => {
18
+ const opts = chatThreadStackOptions({ headerTitle: "Lin", headerOptions: { headerStyle: { backgroundColor: "red" } } }, "#fff");
19
+ expect(opts.headerStyle).toEqual({ backgroundColor: "red" });
20
+ });
21
+ });
22
+
23
+ describe("barsThatFit", () => {
24
+ it("fits one 3-point bar and a 2-point gap each across the width", () => {
25
+ expect(barsThatFit(98)).toBe(20);
26
+ expect(barsThatFit(3)).toBe(1);
27
+ });
28
+
29
+ it("draws one bar before the width is known", () => {
30
+ expect(barsThatFit(0)).toBe(1);
31
+ expect(barsThatFit(Number.NaN)).toBe(1);
32
+ });
33
+
34
+ it("never asks for more bars than the slot holds", () => {
35
+ for (const width of [40, 78, 98, 140]) {
36
+ const n = barsThatFit(width);
37
+ expect(n * 3 + (n - 1) * 2).toBeLessThanOrEqual(width);
38
+ expect(waveformBars([10, 50, 90], n)).toHaveLength(n);
39
+ }
40
+ });
41
+ });
@@ -0,0 +1,116 @@
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 type { ReactNode } from "react";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { GenesisModulesProvider, NativeToastProvider } from "@marktiderman/genesis-ui-native/components";
8
+ import type { ChatClient } from "../../core/client";
9
+ import type { ChatMessage } from "../../core/types";
10
+ import { ChatProvider } from "../../react/index";
11
+ import { ChatNativeProvider } from "../context";
12
+ import type { ThreadRow } from "../rows";
13
+ import { nearBottom, ChatThreadScreen, threadListMode } from "../thread-screen";
14
+
15
+ vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
16
+
17
+ afterEach(cleanup);
18
+
19
+ const ME = "11111111-1111-4111-8111-111111111111";
20
+ const SAM = "22222222-2222-4222-8222-222222222222";
21
+ const CONV = "33333333-3333-4333-8333-333333333333";
22
+
23
+ function msg(seq: number, senderId: string, createdAt: string): ChatMessage {
24
+ return {
25
+ id: `m${seq}`, conversationId: CONV, seq, clientId: `c${seq}`, senderId, senderRole: "member", kind: "text", body: `body ${seq}`,
26
+ mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone",
27
+ silent: false, meta: {}, createdAt, editedAt: null, deletedAt: null, attachments: [], reactions: [],
28
+ } as ChatMessage;
29
+ }
30
+
31
+ function fakeClient(messages: ChatMessage[], lastReadSeq: number): ChatClient {
32
+ const sub = { unsubscribe: vi.fn(), typing: vi.fn(), recording: vi.fn(), send: vi.fn() };
33
+ return {
34
+ conversation: vi.fn(async () => ({ id: CONV, kind: "direct", title: null, settings: {}, status: "open", archivedAt: null })),
35
+ members: vi.fn(async () => [
36
+ { conversationId: CONV, userId: ME, role: "member", lastReadSeq, joinedAt: "2026-10-01T00:00:00Z" },
37
+ { conversationId: CONV, userId: SAM, role: "member", lastReadSeq: 9, joinedAt: "2026-10-01T00:00:00Z" },
38
+ ]),
39
+ kind: vi.fn(async () => ({ kind: "direct", label: "Direct", maxMembers: 2 })),
40
+ myRole: vi.fn(async () => "member"),
41
+ canPost: vi.fn(async () => true),
42
+ messages: vi.fn(async () => ({ messages, hasEarlier: true })),
43
+ message: vi.fn(async () => null),
44
+ subscribeConversation: vi.fn(() => sub),
45
+ subscribeInbox: vi.fn(() => ({ unsubscribe: vi.fn() })),
46
+ markRead: vi.fn(async () => 0),
47
+ pins: vi.fn(async () => []),
48
+ fileUrls: vi.fn(async () => ({})),
49
+ } as unknown as ChatClient;
50
+ }
51
+
52
+ /** A list that records what it was given (the app's FlashList, as GenesisNativeApp passes it). */
53
+ function recordingList() {
54
+ const seen: { props: Record<string, unknown> }[] = [];
55
+ function List(props: Record<string, unknown>) {
56
+ seen.push({ props });
57
+ return null;
58
+ }
59
+ return { List, last: () => seen.at(-1)!.props };
60
+ }
61
+
62
+ function wrap(client: ChatClient, list: unknown, children: ReactNode) {
63
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
64
+ return (
65
+ <QueryClientProvider client={qc}>
66
+ <GenesisModulesProvider modules={{ list: list as never }}>
67
+ <ChatProvider client={client} userId={ME}>
68
+ <NativeToastProvider>
69
+ <ChatNativeProvider>{children}</ChatNativeProvider>
70
+ </NativeToastProvider>
71
+ </ChatProvider>
72
+ </GenesisModulesProvider>
73
+ </QueryClientProvider>
74
+ );
75
+ }
76
+
77
+ describe("ChatThreadScreen's list", () => {
78
+ it("is oldest first and upright, so VoiceOver reads each day's heading and the New messages line before their messages", async () => {
79
+ const messages = [msg(1, SAM, "2026-10-23T09:00:00Z"), msg(2, ME, "2026-10-23T09:01:00Z"), msg(3, SAM, "2026-10-24T10:00:00Z"), msg(4, SAM, "2026-10-24T10:01:00Z")];
80
+ const list = recordingList();
81
+ render(wrap(fakeClient(messages, 3), list.List, <ChatThreadScreen conversationId={CONV} />));
82
+ await waitFor(() => expect((list.last().data as ThreadRow[] | undefined)?.some((r) => r.type === "unread")).toBe(true));
83
+ const rows = list.last().data as ThreadRow[];
84
+ const shape = rows.map((r) => (r.type === "message" ? (r.item.type === "message" ? r.item.message.id : "pending") : r.type));
85
+ expect(shape).toEqual(["day", "m1", "m2", "day", "m3", "unread", "m4"]);
86
+ expect(list.last().inverted).toBeFalsy();
87
+ });
88
+
89
+ it("uses FlashList's bottom-anchored chat mode, never the autoscrollToTopThreshold FlashList 2 ignores, and loads earlier at the top", async () => {
90
+ const list = recordingList();
91
+ render(wrap(fakeClient([msg(1, SAM, "2026-10-24T10:00:00Z")], 0), list.List, <ChatThreadScreen conversationId={CONV} />));
92
+ await waitFor(() => expect((list.last().data as ThreadRow[] | undefined)?.length).toBeGreaterThan(0));
93
+ const mvcp = list.last().maintainVisibleContentPosition as Record<string, unknown>;
94
+ expect(mvcp).toMatchObject({ startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.2 });
95
+ expect(mvcp).not.toHaveProperty("autoscrollToTopThreshold");
96
+ expect(typeof list.last().onStartReached).toBe("function");
97
+ expect(list.last().onEndReached).toBeUndefined();
98
+ // "Load earlier" sits at the top, over the oldest message.
99
+ expect(list.last().ListHeaderComponent).toBeTruthy();
100
+ });
101
+ });
102
+
103
+ describe("threadListMode", () => {
104
+ it("follows new rows by hand only on FlatList, which keeps its place for earlier rows", () => {
105
+ expect(threadListMode(true, false)).toMatchObject({ followsByHand: false });
106
+ expect(threadListMode(true, true).maintainVisibleContentPosition).toMatchObject({ animateAutoScrollToBottom: false });
107
+ expect(threadListMode(false, false)).toEqual({ followsByHand: true, maintainVisibleContentPosition: { minIndexForVisible: 0 } });
108
+ });
109
+
110
+ it("counts a position within the slack of the end as the bottom", () => {
111
+ const at = (y: number) => ({ contentOffset: { y }, contentSize: { height: 2000 }, layoutMeasurement: { height: 800 } });
112
+ expect(nearBottom(at(1200))).toBe(true);
113
+ expect(nearBottom(at(1100))).toBe(true);
114
+ expect(nearBottom(at(900))).toBe(false);
115
+ });
116
+ });