@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,453 @@
1
+ /**
2
+ * @feat:chat
3
+ * The composer: a field that grows with its text, photos and videos from the
4
+ * library or camera and a pasted picture (attached as thumbnails), what the
5
+ * message replies to, Send with a haptic, and, with expo-audio, the mic: hold
6
+ * to record, slide left to cancel, slide up to lock and record hands-free.
7
+ * The emoji button opens the full picker, which types where the cursor is.
8
+ * With `onRecordVideo`, a camera button opens the video recorder. When the
9
+ * person can't post, it says why instead of taking input.
10
+ */
11
+ import { useEffect, useMemo, useRef, useState } from "react";
12
+ import { PanResponder, Pressable, ScrollView, TextInput, View } from "react-native";
13
+ import { NativeIcon, NativeImage, NativeListItem, NativeSheet, NativeText } from "@marktiderman/genesis-ui-native/components";
14
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
15
+ import { CHAT_LIMITS, formatDuration, recordingLimitMs } from "../core/media";
16
+ import type { LocalFile } from "../core/outbox";
17
+ import type { ChatRole } from "../core/types";
18
+ import { canSend, fileFromDataUri, filesFromAssets, toSend } from "./composer-state";
19
+ import { fill } from "./copy";
20
+ import { useChatNative } from "./context";
21
+ import { insertAtSelection } from "./emoji";
22
+ import { ChatEmojiPickerSheet } from "./emoji-picker";
23
+ import { haptic, type ExpoAudioLike } from "./modules";
24
+ import { liveBars } from "./recorder-machine";
25
+ import type { ChatComposerHandle } from "./use-composer";
26
+ import { useVoiceRecorder } from "./use-voice-recorder";
27
+
28
+ export interface ChatComposerProps {
29
+ composer: ChatComposerHandle;
30
+ /** null while it loads. */
31
+ canPost: boolean | null;
32
+ /** Why not, when `canPost` is false (`composerDisabledReason`). */
33
+ disabledReason: string | null;
34
+ role: ChatRole | null;
35
+ onSend: (payload: ReturnType<typeof toSend>) => void;
36
+ signalTyping?: () => void;
37
+ signalRecording?: () => void;
38
+ /** The signed-in person (`useChat().userId`), whose recent emoji the picker keeps. */
39
+ userId?: string | null;
40
+ /** Touch and hold Send (or VoiceOver's action on it) to schedule what's written (`ChatScheduleSheet`). */
41
+ onSchedule?: () => void;
42
+ /** The field's placeholder ("Message #Week 1" in a topic). */
43
+ placeholder?: string;
44
+ /** Opens the video recorder (`ChatVideoRecorder`); with it, a camera button sits beside attach. */
45
+ onRecordVideo?: () => void;
46
+ testID?: string;
47
+ }
48
+
49
+ export function ChatComposer(props: ChatComposerProps) {
50
+ const { modules } = useChatNative();
51
+ if (props.canPost === false) return <DisabledComposer reason={props.disabledReason} testID={props.testID ?? "chat-composer"} />;
52
+ return modules.audio ? <ComposerWithVoice {...props} audio={modules.audio} /> : <ComposerBody {...props} voice={null} />;
53
+ }
54
+
55
+ function DisabledComposer({ reason, testID }: { reason: string | null; testID: string }) {
56
+ const colors = useThemeRoles();
57
+ const { copy } = useChatNative();
58
+ return (
59
+ <View testID={`${testID}-disabled`} accessible accessibilityRole="text" style={{ padding: 16, alignItems: "center", backgroundColor: colors.groupedBackground }}>
60
+ <NativeText preset="body-sm" style={{ color: colors.secondaryLabel, textAlign: "center" }}>
61
+ {reason ?? copy.cantPost}
62
+ </NativeText>
63
+ </View>
64
+ );
65
+ }
66
+
67
+ type Voice = ReturnType<typeof useVoiceRecorder>;
68
+
69
+ function ComposerWithVoice(props: ChatComposerProps & { audio: ExpoAudioLike }) {
70
+ const { modules } = useChatNative();
71
+ const { composer, role, onSend } = props;
72
+ // What the note replies to is taken when recording starts: the composer may change before it lands.
73
+ const replyAtStart = useRef<string | null>(null);
74
+ const voice = useVoiceRecorder(props.audio, {
75
+ limitMs: recordingLimitMs(role),
76
+ haptics: modules.haptics,
77
+ signalRecording: props.signalRecording,
78
+ // A voice note goes on its own, with what it replied to when recording started.
79
+ onRecorded: (file: LocalFile) => {
80
+ const replyToId = replyAtStart.current;
81
+ replyAtStart.current = null;
82
+ onSend({ input: { body: "", replyToId, mentions: [] }, files: [file] });
83
+ if (replyToId && composer.state.replyTo?.messageId === replyToId) composer.setReply(null);
84
+ },
85
+ });
86
+ const phase = voice.state.phase;
87
+ useEffect(() => {
88
+ if (phase === "starting") replyAtStart.current = composer.state.replyTo?.messageId ?? null;
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [phase]);
91
+ return <ComposerBody {...props} voice={voice} />;
92
+ }
93
+
94
+ function ComposerBody({ composer, canPost, onSend, signalTyping, onRecordVideo, onSchedule, placeholder, voice, userId, testID = "chat-composer" }: ChatComposerProps & { voice: Voice | null }) {
95
+ const { modules, copy } = useChatNative();
96
+ const colors = useThemeRoles();
97
+ const { state, dispatch } = composer;
98
+ const [attachOpen, setAttachOpen] = useState(false);
99
+ const [canPaste, setCanPaste] = useState(false);
100
+ const [emojiOpen, setEmojiOpen] = useState(false);
101
+ // Where the cursor is, so an emoji from the picker goes there.
102
+ const selection = useRef<{ start: number; end: number } | null>(null);
103
+ // The text as last typed or inserted: two picks before a re-render both land.
104
+ const latestText = useRef(state.text);
105
+ latestText.current = state.text;
106
+ const insertEmoji = (emoji: string) => {
107
+ const next = insertAtSelection(latestText.current, selection.current, emoji);
108
+ latestText.current = next.text;
109
+ selection.current = { start: next.cursor, end: next.cursor };
110
+ dispatch({ type: "text", text: next.text });
111
+ signalTyping?.();
112
+ };
113
+ const ready = canPost === true;
114
+ const sendable = canSend(state, ready);
115
+ const recording = voice && voice.state.phase !== "idle";
116
+ const voiceNotice = voice?.state.phase === "idle" ? voice.state.notice : null;
117
+ const notice =
118
+ state.notice ??
119
+ (voiceNotice === "denied" ? copy.micDenied : voiceNotice === "too-short" ? copy.tooShort : voiceNotice === "error" ? copy.recordingFailed : voiceNotice === "interrupted" ? copy.recordingInterrupted : null);
120
+
121
+ const send = () => {
122
+ if (!sendable) return;
123
+ const payload = composer.take();
124
+ if (!payload) return;
125
+ haptic(modules.haptics, "send");
126
+ onSend(payload);
127
+ };
128
+
129
+ const openAttach = async () => {
130
+ let paste = false;
131
+ try {
132
+ paste = Boolean(await modules.clipboard?.hasImageAsync?.());
133
+ } catch {
134
+ paste = false;
135
+ }
136
+ setCanPaste(paste);
137
+ // One source: straight to the picker, no sheet in between.
138
+ if (!paste && !modules.imagePicker?.launchCameraAsync && modules.imagePicker) return pick("library", false);
139
+ setAttachOpen(true);
140
+ };
141
+
142
+ const remaining = CHAT_LIMITS.attachmentsPerMessage - state.attachments.length;
143
+ // The picker opens once the sheet has gone: iOS won't present one modal while another is dismissing.
144
+ const afterSheet = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
145
+ const pick = async (from: "library" | "camera", fromSheet = true) => {
146
+ setAttachOpen(false);
147
+ const picker = modules.imagePicker;
148
+ if (!picker) return;
149
+ if (fromSheet) await afterSheet();
150
+ try {
151
+ if (from === "camera") {
152
+ const permission = await picker.requestCameraPermissionsAsync?.();
153
+ if (permission && !permission.granted) return;
154
+ }
155
+ const result =
156
+ from === "camera"
157
+ ? await picker.launchCameraAsync?.({ mediaTypes: ["images", "videos"], quality: 0.8 })
158
+ : await picker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], allowsMultipleSelection: true, selectionLimit: Math.max(1, remaining), quality: 0.8 });
159
+ if (!result || result.canceled || !result.assets?.length) return;
160
+ const { attachments, notice } = filesFromAssets(result.assets, copy);
161
+ dispatch({ type: "add", attachments, notice, tooMany: fill(copy.tooMany, { count: CHAT_LIMITS.attachmentsPerMessage }) });
162
+ } catch {
163
+ haptic(modules.haptics, "error");
164
+ }
165
+ };
166
+
167
+ const paste = async () => {
168
+ setAttachOpen(false);
169
+ try {
170
+ const image = await modules.clipboard?.getImageAsync?.({ format: "png" });
171
+ const attachment = image ? fileFromDataUri(image.data, image.size) : null;
172
+ if (attachment) dispatch({ type: "add", attachments: [attachment], tooMany: fill(copy.tooMany, { count: CHAT_LIMITS.attachmentsPerMessage }) });
173
+ } catch {
174
+ haptic(modules.haptics, "error");
175
+ }
176
+ };
177
+
178
+ return (
179
+ <View testID={testID} style={{ backgroundColor: colors.groupedBackground, paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6, gap: 6 }}>
180
+ {notice ? (
181
+ <NativeText testID={`${testID}-notice`} accessibilityLiveRegion="polite" preset="caption" style={{ color: colors.destructive, paddingHorizontal: 8 }}>
182
+ {notice}
183
+ </NativeText>
184
+ ) : null}
185
+ {state.replyTo ? (
186
+ <View testID={`${testID}-reply`} style={{ flexDirection: "row", alignItems: "center", paddingLeft: 12, borderLeftWidth: 3, borderLeftColor: colors.tint, marginHorizontal: 4 }}>
187
+ <View style={{ flex: 1 }}>
188
+ <NativeText preset="caption" style={{ color: colors.tint, fontWeight: "600" }}>
189
+ {fill(copy.replyingTo, { name: state.replyTo.name })}
190
+ </NativeText>
191
+ <NativeText preset="caption" numberOfLines={1} style={{ color: colors.secondaryLabel }}>
192
+ {state.replyTo.preview}
193
+ </NativeText>
194
+ </View>
195
+ <IconButton testID={`${testID}-reply-cancel`} icon="close" label={copy.cancelReply} onPress={() => composer.setReply(null)} color={colors.secondaryLabel} />
196
+ </View>
197
+ ) : null}
198
+ {state.attachments.length ? (
199
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} testID={`${testID}-attachments`} contentContainerStyle={{ gap: 8, paddingHorizontal: 4 }}>
200
+ {state.attachments.map((a) => (
201
+ <View key={a.key} testID={`${testID}-attachment-${a.key}`} style={{ width: 64, height: 64 }}>
202
+ {a.file.previewUrl && (a.file.meta?.kind ?? "image") === "image" ? (
203
+ <NativeImage src={a.file.previewUrl} width={64} height={64} className="rounded-xl" alt={a.file.name} />
204
+ ) : (
205
+ <View style={{ width: 64, height: 64, borderRadius: 12, backgroundColor: colors.fill, alignItems: "center", justifyContent: "center" }}>
206
+ <NativeIcon name={a.file.contentType.startsWith("video/") ? "play" : "file"} size={22} />
207
+ </View>
208
+ )}
209
+ <Pressable
210
+ testID={`${testID}-attachment-${a.key}-remove`}
211
+ accessibilityRole="button"
212
+ accessibilityLabel={fill(copy.removeAttachment, { name: a.file.name })}
213
+ onPress={() => dispatch({ type: "remove", key: a.key })}
214
+ hitSlop={12}
215
+ style={{ position: "absolute", top: -6, right: -6, width: 24, height: 24, borderRadius: 12, backgroundColor: colors.label, alignItems: "center", justifyContent: "center" }}
216
+ >
217
+ <NativeIcon name="close" size={14} color={colors.surface} />
218
+ </Pressable>
219
+ </View>
220
+ ))}
221
+ </ScrollView>
222
+ ) : null}
223
+ <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 4 }}>
224
+ {recording && voice ? (
225
+ <RecordingBar voice={voice} testID={testID} />
226
+ ) : (
227
+ <>
228
+ {modules.imagePicker || modules.clipboard?.getImageAsync ? (
229
+ <IconButton testID={`${testID}-attach`} icon="plus" label={copy.attach} onPress={() => void openAttach()} color={colors.tint} disabled={!ready} />
230
+ ) : null}
231
+ {onRecordVideo ? (
232
+ <IconButton testID={`${testID}-video`} icon="video" label={copy.recordVideo} onPress={onRecordVideo} color={colors.tint} disabled={!ready} />
233
+ ) : null}
234
+ <TextInput
235
+ testID={`${testID}-input`}
236
+ value={state.text}
237
+ onChangeText={(text) => {
238
+ dispatch({ type: "text", text });
239
+ if (text) signalTyping?.();
240
+ }}
241
+ onSelectionChange={(e) => {
242
+ selection.current = e.nativeEvent.selection;
243
+ }}
244
+ placeholder={placeholder ?? copy.placeholder}
245
+ placeholderTextColor={colors.tertiaryLabel as string}
246
+ accessibilityLabel={placeholder ?? copy.placeholder}
247
+ editable={ready}
248
+ multiline
249
+ maxLength={CHAT_LIMITS.bodyChars}
250
+ style={{
251
+ flex: 1,
252
+ minHeight: 40,
253
+ maxHeight: 140,
254
+ paddingHorizontal: 14,
255
+ paddingTop: 10,
256
+ paddingBottom: 10,
257
+ borderRadius: 20,
258
+ fontSize: 17,
259
+ color: colors.label as string,
260
+ backgroundColor: colors.surface,
261
+ }}
262
+ />
263
+ <IconButton testID={`${testID}-emoji`} icon="smile" label={copy.addEmoji} onPress={() => setEmojiOpen(true)} color={colors.secondaryLabel} disabled={!ready} />
264
+ </>
265
+ )}
266
+ {sendable && !recording ? (
267
+ <IconButton testID={`${testID}-send`} icon="arrow-up" label={copy.send} onPress={send} onLongPress={onSchedule} longPressLabel={onSchedule ? copy.schedule : undefined} filled color={colors.tint} />
268
+ ) : voice ? (
269
+ <MicButton voice={voice} testID={testID} disabled={!ready} />
270
+ ) : (
271
+ <IconButton testID={`${testID}-send`} icon="arrow-up" label={copy.send} onPress={send} filled color={colors.tint} disabled />
272
+ )}
273
+ </View>
274
+ <ChatEmojiPickerSheet open={emojiOpen} onClose={() => setEmojiOpen(false)} onSelect={insertEmoji} userId={userId} testID={`${testID}-emoji-sheet`} />
275
+ <NativeSheet open={attachOpen} onOpenChange={setAttachOpen} testID={`${testID}-attach-sheet`}>
276
+ <View>
277
+ {modules.imagePicker ? (
278
+ <NativeListItem testID={`${testID}-attach-library`} title={copy.attachLibrary} leading={<NativeIcon name="image" />} onPress={() => void pick("library")} />
279
+ ) : null}
280
+ {modules.imagePicker?.launchCameraAsync ? (
281
+ <NativeListItem testID={`${testID}-attach-camera`} title={copy.attachCamera} leading={<NativeIcon name="camera" />} onPress={() => void pick("camera")} />
282
+ ) : null}
283
+ {canPaste ? (
284
+ <NativeListItem testID={`${testID}-attach-paste`} title={copy.attachPaste} leading={<NativeIcon name="copy" />} onPress={() => void paste()} />
285
+ ) : null}
286
+ </View>
287
+ </NativeSheet>
288
+ </View>
289
+ );
290
+ }
291
+
292
+ function IconButton({
293
+ icon,
294
+ label,
295
+ onPress,
296
+ color,
297
+ filled = false,
298
+ disabled = false,
299
+ onLongPress,
300
+ longPressLabel,
301
+ testID,
302
+ }: {
303
+ icon: "plus" | "arrow-up" | "close" | "trash" | "mic" | "video" | "smile";
304
+ label: string;
305
+ onPress: () => void;
306
+ onLongPress?: () => void;
307
+ /** The long press as a VoiceOver / TalkBack action. */
308
+ longPressLabel?: string;
309
+ color: unknown;
310
+ filled?: boolean;
311
+ disabled?: boolean;
312
+ testID: string;
313
+ }) {
314
+ const colors = useThemeRoles();
315
+ return (
316
+ <Pressable
317
+ testID={testID}
318
+ accessibilityRole="button"
319
+ accessibilityLabel={label}
320
+ accessibilityState={{ disabled }}
321
+ disabled={disabled}
322
+ onPress={onPress}
323
+ onLongPress={onLongPress}
324
+ delayLongPress={onLongPress ? 400 : undefined}
325
+ accessibilityActions={onLongPress && longPressLabel ? [{ name: "longpress", label: longPressLabel }] : undefined}
326
+ onAccessibilityAction={onLongPress ? (e) => e.nativeEvent.actionName === "longpress" && onLongPress() : undefined}
327
+ style={{ width: 44, height: 44, alignItems: "center", justifyContent: "center", opacity: disabled ? 0.4 : 1 }}
328
+ >
329
+ {filled ? (
330
+ <View style={{ width: 34, height: 34, borderRadius: 17, backgroundColor: color as string, alignItems: "center", justifyContent: "center" }}>
331
+ <NativeIcon name={icon} size={18} color={colors.surface} weight="semibold" />
332
+ </View>
333
+ ) : (
334
+ <NativeIcon name={icon} size={24} color={color as string} />
335
+ )}
336
+ </Pressable>
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Hold to record. While held, the finger's movement slides to cancel or up to lock; locked, the
342
+ * same button stops and sends. It stays one view throughout: swapping in another button while
343
+ * the finger that locked is still down left the touch with a responder that no longer existed,
344
+ * and the next tap anywhere on the screen did nothing.
345
+ */
346
+ function MicButton({ voice, disabled, testID }: { voice: Voice; disabled: boolean; testID: string }) {
347
+ const { copy } = useChatNative();
348
+ const colors = useThemeRoles();
349
+ const { state, dispatch } = voice;
350
+ const latest = useRef(dispatch);
351
+ latest.current = dispatch;
352
+ const locked = state.phase === "recording" && state.locked;
353
+ const lockedNow = useRef(locked);
354
+ lockedNow.current = locked;
355
+ // VoiceOver and TalkBack can't hold and slide: a double tap starts a locked (hands-free) recording.
356
+ const lockWhenReady = useRef(false);
357
+ useEffect(() => {
358
+ if (state.phase === "recording" && lockWhenReady.current) {
359
+ lockWhenReady.current = false;
360
+ dispatch({ type: "move", dx: 0, dy: -10_000 });
361
+ }
362
+ if (state.phase === "idle") lockWhenReady.current = false;
363
+ }, [state.phase, dispatch]);
364
+
365
+ const responder = useMemo(() => {
366
+ // A touch that began on the locked button is a tap on Send, not a new hold.
367
+ let tapToSend = false;
368
+ return PanResponder.create({
369
+ onStartShouldSetPanResponder: () => true,
370
+ onMoveShouldSetPanResponder: () => true,
371
+ onPanResponderTerminationRequest: () => false,
372
+ onPanResponderGrant: () => {
373
+ tapToSend = lockedNow.current;
374
+ if (!tapToSend) latest.current({ type: "press" });
375
+ },
376
+ onPanResponderMove: (_e, g) => {
377
+ if (!tapToSend) latest.current({ type: "move", dx: g.dx, dy: g.dy });
378
+ },
379
+ onPanResponderRelease: () => latest.current({ type: tapToSend ? "send" : "release" }),
380
+ onPanResponderTerminate: () => {
381
+ if (!tapToSend) latest.current({ type: "cancel" });
382
+ },
383
+ });
384
+ }, []);
385
+
386
+ const holding = !locked && (state.phase === "starting" || state.phase === "recording");
387
+ return (
388
+ <View style={{ alignItems: "center" }}>
389
+ {holding ? (
390
+ <View testID={`${testID}-lock-hint`} pointerEvents="none" style={{ position: "absolute", bottom: 52, width: 36, height: 56, borderRadius: 18, backgroundColor: colors.surface, alignItems: "center", justifyContent: "flex-start", paddingTop: 8 }}>
391
+ <NativeIcon name="lock" size={16} color={colors.secondaryLabel} />
392
+ <NativeIcon name="chevron-up" size={14} color={colors.tertiaryLabel} />
393
+ </View>
394
+ ) : null}
395
+ <View
396
+ testID={locked ? `${testID}-voice-send` : `${testID}-mic`}
397
+ accessible
398
+ accessibilityRole="button"
399
+ accessibilityLabel={locked ? copy.stopAndSend : copy.recordVoice}
400
+ accessibilityHint={locked ? undefined : copy.recordHint}
401
+ accessibilityState={{ disabled }}
402
+ accessibilityActions={disabled ? [] : [{ name: "activate" }]}
403
+ onAccessibilityAction={(e) => {
404
+ if (e.nativeEvent.actionName !== "activate") return;
405
+ if (locked) return dispatch({ type: "send" });
406
+ if (state.phase !== "idle") return;
407
+ lockWhenReady.current = true;
408
+ dispatch({ type: "press" });
409
+ }}
410
+ style={{ width: 44, height: 44, alignItems: "center", justifyContent: "center", opacity: disabled ? 0.4 : 1, transform: [{ scale: holding ? 1.25 : 1 }] }}
411
+ {...(disabled ? {} : responder.panHandlers)}
412
+ >
413
+ <View style={{ width: 34, height: 34, borderRadius: 17, alignItems: "center", justifyContent: "center", backgroundColor: locked ? colors.tint : holding ? colors.destructive : "transparent" }}>
414
+ <NativeIcon name={locked ? "arrow-up" : "mic"} size={locked || holding ? 18 : 24} color={locked || holding ? colors.surface : colors.tint} weight={locked ? "semibold" : undefined} />
415
+ </View>
416
+ </View>
417
+ </View>
418
+ );
419
+ }
420
+
421
+ /** While recording: the time, the live waveform, and slide-to-cancel (or, locked, Delete). */
422
+ function RecordingBar({ voice, testID }: { voice: Voice; testID: string }) {
423
+ const { copy } = useChatNative();
424
+ const colors = useThemeRoles();
425
+ const { state, dispatch } = voice;
426
+ const bars = liveBars(state.levels, 28);
427
+ return (
428
+ <View testID={`${testID}-recording`} accessibilityLiveRegion="polite" style={{ flex: 1, minHeight: 44, flexDirection: "row", alignItems: "center", gap: 8, paddingLeft: 4 }}>
429
+ {state.locked ? (
430
+ <IconButton testID={`${testID}-voice-cancel`} icon="trash" label={copy.cancelRecording} onPress={() => dispatch({ type: "cancel" })} color={colors.destructive} />
431
+ ) : (
432
+ <View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: colors.destructive, marginLeft: 8 }} />
433
+ )}
434
+ <NativeText testID={`${testID}-recording-time`} style={{ fontVariant: ["tabular-nums"], color: colors.label, minWidth: 40 }}>
435
+ {formatDuration(state.durationMs)}
436
+ </NativeText>
437
+ <View testID={`${testID}-recording-wave`} style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 2, height: 28, overflow: "hidden" }}>
438
+ {bars.map((h, i) => (
439
+ <View key={i} style={{ width: 3, borderRadius: 1.5, height: Math.max(3, Math.round(h * 28)), backgroundColor: colors.destructive }} />
440
+ ))}
441
+ </View>
442
+ {state.locked ? (
443
+ <View accessible accessibilityLabel={copy.lockedHint}>
444
+ <NativeIcon name="lock" size={16} color={colors.secondaryLabel} />
445
+ </View>
446
+ ) : (
447
+ <NativeText preset="caption" style={{ color: state.cancelArmed ? colors.destructive : colors.secondaryLabel }}>
448
+ {state.cancelArmed ? copy.releaseToCancel : copy.slideToCancel}
449
+ </NativeText>
450
+ )}
451
+ </View>
452
+ );
453
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @feat:chat
3
+ * What the native screens share: the native modules the app passed, the copy,
4
+ * and how to name and picture a person (the chat schema stores user ids only;
5
+ * names and avatars are the app's, from its own profiles: `ChatProvider
6
+ * loadPeople`, read through `usePeople`, or `getPerson` here, which wins).
7
+ */
8
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
9
+ import { usePeople } from "../react/index";
10
+ import { resolveChatCopy, type ChatNativeCopy, type ChatNativeCopyOverrides } from "./copy";
11
+ import type { ChatNativeModules } from "./modules";
12
+
13
+ export interface ChatPerson {
14
+ name: string;
15
+ avatarUrl?: string | null;
16
+ }
17
+
18
+ export interface ChatNativeContextValue {
19
+ modules: ChatNativeModules;
20
+ copy: ChatNativeCopy;
21
+ /** A person's name and picture, or undefined while it isn't known. */
22
+ person(userId: string | null | undefined): ChatPerson | undefined;
23
+ }
24
+
25
+ const EMPTY_MODULES: ChatNativeModules = Object.freeze({});
26
+ const DEFAULT: ChatNativeContextValue = { modules: EMPTY_MODULES, copy: resolveChatCopy(), person: () => undefined };
27
+ const Context = createContext<ChatNativeContextValue>(DEFAULT);
28
+
29
+ export interface ChatNativeProviderProps {
30
+ /** Define at module scope: a new object every render re-renders every chat screen. */
31
+ modules?: ChatNativeModules;
32
+ copy?: ChatNativeCopyOverrides;
33
+ /** Names and avatars the app already has; wins over `ChatProvider loadPeople`. */
34
+ getPerson?: (userId: string) => ChatPerson | undefined;
35
+ children: ReactNode;
36
+ }
37
+
38
+ /** Inside `ChatProvider` (from `/react`). Optional: without it there are no native extras and people are "Someone". */
39
+ export function ChatNativeProvider({ modules = EMPTY_MODULES, copy, getPerson, children }: ChatNativeProviderProps) {
40
+ const value = useMemo<ChatNativeContextValue>(
41
+ () => ({ modules, copy: resolveChatCopy(copy), person: (id) => (id && getPerson ? getPerson(id) : undefined) }),
42
+ [modules, copy, getPerson],
43
+ );
44
+ return <Context.Provider value={value}>{children}</Context.Provider>;
45
+ }
46
+
47
+ export function useChatNative(): ChatNativeContextValue {
48
+ return useContext(Context);
49
+ }
50
+
51
+ /**
52
+ * Below it, `person()` also knows these people, fetched in one batch through
53
+ * `ChatProvider loadPeople` (a screen wraps its rows in it with the ids it shows).
54
+ */
55
+ export function ChatPeopleScope({ userIds, children }: { userIds: readonly (string | null | undefined)[]; children: ReactNode }) {
56
+ const parent = useContext(Context);
57
+ const people = usePeople(userIds as (string | null | undefined)[]);
58
+ const value = useMemo<ChatNativeContextValue>(
59
+ () => ({ ...parent, person: (id) => parent.person(id) ?? (id ? people[id] : undefined) }),
60
+ [parent, people],
61
+ );
62
+ return <Context.Provider value={value}>{children}</Context.Provider>;
63
+ }
64
+
65
+ /** "SA" for "Sam Altman", "S" for "sam". */
66
+ export function initials(name: string | undefined): string {
67
+ const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
68
+ if (parts.length === 0) return "?";
69
+ return ((parts[0]![0] ?? "") + (parts.length > 1 ? (parts[parts.length - 1]![0] ?? "") : "")).toUpperCase();
70
+ }