@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,140 @@
1
+ /**
2
+ * @feat:chat
3
+ * The video recorder's rules, without React or the camera, so they are tested
4
+ * directly. One button does both gestures: a tap starts a recording that a
5
+ * second tap stops (what VoiceOver and TalkBack do too), and a hold records
6
+ * until the finger lifts. Stopping sends; Cancel throws the take away. The
7
+ * camera flips only between takes (expo-camera stops a recording that flips).
8
+ * A take the camera doesn't hand over is only that take: the camera stays
9
+ * ready. A camera that fails to start offers Try again.
10
+ */
11
+
12
+ export type VideoRecorderPhase = "asking" | "denied" | "starting" | "ready" | "recording" | "stopping" | "failed";
13
+
14
+ export interface VideoRecorderState {
15
+ phase: VideoRecorderPhase;
16
+ facing: "front" | "back";
17
+ /** When recording began (ms, the clock the events carry). */
18
+ startedAt: number;
19
+ elapsedMs: number;
20
+ /** "hold": a finger is down and lifting it stops; "tap": recording hands-free until the next tap. */
21
+ gesture: "hold" | "tap" | "stop-on-release" | null;
22
+ /** The take is to be thrown away when the camera hands it over. */
23
+ discard: boolean;
24
+ notice: "too-short" | "take-failed" | null;
25
+ /** Bumped by Try again after the camera failed: the camera view starts over. */
26
+ cameraKey: number;
27
+ }
28
+
29
+ export type VideoRecorderEvent =
30
+ | { type: "permissions"; granted: boolean }
31
+ | { type: "camera-ready" }
32
+ | { type: "camera-failed" }
33
+ | { type: "press"; at: number }
34
+ | { type: "release"; at: number }
35
+ | { type: "tick"; at: number }
36
+ | { type: "recorded"; at: number; uri: string | null }
37
+ | { type: "flip" }
38
+ | { type: "retry" }
39
+ | { type: "cancel" };
40
+
41
+ export type VideoRecorderEffect =
42
+ | { type: "start" }
43
+ | { type: "stop" }
44
+ | { type: "send"; uri: string; durationMs: number }
45
+ | { type: "close" }
46
+ | { type: "haptic"; event: "record-start" | "send" | "record-cancel" | "error" };
47
+
48
+ export interface VideoRecorderConfig {
49
+ limitMs: number;
50
+ /** A press held this long is a hold; shorter is a tap. */
51
+ holdMs?: number;
52
+ /** Shorter takes aren't sent. */
53
+ minMs?: number;
54
+ }
55
+
56
+ export const VIDEO_HOLD_MS = 350;
57
+ export const VIDEO_MIN_MS = 1000;
58
+
59
+ export const initialVideoRecorder = (facing: "front" | "back" = "front"): VideoRecorderState => ({
60
+ phase: "asking",
61
+ facing,
62
+ startedAt: 0,
63
+ elapsedMs: 0,
64
+ gesture: null,
65
+ discard: false,
66
+ notice: null,
67
+ cameraKey: 0,
68
+ });
69
+
70
+ type Step = { state: VideoRecorderState; effects: VideoRecorderEffect[] };
71
+
72
+ export function videoRecorderStep(state: VideoRecorderState, event: VideoRecorderEvent, config: VideoRecorderConfig): Step {
73
+ const holdMs = config.holdMs ?? VIDEO_HOLD_MS;
74
+ const minMs = config.minMs ?? VIDEO_MIN_MS;
75
+ const same = { state, effects: [] as VideoRecorderEffect[] };
76
+ const stop = (s: VideoRecorderState): Step => ({ state: { ...s, phase: "stopping", gesture: null }, effects: [{ type: "stop" }] });
77
+
78
+ switch (event.type) {
79
+ case "permissions":
80
+ if (state.phase !== "asking") return same;
81
+ return { state: { ...state, phase: event.granted ? "starting" : "denied" }, effects: [] };
82
+ case "camera-ready":
83
+ return state.phase === "starting" ? { state: { ...state, phase: "ready" }, effects: [] } : same;
84
+ case "camera-failed":
85
+ return state.phase === "starting" || state.phase === "ready" ? { state: { ...state, phase: "failed" }, effects: [{ type: "haptic", event: "error" }] } : same;
86
+ case "press":
87
+ if (state.phase === "ready") {
88
+ return {
89
+ state: { ...state, phase: "recording", startedAt: event.at, elapsedMs: 0, gesture: "hold", discard: false, notice: null },
90
+ effects: [{ type: "start" }, { type: "haptic", event: "record-start" }],
91
+ };
92
+ }
93
+ if (state.phase === "recording" && state.gesture === "tap") return { state: { ...state, gesture: "stop-on-release" }, effects: [] };
94
+ return same;
95
+ case "release":
96
+ if (state.phase !== "recording") return same;
97
+ if (state.gesture === "hold") return event.at - state.startedAt >= holdMs ? stop(state) : { state: { ...state, gesture: "tap" }, effects: [] };
98
+ if (state.gesture === "stop-on-release") return stop(state);
99
+ return same;
100
+ case "tick":
101
+ if (state.phase !== "recording") return same;
102
+ {
103
+ const elapsedMs = Math.max(0, event.at - state.startedAt);
104
+ // The camera stops at the limit too (maxDuration); this keeps the button in step.
105
+ if (elapsedMs >= config.limitMs) return stop({ ...state, elapsedMs: config.limitMs });
106
+ return { state: { ...state, elapsedMs }, effects: [] };
107
+ }
108
+ case "recorded": {
109
+ if (state.phase !== "recording" && state.phase !== "stopping") return same;
110
+ const durationMs = Math.min(config.limitMs, Math.max(0, event.at - state.startedAt));
111
+ const base: VideoRecorderState = { ...state, phase: "ready", gesture: null, elapsedMs: 0, discard: false };
112
+ if (state.discard) return { state: base, effects: [{ type: "close" }] };
113
+ // Too short first: a take stopped straight after it started often leaves no file at all.
114
+ if (durationMs < minMs) return { state: { ...base, notice: "too-short" }, effects: [{ type: "haptic", event: "record-cancel" }] };
115
+ // The camera is still there; only this take is lost.
116
+ if (!event.uri) return { state: { ...base, notice: "take-failed" }, effects: [{ type: "haptic", event: "error" }] };
117
+ return { state: base, effects: [{ type: "send", uri: event.uri, durationMs }, { type: "haptic", event: "send" }, { type: "close" }] };
118
+ }
119
+ case "flip":
120
+ if (state.phase === "recording" || state.phase === "stopping") return same;
121
+ // The preview restarts on the other camera: wait for it (the screen also gives it a moment).
122
+ return { state: { ...state, facing: state.facing === "front" ? "back" : "front", phase: state.phase === "ready" ? "starting" : state.phase, notice: null }, effects: [] };
123
+ case "retry":
124
+ return state.phase === "failed" ? { state: { ...state, phase: "starting", cameraKey: state.cameraKey + 1, notice: null }, effects: [] } : same;
125
+ case "cancel":
126
+ if (state.phase === "recording") {
127
+ return { state: { ...state, phase: "stopping", gesture: null, discard: true }, effects: [{ type: "stop" }, { type: "haptic", event: "record-cancel" }] };
128
+ }
129
+ if (state.phase === "stopping") return { state: { ...state, discard: true }, effects: [] };
130
+ return { state, effects: [{ type: "close" }] };
131
+ }
132
+ }
133
+
134
+ /** The kind of file a recording is, from its URI: .mov on iOS, .mp4 on Android. */
135
+ export function recordingType(uri: string): { contentType: string; ext: string } {
136
+ const ext = /\.([a-z0-9]+)(?:\?|#|$)/i.exec(uri)?.[1]?.toLowerCase() ?? "mp4";
137
+ if (ext === "mov") return { contentType: "video/quicktime", ext };
138
+ if (ext === "webm") return { contentType: "video/webm", ext };
139
+ return { contentType: "video/mp4", ext: "mp4" };
140
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * @feat:chat
3
+ * Async video messages, recorded in the thread: a full-screen camera (expo-camera, passed as
4
+ * `modules.camera`), one button to tap or hold, the time against the role's limit, flip
5
+ * between takes, and close to throw a take away. Stopping sends: the recording is handed to
6
+ * `onRecorded`, which the thread screen sends (`videoUploads`). Asks for the camera and the
7
+ * microphone when it opens; refused, it says how to turn them on.
8
+ */
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { AccessibilityInfo, Linking, Modal, Pressable, View } from "react-native";
11
+ import { SafeAreaView } from "react-native-safe-area-context";
12
+ import { NativeButton, NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
13
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
14
+ import { formatDuration } from "../core/media";
15
+ import { fill } from "./copy";
16
+ import { useChatNative } from "./context";
17
+ import { MEDIA_BACKDROP, MEDIA_SCRIM, ON_MEDIA } from "./media-colors";
18
+ import { haptic, type CameraViewHandleLike, type ExpoCameraLike } from "./modules";
19
+ import type { RecordedVideo } from "./video-upload";
20
+ import { initialVideoRecorder, recordingType, videoRecorderStep, type VideoRecorderEffect, type VideoRecorderEvent, type VideoRecorderState } from "./video-recorder-state";
21
+
22
+ export interface ChatVideoRecorderProps {
23
+ open: boolean;
24
+ onClose: () => void;
25
+ /** The role's limit (`recordingLimitMs(role)`). */
26
+ limitMs: number;
27
+ /** A take began: the moment to mint the upload, so the bytes can go as soon as it stops. */
28
+ onStart?: () => void;
29
+ /** About every second while recording (to tell the others: `signalRecordingVideo`). */
30
+ onTick?: () => void;
31
+ onRecorded: (video: RecordedVideo) => void;
32
+ /** For tests: the clock. */
33
+ now?: () => number;
34
+ testID?: string;
35
+ }
36
+
37
+ /** Renders nothing without `modules.camera`. */
38
+ export function ChatVideoRecorder(props: ChatVideoRecorderProps) {
39
+ const { modules } = useChatNative();
40
+ const camera = modules.camera;
41
+ if (!camera) return null;
42
+ const testID = props.testID ?? "chat-video-recorder";
43
+ return (
44
+ <Modal visible={props.open} animationType="slide" presentationStyle="fullScreen" onRequestClose={props.onClose} testID={testID}>
45
+ {props.open ? <RecorderBody {...props} camera={camera} testID={testID} /> : null}
46
+ </Modal>
47
+ );
48
+ }
49
+
50
+ function RecorderBody({ camera, onClose, limitMs, onStart, onTick, onRecorded, now = Date.now, testID }: ChatVideoRecorderProps & { camera: ExpoCameraLike; testID: string }) {
51
+ const { copy, modules } = useChatNative();
52
+ const colors = useThemeRoles();
53
+ const [state, setState] = useState<VideoRecorderState>(() => initialVideoRecorder());
54
+ const stateRef = useRef(state);
55
+ const cameraRef = useRef<CameraViewHandleLike | null>(null);
56
+ const alive = useRef(true);
57
+ const latest = useRef({ onClose, onStart, onTick, onRecorded, limitMs, now });
58
+ latest.current = { onClose, onStart, onTick, onRecorded, limitMs, now };
59
+
60
+ const dispatch = useCallback(
61
+ (event: VideoRecorderEvent) => {
62
+ if (!alive.current) return;
63
+ const step = videoRecorderStep(stateRef.current, event, { limitMs: latest.current.limitMs });
64
+ stateRef.current = step.state;
65
+ setState(step.state);
66
+ step.effects.forEach(run);
67
+ },
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ [],
70
+ );
71
+
72
+ function run(effect: VideoRecorderEffect) {
73
+ const l = latest.current;
74
+ switch (effect.type) {
75
+ case "start": {
76
+ l.onStart?.();
77
+ const cam = cameraRef.current;
78
+ if (!cam) return dispatch({ type: "recorded", at: l.now(), uri: null });
79
+ cam
80
+ .recordAsync({ maxDuration: Math.ceil(l.limitMs / 1000) })
81
+ .then((r) => dispatch({ type: "recorded", at: latest.current.now(), uri: r?.uri ?? null }))
82
+ .catch(() => dispatch({ type: "recorded", at: latest.current.now(), uri: null }));
83
+ return;
84
+ }
85
+ case "stop":
86
+ try {
87
+ cameraRef.current?.stopRecording();
88
+ } catch {
89
+ dispatch({ type: "recorded", at: l.now(), uri: null });
90
+ }
91
+ return;
92
+ case "send": {
93
+ const { contentType, ext } = recordingType(effect.uri);
94
+ l.onRecorded({ uri: effect.uri, durationMs: effect.durationMs, contentType, name: `video-${Math.round(l.now())}.${ext}` });
95
+ return;
96
+ }
97
+ case "close":
98
+ l.onClose();
99
+ return;
100
+ case "haptic":
101
+ haptic(modules.haptics, effect.event);
102
+ return;
103
+ }
104
+ }
105
+
106
+ // Camera and microphone, asked for when the recorder opens.
107
+ useEffect(() => {
108
+ alive.current = true;
109
+ void Promise.all([camera.Camera.requestCameraPermissionsAsync(), camera.Camera.requestMicrophonePermissionsAsync()])
110
+ .then(([cam, mic]) => dispatch({ type: "permissions", granted: Boolean(cam?.granted && mic?.granted) }))
111
+ .catch(() => dispatch({ type: "permissions", granted: false }));
112
+ return () => {
113
+ // Closed mid-take (Android back, the app's navigation): the take is thrown away.
114
+ if (stateRef.current.phase === "recording" || stateRef.current.phase === "stopping") {
115
+ try {
116
+ cameraRef.current?.stopRecording();
117
+ } catch {
118
+ // Already stopped.
119
+ }
120
+ }
121
+ alive.current = false;
122
+ };
123
+ }, [camera, dispatch]);
124
+
125
+ // The timer, and the others told about once a second.
126
+ const recording = state.phase === "recording";
127
+ useEffect(() => {
128
+ if (!recording) return;
129
+ let ticks = 0;
130
+ const id = setInterval(() => {
131
+ dispatch({ type: "tick", at: latest.current.now() });
132
+ if (ticks++ % 4 === 0) latest.current.onTick?.();
133
+ }, 250);
134
+ return () => clearInterval(id);
135
+ }, [recording, dispatch]);
136
+
137
+ // The preview says when it's ready (at open, after a flip, after Try again); a camera that never
138
+ // says so gets the button anyway after a moment, and a take that can't record says that.
139
+ const starting = state.phase === "starting";
140
+ useEffect(() => {
141
+ if (!starting) return;
142
+ const id = setTimeout(() => dispatch({ type: "camera-ready" }), 1500);
143
+ return () => clearTimeout(id);
144
+ }, [starting, state.facing, state.cameraKey, dispatch]);
145
+
146
+ // Said out loud on iOS too (a live region is Android only).
147
+ const noticeText = state.notice === "too-short" ? copy.videoTooShort : state.notice === "take-failed" ? copy.videoTakeFailed : null;
148
+ const blockedText = state.phase === "denied" ? copy.cameraDenied : state.phase === "failed" ? copy.cameraFailed : null;
149
+ useEffect(() => {
150
+ const text = noticeText ?? blockedText;
151
+ if (text) AccessibilityInfo?.announceForAccessibility?.(text);
152
+ }, [noticeText, blockedText]);
153
+
154
+ const { CameraView } = camera;
155
+ const blocked = state.phase === "denied" || state.phase === "failed";
156
+ const canRecord = state.phase === "ready" || recording;
157
+ const stopping = state.phase === "stopping";
158
+
159
+ return (
160
+ <View testID={`${testID}-body`} style={{ flex: 1, backgroundColor: MEDIA_BACKDROP }}>
161
+ {state.phase !== "asking" && state.phase !== "denied" ? (
162
+ <CameraView
163
+ key={state.cameraKey}
164
+ ref={cameraRef}
165
+ testID={`${testID}-camera`}
166
+ style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
167
+ facing={state.facing}
168
+ mode="video"
169
+ videoQuality="720p"
170
+ onCameraReady={() => dispatch({ type: "camera-ready" })}
171
+ onMountError={() => dispatch({ type: "camera-failed" })}
172
+ />
173
+ ) : null}
174
+ <SafeAreaView edges={["top", "bottom", "left", "right"]} style={{ flex: 1, justifyContent: "space-between" }}>
175
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 8, paddingTop: 4 }}>
176
+ <Pressable
177
+ testID={`${testID}-close`}
178
+ accessibilityRole="button"
179
+ accessibilityLabel={copy.closeRecorder}
180
+ onPress={() => dispatch({ type: "cancel" })}
181
+ style={{ width: 44, height: 44, alignItems: "center", justifyContent: "center" }}
182
+ >
183
+ <NativeIcon name="close" size={24} color={ON_MEDIA} />
184
+ </Pressable>
185
+ {canRecord || stopping ? (
186
+ <View
187
+ testID={`${testID}-time`}
188
+ accessible
189
+ accessibilityLabel={fill(copy.videoElapsed, { elapsed: formatDuration(state.elapsedMs), limit: formatDuration(limitMs) })}
190
+ style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 14, backgroundColor: MEDIA_SCRIM }}
191
+ >
192
+ {recording ? <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: colors.destructive }} /> : null}
193
+ <NativeText style={{ color: ON_MEDIA, fontVariant: ["tabular-nums"], fontSize: 15 }}>
194
+ {fill(copy.videoElapsed, { elapsed: formatDuration(state.elapsedMs), limit: formatDuration(limitMs) })}
195
+ </NativeText>
196
+ </View>
197
+ ) : null}
198
+ <View style={{ width: 44 }} />
199
+ </View>
200
+
201
+ {blocked ? (
202
+ <View testID={`${testID}-${state.phase}`} accessibilityLiveRegion="polite" style={{ paddingHorizontal: 32, gap: 16, alignItems: "center" }}>
203
+ <NativeIcon name="camera" size={40} color={ON_MEDIA} />
204
+ <NativeText style={{ color: ON_MEDIA, textAlign: "center", fontSize: 17 }}>{state.phase === "denied" ? copy.cameraDenied : copy.cameraFailed}</NativeText>
205
+ {state.phase === "denied" ? (
206
+ <NativeButton testID={`${testID}-settings`} variant="secondary" onPress={() => void Linking?.openSettings?.()}>
207
+ {copy.openSettings}
208
+ </NativeButton>
209
+ ) : (
210
+ <NativeButton testID={`${testID}-retry`} variant="secondary" onPress={() => dispatch({ type: "retry" })}>
211
+ {copy.tryAgain}
212
+ </NativeButton>
213
+ )}
214
+ </View>
215
+ ) : null}
216
+
217
+ <View style={{ paddingBottom: 16, gap: 12, alignItems: "center" }}>
218
+ {state.notice ? (
219
+ <NativeText testID={`${testID}-notice`} accessibilityLiveRegion="polite" style={{ color: ON_MEDIA, textAlign: "center", paddingHorizontal: 24 }}>
220
+ {noticeText}
221
+ </NativeText>
222
+ ) : null}
223
+ {!blocked ? (
224
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", width: "100%" }}>
225
+ <View style={{ flex: 1 }} />
226
+ <Pressable
227
+ testID={`${testID}-record`}
228
+ accessibilityRole="button"
229
+ accessibilityLabel={recording ? copy.stopVideo : copy.startVideo}
230
+ accessibilityHint={recording ? undefined : copy.recordVideoHint}
231
+ accessibilityState={{ disabled: !canRecord, busy: stopping }}
232
+ // A screen reader's double tap is a tap: it starts hands-free, and the next one stops.
233
+ accessibilityActions={canRecord ? [{ name: "activate" }] : []}
234
+ onAccessibilityAction={(e) => {
235
+ if (e.nativeEvent.actionName !== "activate") return;
236
+ const at = latest.current.now();
237
+ dispatch({ type: "press", at });
238
+ dispatch({ type: "release", at });
239
+ }}
240
+ disabled={!canRecord}
241
+ onPressIn={() => dispatch({ type: "press", at: latest.current.now() })}
242
+ onPressOut={() => dispatch({ type: "release", at: latest.current.now() })}
243
+ style={{ width: 84, height: 84, borderRadius: 42, borderWidth: 5, borderColor: ON_MEDIA, alignItems: "center", justifyContent: "center", opacity: canRecord ? 1 : 0.5 }}
244
+ >
245
+ <View style={recording ? { width: 30, height: 30, borderRadius: 6, backgroundColor: colors.destructive } : { width: 64, height: 64, borderRadius: 32, backgroundColor: colors.destructive }} />
246
+ </Pressable>
247
+ <View style={{ flex: 1, alignItems: "center" }}>
248
+ <Pressable
249
+ testID={`${testID}-flip`}
250
+ accessibilityRole="button"
251
+ accessibilityLabel={copy.flipCamera}
252
+ accessibilityState={{ disabled: recording || stopping }}
253
+ disabled={recording || stopping}
254
+ onPress={() => dispatch({ type: "flip" })}
255
+ style={{ width: 48, height: 48, borderRadius: 24, alignItems: "center", justifyContent: "center", backgroundColor: MEDIA_SCRIM, opacity: recording || stopping ? 0.4 : 1 }}
256
+ >
257
+ <NativeIcon name="camera-flip" size={24} color={ON_MEDIA} />
258
+ </Pressable>
259
+ </View>
260
+ </View>
261
+ ) : null}
262
+ {!blocked && !recording ? (
263
+ <NativeText preset="caption" style={{ color: ON_MEDIA, opacity: 0.85, textAlign: "center", paddingHorizontal: 24 }}>
264
+ {copy.recordVideoHint}
265
+ </NativeText>
266
+ ) : null}
267
+ </View>
268
+ </SafeAreaView>
269
+ </View>
270
+ );
271
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @feat:chat
3
+ * Videos on their way, above the composer: how far each upload has got, and, when one
4
+ * fails, why, with Retry (it resumes where it stopped) and Cancel. Once a video's bytes are
5
+ * all there, its message joins the outbox and shows in the thread like any other.
6
+ */
7
+ import { useEffect, useRef } from "react";
8
+ import { AccessibilityInfo, Pressable, View } from "react-native";
9
+ import { NativeIcon, NativeProgress, NativeText } from "@marktiderman/genesis-ui-native/components";
10
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
11
+ import { fill } from "./copy";
12
+ import { useChatNative } from "./context";
13
+ import { useVideoUploads, videoUploads as defaultUploads, type VideoUploads } from "./video-upload";
14
+
15
+ export interface VideoUploadTrayProps {
16
+ conversationId: string;
17
+ /** The signed-in person: the tray shows only their uploads. */
18
+ userId?: string | null;
19
+ uploads?: VideoUploads;
20
+ testID?: string;
21
+ }
22
+
23
+ export function VideoUploadTray({ conversationId, userId = null, uploads = defaultUploads, testID = "chat-video-uploads" }: VideoUploadTrayProps) {
24
+ const { copy } = useChatNative();
25
+ const colors = useThemeRoles();
26
+ const tasks = useVideoUploads(conversationId, userId, uploads);
27
+ const why = (t: (typeof tasks)[number]) => (t.error === "offline" ? copy.videoOffline : t.error === "forbidden" ? copy.videoForbidden : copy.videoUploadFailed);
28
+ // A failure is said once, on both platforms (a live region is Android only, and would read every percent).
29
+ const told = useRef(new Set<string>());
30
+ const failedNow = tasks.filter((t) => t.status === "failed").map((t) => t.id).join(",");
31
+ useEffect(() => {
32
+ for (const t of tasks) {
33
+ if (t.status === "failed" && !told.current.has(t.id)) {
34
+ told.current.add(t.id);
35
+ AccessibilityInfo?.announceForAccessibility?.(why(t));
36
+ }
37
+ if (t.status !== "failed") told.current.delete(t.id);
38
+ }
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, [failedNow]);
41
+ if (!tasks.length) return null;
42
+ return (
43
+ <View testID={testID} style={{ paddingHorizontal: 12, paddingTop: 6, gap: 6, backgroundColor: colors.groupedBackground }}>
44
+ {tasks.map((t) => {
45
+ const percent = Math.round(t.progress * 100);
46
+ const failed = t.status === "failed";
47
+ return (
48
+ <View key={t.id} testID={`${testID}-${t.id}`} style={{ flexDirection: "row", alignItems: "center", gap: 8, padding: 8, borderRadius: 12, backgroundColor: colors.surface }}>
49
+ <NativeIcon name="video" size={20} color={failed ? colors.destructive : colors.tint} />
50
+ <View
51
+ style={{ flex: 1, gap: 4 }}
52
+ accessible
53
+ accessibilityRole={failed ? "alert" : "progressbar"}
54
+ accessibilityLabel={failed ? why(t) : fill(copy.videoUploading, { percent })}
55
+ accessibilityValue={failed ? undefined : { min: 0, max: 100, now: percent }}
56
+ >
57
+ <NativeText testID={`${testID}-${t.id}-text`} preset="caption" numberOfLines={2} style={{ color: failed ? colors.destructive : colors.label }}>
58
+ {failed ? why(t) : fill(copy.videoUploading, { percent })}
59
+ </NativeText>
60
+ {failed ? null : <NativeProgress value={percent} testID={`${testID}-${t.id}-progress`} />}
61
+ </View>
62
+ {failed ? (
63
+ <Pressable testID={`${testID}-${t.id}-retry`} accessibilityRole="button" accessibilityLabel={copy.retry} onPress={() => uploads.retry(t.id)} style={{ minHeight: 44, minWidth: 44, paddingHorizontal: 8, alignItems: "center", justifyContent: "center" }}>
64
+ <NativeText preset="label" style={{ color: colors.tint }}>
65
+ {copy.retry}
66
+ </NativeText>
67
+ </Pressable>
68
+ ) : null}
69
+ <Pressable testID={`${testID}-${t.id}-cancel`} accessibilityRole="button" accessibilityLabel={copy.cancelUpload} onPress={() => uploads.cancel(t.id)} style={{ width: 44, height: 44, alignItems: "center", justifyContent: "center" }}>
70
+ <NativeIcon name="close" size={18} color={colors.secondaryLabel} />
71
+ </Pressable>
72
+ </View>
73
+ );
74
+ })}
75
+ </View>
76
+ );
77
+ }