movius-chats 1.3.13 → 1.4.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 (32) hide show
  1. package/lib/commonjs/index.js +5 -3
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/module/index.js +5 -3
  4. package/lib/module/index.js.map +1 -1
  5. package/lib/typescript/assets/Icons/ChevronUpIcon.d.ts +5 -0
  6. package/lib/typescript/assets/Icons/LockIcon.d.ts +5 -0
  7. package/lib/typescript/assets/Icons/TrashIcon.d.ts +5 -0
  8. package/lib/typescript/components/AudioPlayer/types.d.ts +1 -0
  9. package/lib/typescript/components/ChatInput/types.d.ts +2 -2
  10. package/lib/typescript/components/VoiceRecorder/LongPressRecording.d.ts +13 -0
  11. package/lib/typescript/components/VoiceRecorder/NormalRecording.d.ts +20 -0
  12. package/lib/typescript/components/VoiceRecorder/WaveformAnimation.d.ts +10 -0
  13. package/lib/typescript/hooks/useVoiceRecorder.d.ts +19 -0
  14. package/lib/typescript/types/index.d.ts +74 -1
  15. package/package.json +12 -2
  16. package/scripts/patchSound.js +48 -23
  17. package/src/assets/Icons/ChevronUpIcon.tsx +20 -0
  18. package/src/assets/Icons/LockIcon.tsx +28 -0
  19. package/src/assets/Icons/TrashIcon.tsx +26 -0
  20. package/src/components/AudioPlayer/AudioPlayer.tsx +147 -163
  21. package/src/components/AudioPlayer/types.ts +1 -0
  22. package/src/components/ChatBubble/MediaGrid.tsx +4 -1
  23. package/src/components/ChatBubble/MessageContent.tsx +1 -0
  24. package/src/components/ChatInput/ChatInput.tsx +296 -62
  25. package/src/components/ChatInput/FilePreview.tsx +3 -0
  26. package/src/components/ChatInput/types.ts +2 -2
  27. package/src/components/MediaViewer/MediaViewer.tsx +45 -10
  28. package/src/components/VoiceRecorder/LongPressRecording.tsx +195 -0
  29. package/src/components/VoiceRecorder/NormalRecording.tsx +156 -0
  30. package/src/components/VoiceRecorder/WaveformAnimation.tsx +56 -0
  31. package/src/hooks/useVoiceRecorder.ts +206 -0
  32. package/src/types/index.ts +80 -1
@@ -1,12 +1,28 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
- import { Platform, Pressable, TextInput, View } from 'react-native';
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import {
9
+ PanResponder,
10
+ Platform,
11
+ Pressable,
12
+ TextInput,
13
+ View,
14
+ } from 'react-native';
3
15
  import tw from 'twrnc';
4
16
  import { CameraIcon } from '../../assets/Icons/CameraIcon';
5
17
  import { EmojiFunnySquareIcon } from '../../assets/Icons/EmojiFunnySquareIcon';
6
18
  import { MicrophoneIcon } from '../../assets/Icons/MicrophoneIcon';
7
19
  import { PaperClipIcon } from '../../assets/Icons/PaperClipIcon';
8
20
  import { PaperPlaneIcon } from '../../assets/Icons/PaperPlaneIcon';
21
+ import { LongPressRecording } from '../VoiceRecorder/LongPressRecording';
22
+ import { NormalRecording } from '../VoiceRecorder/NormalRecording';
9
23
  import { useChatContext } from '../../context/ChatContext';
24
+ import { useVoiceRecorder } from '../../hooks/useVoiceRecorder';
25
+ import { RecordingResult, VoiceRecorderExposedState } from '../../types';
10
26
  import {
11
27
  getInputBarIconPixelSize,
12
28
  getInputBarIconStyle,
@@ -15,14 +31,22 @@ import {
15
31
  import FilePreview from './FilePreview';
16
32
  import { ChatInputProps, InputHeightState } from './types';
17
33
 
34
+ // ─── Layout constants ─────────────────────────────────────────────────────────
18
35
  const MIN_INPUT_HEIGHT = Platform.OS === 'ios' ? 32 : 30;
19
36
  const MAX_INPUT_HEIGHT = 118;
20
- /** Visual height of the pill bar (icons stay vertically centered in this band). */
21
37
  const INPUT_BAR_SHELL_HEIGHT = Platform.OS === 'ios' ? 50 : 48;
22
38
 
23
39
  const SEND_ICON_CLASS = 'h-6 w-6';
24
40
  const MIC_ICON_CLASS = 'h-8 w-8';
25
41
 
42
+ // Long-press / swipe thresholds (px)
43
+ const LONG_PRESS_MS = 500;
44
+ const CANCEL_THRESHOLD_X = -70; // slide left to cancel
45
+ const LOCK_THRESHOLD_Y = -80; // slide up to lock
46
+
47
+ type VoiceMode = 'idle' | 'normal' | 'longPress' | 'locked';
48
+
49
+ // ─── Component ────────────────────────────────────────────────────────────────
26
50
  const ChatInput: React.FC<ChatInputProps> = ({
27
51
  onSendMessage,
28
52
  onTypingStart,
@@ -40,12 +64,14 @@ const ChatInput: React.FC<ChatInputProps> = ({
40
64
  CustomImagePreview,
41
65
  CustomVideoPreview,
42
66
  }) => {
67
+ // ── Text input state ───────────────────────────────────────────────────────
43
68
  const [inputText, setInputText] = useState('');
44
69
  const [inputResetKey, setInputResetKey] = useState(0);
45
70
  const [inputHeight, setInputHeight] = useState<InputHeightState>({
46
71
  height: MIN_INPUT_HEIGHT,
47
72
  isMultiline: false,
48
73
  });
74
+
49
75
  const {
50
76
  theme,
51
77
  currentUserId,
@@ -58,8 +84,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
58
84
  previewItems,
59
85
  closePreview,
60
86
  onRemovePreviewItem,
87
+ voiceRecorderProps,
88
+ voiceRecorderStyles,
89
+ recordingUIProps,
90
+ renderVoiceRecorder,
61
91
  } = useChatContext();
62
92
 
93
+ // ── Preview list ───────────────────────────────────────────────────────────
63
94
  const previewList = useMemo(() => {
64
95
  if (previewItems?.length) return previewItems;
65
96
  if (previewData) return [previewData];
@@ -68,6 +99,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
68
99
 
69
100
  const hasPreviewAttachments = previewList.length > 0;
70
101
 
102
+ // ── Icon sizing ────────────────────────────────────────────────────────────
71
103
  const inputBarIconSize = theme?.sizes?.inputIconSize;
72
104
  const inputBarIconStyle = getInputBarIconStyle(inputBarIconSize);
73
105
  const iconPixelSize = getInputBarIconPixelSize(inputBarIconSize);
@@ -80,17 +112,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
80
112
  ? { paddingTop: iconInset, paddingBottom: iconInset }
81
113
  : { paddingBottom: iconInset };
82
114
 
115
+ // ── Text input handlers ────────────────────────────────────────────────────
83
116
  const resetInputLayout = useCallback(() => {
84
117
  setInputHeight({ height: MIN_INPUT_HEIGHT, isMultiline: false });
85
- setInputResetKey((key) => key + 1);
118
+ setInputResetKey((k) => k + 1);
86
119
  }, []);
87
120
 
88
121
  const handleChangeText = useCallback(
89
122
  (text: string) => {
90
123
  setInputText(text);
91
- if (text.length === 0) {
92
- resetInputLayout();
93
- }
124
+ if (text.length === 0) resetInputLayout();
94
125
  },
95
126
  [resetInputLayout]
96
127
  );
@@ -112,36 +143,200 @@ const ChatInput: React.FC<ChatInputProps> = ({
112
143
 
113
144
  const handleSendMessage = useCallback(() => {
114
145
  const trimmedText = inputText.trim();
115
-
116
- if (!trimmedText && !hasPreviewAttachments) {
117
- return;
118
- }
119
-
120
- onSendMessage({
121
- text: trimmedText,
122
- senderId: currentUserId,
123
- });
124
-
146
+ if (!trimmedText && !hasPreviewAttachments) return;
147
+ onSendMessage({ text: trimmedText, senderId: currentUserId });
125
148
  setInputText('');
126
149
  resetInputLayout();
127
- }, [
128
- inputText,
129
- onSendMessage,
130
- currentUserId,
131
- hasPreviewAttachments,
132
- resetInputLayout,
133
- ]);
150
+ }, [inputText, onSendMessage, currentUserId, hasPreviewAttachments, resetInputLayout]);
134
151
 
135
152
  useEffect(() => {
136
- if (inputText.trim()) {
137
- onTypingStart?.();
138
- } else {
139
- onTypingEnd?.();
140
- }
153
+ if (inputText.trim()) onTypingStart?.();
154
+ else onTypingEnd?.();
141
155
  }, [inputText, onTypingStart, onTypingEnd]);
142
156
 
143
157
  const showSendButton = !!inputText.trim() || hasPreviewAttachments;
144
158
 
159
+ // ── Voice recorder ─────────────────────────────────────────────────────────
160
+ const [voiceMode, setVoiceModeState] = useState<VoiceMode>('idle');
161
+ const voiceModeRef = useRef<VoiceMode>('idle');
162
+ const setVoiceMode = useCallback((mode: VoiceMode) => {
163
+ voiceModeRef.current = mode;
164
+ setVoiceModeState(mode);
165
+ }, []);
166
+
167
+ // Track slide position for the long-press UI feedback
168
+ const [slideX, setSlideX] = useState(0);
169
+ const slideXRef = useRef(0);
170
+
171
+ const onRecordEnd = useCallback(
172
+ (result: RecordingResult) => {
173
+ onAudioRecordEnd?.(result);
174
+ },
175
+ [onAudioRecordEnd]
176
+ );
177
+
178
+ const recorder = useVoiceRecorder({
179
+ maxDuration: voiceRecorderProps?.maxDuration ?? 300,
180
+ onRecordStart: onAudioRecordStart,
181
+ onRecordEnd,
182
+ });
183
+
184
+ // Keep a stable ref to the recorder so the PanResponder closure never stales
185
+ const recorderRef = useRef(recorder);
186
+ recorderRef.current = recorder;
187
+
188
+ // ── Send / cancel helpers ──────────────────────────────────────────────────
189
+ const handleSendVoice = useCallback(async () => {
190
+ const result = await recorderRef.current.stopRecording();
191
+ setVoiceMode('idle');
192
+ if (result) {
193
+ onSendMessage({ audio: result.uri, senderId: currentUserId });
194
+ }
195
+ }, [onSendMessage, currentUserId, setVoiceMode]);
196
+
197
+ const handleCancelVoice = useCallback(() => {
198
+ recorderRef.current.cancelRecording();
199
+ setVoiceMode('idle');
200
+ setSlideX(0);
201
+ slideXRef.current = 0;
202
+ }, [setVoiceMode]);
203
+
204
+ // Stable refs for PanResponder closures
205
+ const handleSendVoiceRef = useRef(handleSendVoice);
206
+ handleSendVoiceRef.current = handleSendVoice;
207
+ const handleCancelVoiceRef = useRef(handleCancelVoice);
208
+ handleCancelVoiceRef.current = handleCancelVoice;
209
+
210
+ // ── PanResponder for the mic button ───────────────────────────────────────
211
+ // Created once; uses refs for all mutable values to avoid stale closures.
212
+ const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>();
213
+ const isLongPressActiveRef = useRef(false);
214
+
215
+ const micPanResponder = useMemo(
216
+ () =>
217
+ PanResponder.create({
218
+ onStartShouldSetPanResponder: () => true,
219
+ onMoveShouldSetPanResponder: () => isLongPressActiveRef.current,
220
+
221
+ onPanResponderGrant: () => {
222
+ isLongPressActiveRef.current = false;
223
+ slideXRef.current = 0;
224
+ setSlideX(0);
225
+
226
+ longPressTimerRef.current = setTimeout(async () => {
227
+ isLongPressActiveRef.current = true;
228
+ await recorderRef.current.startRecording();
229
+ setVoiceMode('longPress');
230
+ }, LONG_PRESS_MS);
231
+ },
232
+
233
+ onPanResponderMove: (_, gestureState) => {
234
+ if (!isLongPressActiveRef.current) return;
235
+
236
+ slideXRef.current = gestureState.dx;
237
+ setSlideX(gestureState.dx);
238
+
239
+ // Auto-lock when finger slides far enough up
240
+ if (
241
+ gestureState.dy < LOCK_THRESHOLD_Y &&
242
+ voiceModeRef.current === 'longPress'
243
+ ) {
244
+ setVoiceMode('locked');
245
+ }
246
+ },
247
+
248
+ onPanResponderRelease: async (_, gestureState) => {
249
+ clearTimeout(longPressTimerRef.current);
250
+ const wasLongPress = isLongPressActiveRef.current;
251
+ isLongPressActiveRef.current = false;
252
+
253
+ if (!wasLongPress) {
254
+ // Quick tap → start normal recording mode
255
+ await recorderRef.current.startRecording();
256
+ setVoiceMode('normal');
257
+ return;
258
+ }
259
+
260
+ // Long press released
261
+ if (voiceModeRef.current === 'locked') {
262
+ // Locked → stay in recording, user taps send/cancel manually
263
+ return;
264
+ }
265
+
266
+ if (gestureState.dx < CANCEL_THRESHOLD_X) {
267
+ handleCancelVoiceRef.current();
268
+ } else {
269
+ // Auto-send on release (WhatsApp behaviour)
270
+ handleSendVoiceRef.current();
271
+ }
272
+ },
273
+
274
+ onPanResponderTerminate: () => {
275
+ clearTimeout(longPressTimerRef.current);
276
+ isLongPressActiveRef.current = false;
277
+ },
278
+ }),
279
+ [] // Intentional: all values accessed via refs
280
+ );
281
+
282
+ // ── Render recording UI ───────────────────────────────────────────────────
283
+ if (voiceMode !== 'idle') {
284
+ const exposedState: VoiceRecorderExposedState = {
285
+ isRecording: recorder.isRecording,
286
+ isPaused: recorder.isPaused,
287
+ duration: recorder.duration,
288
+ isLocked: voiceMode === 'locked',
289
+ slideOffset: { x: slideX, y: 0 },
290
+ waveformData: [],
291
+ startRecording: recorder.startRecording,
292
+ stopRecording: recorder.stopRecording,
293
+ pauseRecording: recorder.pauseRecording,
294
+ resumeRecording: recorder.resumeRecording,
295
+ cancelRecording: handleCancelVoice,
296
+ };
297
+
298
+ const sendBg =
299
+ (theme?.inputStyles?.sendButtonStyle?.backgroundColor as string) ??
300
+ '#16a34a';
301
+ const sendFg = theme?.colors?.sendIconsColor ?? '#ffffff';
302
+
303
+ return (
304
+ <View style={tw`w-full px-2`}>
305
+ {renderVoiceRecorder ? (
306
+ renderVoiceRecorder(exposedState)
307
+ ) : voiceMode === 'longPress' ? (
308
+ <LongPressRecording
309
+ duration={recorder.duration}
310
+ slideX={slideX}
311
+ containerHeight={INPUT_BAR_SHELL_HEIGHT}
312
+ fontFamily={theme?.fontFamily}
313
+ voiceRecorderStyles={voiceRecorderStyles}
314
+ recordingUIProps={recordingUIProps}
315
+ />
316
+ ) : (
317
+ // 'normal' or 'locked' → show the full controls bar
318
+ <NormalRecording
319
+ isRecording={recorder.isRecording}
320
+ isPaused={recorder.isPaused}
321
+ duration={recorder.duration}
322
+ onCancel={handleCancelVoice}
323
+ onSend={handleSendVoice}
324
+ onPause={recorder.pauseRecording}
325
+ onResume={recorder.resumeRecording}
326
+ enablePauseResume={voiceRecorderProps?.enablePauseResume ?? true}
327
+ containerHeight={INPUT_BAR_SHELL_HEIGHT}
328
+ fontFamily={theme?.fontFamily}
329
+ sendButtonColor={sendBg}
330
+ sendIconColor={sendFg}
331
+ voiceRecorderStyles={voiceRecorderStyles}
332
+ recordingUIProps={recordingUIProps}
333
+ />
334
+ )}
335
+ </View>
336
+ );
337
+ }
338
+
339
+ // ── Normal input UI ───────────────────────────────────────────────────────
145
340
  return (
146
341
  <View style={tw`w-full px-2`}>
147
342
  {hasPreviewAttachments && (
@@ -162,6 +357,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
162
357
  theme?.inputStyles?.inputSectionContainerStyle,
163
358
  ]}
164
359
  >
360
+ {/* ── Text input pill ── */}
165
361
  <View
166
362
  style={[
167
363
  tw`flex-1 flex-row bg-white overflow-hidden px-3.5`,
@@ -181,7 +377,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
181
377
  ) : (
182
378
  <EmojiFunnySquareIcon
183
379
  style={inputBarIconStyle}
184
- color={theme?.colors?.inputsIconsColor || 'rgba(0,0,0,0.7)'}
380
+ color={
381
+ theme?.colors?.inputsIconsColor || 'rgba(0,0,0,0.7)'
382
+ }
185
383
  />
186
384
  )}
187
385
  </Pressable>
@@ -206,7 +404,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
206
404
  : 4,
207
405
  },
208
406
  {
209
- color: theme?.colors?.inputTextColor || 'rgba(0, 0, 0, 0.87)',
407
+ color:
408
+ theme?.colors?.inputTextColor || 'rgba(0, 0, 0, 0.87)',
210
409
  },
211
410
  ],
212
411
  theme?.fontFamily
@@ -216,7 +415,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
216
415
  }
217
416
  multiline
218
417
  textAlignVertical={
219
- inputHeight.isMultiline && inputText.length > 0 ? 'top' : 'center'
418
+ inputHeight.isMultiline && inputText.length > 0
419
+ ? 'top'
420
+ : 'center'
220
421
  }
221
422
  onContentSizeChange={handleContentSizeChange}
222
423
  />
@@ -229,7 +430,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
229
430
  ) : (
230
431
  <PaperClipIcon
231
432
  style={inputBarIconStyle}
232
- color={theme?.colors?.inputsIconsColor || 'rgba(0,0,0,0.7)'}
433
+ color={
434
+ theme?.colors?.inputsIconsColor || 'rgba(0,0,0,0.7)'
435
+ }
233
436
  />
234
437
  )}
235
438
  </Pressable>
@@ -241,7 +444,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
241
444
  ) : (
242
445
  <CameraIcon
243
446
  style={inputBarIconStyle}
244
- color={theme?.colors?.inputsIconsColor || 'rgba(0,0,0,0.7)'}
447
+ color={
448
+ theme?.colors?.inputsIconsColor || 'rgba(0,0,0,0.7)'
449
+ }
245
450
  />
246
451
  )}
247
452
  </Pressable>
@@ -249,46 +454,75 @@ const ChatInput: React.FC<ChatInputProps> = ({
249
454
  </View>
250
455
  </View>
251
456
 
252
- <Pressable
253
- style={[
254
- tw`p-2 rounded-full bg-green-600 justify-center items-center`,
255
- {
256
- height: INPUT_BAR_SHELL_HEIGHT,
257
- width: INPUT_BAR_SHELL_HEIGHT,
258
- ...theme?.inputStyles?.sendButtonStyle,
259
- },
260
- ]}
261
- onPress={showSendButton ? handleSendMessage : onAudioRecordStart}
262
- onLongPress={onAudioRecordStart}
263
- onPressOut={onAudioRecordEnd}
264
- >
265
- {showSendButton ? (
266
- CustomSendIcon ? (
457
+ {/* ── Right action button (send / mic) ── */}
458
+ {showSendButton ? (
459
+ <Pressable
460
+ onPress={handleSendMessage}
461
+ style={[
462
+ tw`rounded-full justify-center items-center`,
463
+ {
464
+ height: INPUT_BAR_SHELL_HEIGHT,
465
+ width: INPUT_BAR_SHELL_HEIGHT,
466
+ backgroundColor: '#16a34a',
467
+ ...theme?.inputStyles?.sendButtonStyle,
468
+ },
469
+ ]}
470
+ >
471
+ {CustomSendIcon ? (
267
472
  <CustomSendIcon />
268
473
  ) : (
269
474
  <PaperPlaneIcon
270
475
  style={tw.style(SEND_ICON_CLASS)}
271
476
  color={theme?.colors?.sendIconsColor || 'rgba(255,255,255,0.7)'}
272
477
  />
273
- )
274
- ) : showVoiceRecordButton ? (
275
- CustomMicrophoneIcon ? (
478
+ )}
479
+ </Pressable>
480
+ ) : showVoiceRecordButton ? (
481
+ // Mic button uses PanResponder for tap + long-press + drag
482
+ <View
483
+ {...micPanResponder.panHandlers}
484
+ style={[
485
+ tw`rounded-full justify-center items-center`,
486
+ {
487
+ height: INPUT_BAR_SHELL_HEIGHT,
488
+ width: INPUT_BAR_SHELL_HEIGHT,
489
+ backgroundColor: '#16a34a',
490
+ ...theme?.inputStyles?.sendButtonStyle,
491
+ },
492
+ ]}
493
+ >
494
+ {CustomMicrophoneIcon ? (
276
495
  <CustomMicrophoneIcon />
277
496
  ) : (
278
497
  <MicrophoneIcon
279
498
  style={tw.style(MIC_ICON_CLASS)}
280
499
  color={theme?.colors?.sendIconsColor || 'rgba(255,255,255,0.7)'}
281
500
  />
282
- )
283
- ) : CustomSendIcon ? (
284
- <CustomSendIcon />
285
- ) : (
286
- <PaperPlaneIcon
287
- style={tw.style(SEND_ICON_CLASS)}
288
- color={theme?.colors?.sendIconsColor || 'rgba(255,255,255,0.7)'}
289
- />
290
- )}
291
- </Pressable>
501
+ )}
502
+ </View>
503
+ ) : (
504
+ <Pressable
505
+ onPress={handleSendMessage}
506
+ style={[
507
+ tw`rounded-full justify-center items-center`,
508
+ {
509
+ height: INPUT_BAR_SHELL_HEIGHT,
510
+ width: INPUT_BAR_SHELL_HEIGHT,
511
+ backgroundColor: '#16a34a',
512
+ ...theme?.inputStyles?.sendButtonStyle,
513
+ },
514
+ ]}
515
+ >
516
+ {CustomSendIcon ? (
517
+ <CustomSendIcon />
518
+ ) : (
519
+ <PaperPlaneIcon
520
+ style={tw.style(SEND_ICON_CLASS)}
521
+ color={theme?.colors?.sendIconsColor || 'rgba(255,255,255,0.7)'}
522
+ />
523
+ )}
524
+ </Pressable>
525
+ )}
292
526
  </View>
293
527
  </View>
294
528
  );
@@ -98,6 +98,9 @@ const FilePreview: React.FC<FilePreviewProps> = ({
98
98
  muted
99
99
  repeat
100
100
  paused={false}
101
+ pointerEvents="none"
102
+ playInBackground={false}
103
+ playWhenInactive={false}
101
104
  />
102
105
  </View>
103
106
  );
@@ -1,4 +1,4 @@
1
- import { Message } from '../../types';
1
+ import { Message, RecordingResult } from '../../types';
2
2
 
3
3
  export interface ChatInputProps {
4
4
  onSendMessage: (message: Omit<Message, 'id' | 'time' | 'status'>) => void;
@@ -7,7 +7,7 @@ export interface ChatInputProps {
7
7
  onAttachmentPress?: () => void;
8
8
  onCameraPress?: () => void;
9
9
  onAudioRecordStart?: () => void;
10
- onAudioRecordEnd?: () => void;
10
+ onAudioRecordEnd?: (audio?: RecordingResult) => void;
11
11
  placeholder?: string;
12
12
  previewData?: { uri: string; type: string; name: string };
13
13
  closePreview?: () => void;
@@ -26,23 +26,33 @@ export interface MediaViewerProps {
26
26
  const MediaViewer: React.FC<MediaViewerProps> = ({ gallery, onClose }) => {
27
27
  const { theme, setIsVideoPlaying } = useChatContext();
28
28
  const listRef = useRef<FlatList<MessageMediaItem>>(null);
29
- const [pageIndex, setPageIndex] = useState(0);
29
+ const initialIndex = gallery?.initialIndex ?? 0;
30
+ const [pageIndex, setPageIndex] = useState(initialIndex);
30
31
  const { width, height: windowHeight } = useWindowDimensions();
31
32
 
32
33
  useEffect(() => {
33
34
  if (!gallery?.items.length) return;
34
- setPageIndex(gallery.initialIndex);
35
+ const idx = gallery.initialIndex;
36
+ setPageIndex(idx);
37
+ const item = gallery.items[idx];
38
+ setIsVideoPlaying(item?.kind === 'video');
39
+
35
40
  requestAnimationFrame(() => {
36
41
  try {
37
42
  listRef.current?.scrollToIndex({
38
- index: gallery.initialIndex,
43
+ index: idx,
39
44
  animated: false,
40
45
  });
41
46
  } catch {
42
47
  /* layout not ready */
43
48
  }
44
49
  });
45
- }, [gallery?.initialIndex, gallery?.items]);
50
+ }, [gallery?.initialIndex, gallery?.items, setIsVideoPlaying]);
51
+
52
+ const handleClose = useCallback(() => {
53
+ setIsVideoPlaying(false);
54
+ onClose();
55
+ }, [onClose, setIsVideoPlaying]);
46
56
 
47
57
  const onMomentumScrollEnd = useCallback(
48
58
  (e: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -57,10 +67,16 @@ const MediaViewer: React.FC<MediaViewerProps> = ({ gallery, onClose }) => {
57
67
  if (!gallery || gallery.items.length === 0) return null;
58
68
 
59
69
  return (
60
- <Modal visible transparent animationType="fade" onRequestClose={onClose}>
70
+ <Modal
71
+ visible
72
+ transparent
73
+ animationType="fade"
74
+ onRequestClose={handleClose}
75
+ statusBarTranslucent
76
+ >
61
77
  <View style={[tw`flex-1 bg-black`, { width, height: windowHeight }]}>
62
78
  <Pressable
63
- onPress={onClose}
79
+ onPress={handleClose}
64
80
  style={tw`absolute right-4 top-12 z-20 p-2 rounded-full bg-slate-100/70`}
65
81
  >
66
82
  <XIcon style={tw`h-8 w-8`} />
@@ -87,6 +103,7 @@ const MediaViewer: React.FC<MediaViewerProps> = ({ gallery, onClose }) => {
87
103
  showsHorizontalScrollIndicator={false}
88
104
  keyExtractor={(item, i) => `${item.uri}-${i}`}
89
105
  initialScrollIndex={gallery.initialIndex}
106
+ extraData={pageIndex}
90
107
  getItemLayout={(_, index) => ({
91
108
  length: width,
92
109
  offset: width * index,
@@ -98,8 +115,14 @@ const MediaViewer: React.FC<MediaViewerProps> = ({ gallery, onClose }) => {
98
115
  listRef.current?.scrollToIndex({ index, animated: false });
99
116
  }, 100);
100
117
  }}
101
- renderItem={({ item }) => (
102
- <ViewerPage item={item} width={width} height={windowHeight} />
118
+ renderItem={({ item, index }) => (
119
+ <ViewerPage
120
+ item={item}
121
+ width={width}
122
+ height={windowHeight}
123
+ isActive={index === pageIndex}
124
+ autoPlayVideo={index === initialIndex && item.kind === 'video'}
125
+ />
103
126
  )}
104
127
  />
105
128
  </View>
@@ -111,12 +134,18 @@ const ViewerPage: React.FC<{
111
134
  item: MessageMediaItem;
112
135
  width: number;
113
136
  height: number;
114
- }> = ({ item, width, height }) => {
137
+ isActive: boolean;
138
+ /** Only true for the item the user tapped — not other pages in the album */
139
+ autoPlayVideo: boolean;
140
+ }> = ({ item, width, height, isActive, autoPlayVideo }) => {
115
141
  const { theme } = useChatContext();
116
142
  const videoRef = useRef<VideoRef>(null);
117
143
  const [loading, setLoading] = useState(item.kind === 'video');
118
144
  const [error, setError] = useState(false);
119
145
 
146
+ const shouldPlayVideo =
147
+ item.kind === 'video' && isActive && autoPlayVideo;
148
+
120
149
  if (item.kind === 'image') {
121
150
  return (
122
151
  <View style={{ width, height, justifyContent: 'center' }}>
@@ -143,7 +172,10 @@ const ViewerPage: React.FC<{
143
172
  source={{ uri: item.uri }}
144
173
  ref={videoRef}
145
174
  controls
175
+ paused={!shouldPlayVideo}
146
176
  shutterColor="transparent"
177
+ playInBackground={false}
178
+ playWhenInactive={false}
147
179
  style={{
148
180
  width: width - 32,
149
181
  height: height * 0.55,
@@ -162,7 +194,10 @@ const ViewerPage: React.FC<{
162
194
  }}
163
195
  />
164
196
  {loading && (
165
- <View style={tw`absolute inset-0 items-center justify-center`}>
197
+ <View
198
+ style={tw`absolute inset-0 items-center justify-center`}
199
+ pointerEvents="none"
200
+ >
166
201
  <LoadingIcon style={tw.style('h-14 w-14')} spinning />
167
202
  </View>
168
203
  )}