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.
- package/lib/commonjs/index.js +5 -3
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/index.js +5 -3
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/assets/Icons/ChevronUpIcon.d.ts +5 -0
- package/lib/typescript/assets/Icons/LockIcon.d.ts +5 -0
- package/lib/typescript/assets/Icons/TrashIcon.d.ts +5 -0
- package/lib/typescript/components/AudioPlayer/types.d.ts +1 -0
- package/lib/typescript/components/ChatInput/types.d.ts +2 -2
- package/lib/typescript/components/VoiceRecorder/LongPressRecording.d.ts +13 -0
- package/lib/typescript/components/VoiceRecorder/NormalRecording.d.ts +20 -0
- package/lib/typescript/components/VoiceRecorder/WaveformAnimation.d.ts +10 -0
- package/lib/typescript/hooks/useVoiceRecorder.d.ts +19 -0
- package/lib/typescript/types/index.d.ts +74 -1
- package/package.json +12 -2
- package/scripts/patchSound.js +48 -23
- package/src/assets/Icons/ChevronUpIcon.tsx +20 -0
- package/src/assets/Icons/LockIcon.tsx +28 -0
- package/src/assets/Icons/TrashIcon.tsx +26 -0
- package/src/components/AudioPlayer/AudioPlayer.tsx +147 -163
- package/src/components/AudioPlayer/types.ts +1 -0
- package/src/components/ChatBubble/MediaGrid.tsx +4 -1
- package/src/components/ChatBubble/MessageContent.tsx +1 -0
- package/src/components/ChatInput/ChatInput.tsx +296 -62
- package/src/components/ChatInput/FilePreview.tsx +3 -0
- package/src/components/ChatInput/types.ts +2 -2
- package/src/components/MediaViewer/MediaViewer.tsx +45 -10
- package/src/components/VoiceRecorder/LongPressRecording.tsx +195 -0
- package/src/components/VoiceRecorder/NormalRecording.tsx +156 -0
- package/src/components/VoiceRecorder/WaveformAnimation.tsx +56 -0
- package/src/hooks/useVoiceRecorder.ts +206 -0
- package/src/types/index.ts +80 -1
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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:
|
|
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
|
|
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={
|
|
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={
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
{
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
);
|
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
)}
|