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
@@ -0,0 +1,195 @@
1
+ import React, { useEffect } from 'react';
2
+ import { Text, View } from 'react-native';
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withRepeat,
8
+ withSequence,
9
+ withTiming,
10
+ } from 'react-native-reanimated';
11
+ import { ChevronUpIcon } from '../../assets/Icons/ChevronUpIcon';
12
+ import { LockIcon } from '../../assets/Icons/LockIcon';
13
+ import { MicrophoneIcon } from '../../assets/Icons/MicrophoneIcon';
14
+ import { formatDuration } from '../../utils/datefunc';
15
+ import { RecordingUIProps, VoiceRecorderStyleOverrides } from '../../types';
16
+
17
+ interface LongPressRecordingProps {
18
+ duration: number;
19
+ /** Current horizontal drag offset (negative = sliding left to cancel) */
20
+ slideX: number;
21
+ containerHeight?: number;
22
+ fontFamily?: string;
23
+ voiceRecorderStyles?: VoiceRecorderStyleOverrides;
24
+ recordingUIProps?: RecordingUIProps;
25
+ }
26
+
27
+ export const LongPressRecording: React.FC<LongPressRecordingProps> = ({
28
+ duration,
29
+ slideX,
30
+ containerHeight = 50,
31
+ fontFamily,
32
+ voiceRecorderStyles,
33
+ recordingUIProps,
34
+ }) => {
35
+ const micPulseColor = recordingUIProps?.micPulseColor ?? '#ef4444';
36
+ const cancelTextColor = recordingUIProps?.cancelTextColor ?? '#6b7280';
37
+
38
+ // ── Mic breathing ─────────────────────────────────────────────────────────
39
+ const micScale = useSharedValue(1);
40
+ useEffect(() => {
41
+ micScale.value = withRepeat(
42
+ withSequence(
43
+ withTiming(1.28, { duration: 700, easing: Easing.inOut(Easing.ease) }),
44
+ withTiming(1, { duration: 700, easing: Easing.inOut(Easing.ease) })
45
+ ),
46
+ -1,
47
+ false
48
+ );
49
+ }, []);
50
+
51
+ // ── "Slide to cancel" text oscillation ───────────────────────────────────
52
+ const slideTextX = useSharedValue(0);
53
+ useEffect(() => {
54
+ slideTextX.value = withRepeat(
55
+ withSequence(
56
+ withTiming(-8, { duration: 600, easing: Easing.inOut(Easing.ease) }),
57
+ withTiming(0, { duration: 600, easing: Easing.inOut(Easing.ease) })
58
+ ),
59
+ -1,
60
+ false
61
+ );
62
+ }, []);
63
+
64
+ // ── Chevron bounce ────────────────────────────────────────────────────────
65
+ const chevronY = useSharedValue(0);
66
+ useEffect(() => {
67
+ chevronY.value = withRepeat(
68
+ withSequence(
69
+ withTiming(-5, { duration: 450, easing: Easing.inOut(Easing.ease) }),
70
+ withTiming(0, { duration: 450, easing: Easing.inOut(Easing.ease) })
71
+ ),
72
+ -1,
73
+ false
74
+ );
75
+ }, []);
76
+
77
+ // ── Lock open/close ───────────────────────────────────────────────────────
78
+ const lockScale = useSharedValue(0.8);
79
+ useEffect(() => {
80
+ lockScale.value = withRepeat(
81
+ withSequence(
82
+ withTiming(1, { duration: 550, easing: Easing.out(Easing.ease) }),
83
+ withTiming(0.8, { duration: 550, easing: Easing.in(Easing.ease) })
84
+ ),
85
+ -1,
86
+ false
87
+ );
88
+ }, []);
89
+
90
+ const micStyle = useAnimatedStyle(() => ({
91
+ transform: [{ scale: micScale.value }],
92
+ }));
93
+
94
+ // Fade + shift the "slide to cancel" text as user drags left
95
+ const cancelProgress = Math.min(1, Math.abs(Math.min(0, slideX)) / 70);
96
+
97
+ const slideTextStyle = useAnimatedStyle(() => ({
98
+ transform: [{ translateX: slideTextX.value }],
99
+ opacity: 1 - cancelProgress,
100
+ }));
101
+
102
+ const chevronStyle = useAnimatedStyle(() => ({
103
+ transform: [{ translateY: chevronY.value }],
104
+ }));
105
+
106
+ const lockStyle = useAnimatedStyle(() => ({
107
+ transform: [{ scale: lockScale.value }],
108
+ }));
109
+
110
+ const micSize = containerHeight * 0.5;
111
+
112
+ return (
113
+ <View
114
+ style={[
115
+ {
116
+ flexDirection: 'row',
117
+ alignItems: 'center',
118
+ height: containerHeight,
119
+ paddingHorizontal: 8,
120
+ },
121
+ voiceRecorderStyles?.container,
122
+ ]}
123
+ >
124
+ {/* ── Animated mic (breathing) ── */}
125
+ <Animated.View
126
+ style={[
127
+ micStyle,
128
+ {
129
+ width: containerHeight,
130
+ height: containerHeight,
131
+ borderRadius: containerHeight / 2,
132
+ backgroundColor: `${micPulseColor}22`,
133
+ justifyContent: 'center',
134
+ alignItems: 'center',
135
+ },
136
+ voiceRecorderStyles?.micButton,
137
+ ]}
138
+ >
139
+ <MicrophoneIcon
140
+ style={{ width: micSize, height: micSize }}
141
+ color={micPulseColor}
142
+ />
143
+ </Animated.View>
144
+
145
+ {/* ── Timer ── */}
146
+ <Text
147
+ style={[
148
+ {
149
+ fontSize: 15,
150
+ fontWeight: '600',
151
+ color: '#374151',
152
+ marginLeft: 8,
153
+ fontFamily,
154
+ },
155
+ voiceRecorderStyles?.timer,
156
+ ]}
157
+ >
158
+ {formatDuration(duration)}
159
+ </Text>
160
+
161
+ {/* ── "Slide to cancel" text ── */}
162
+ <Animated.View
163
+ style={[slideTextStyle, { flex: 1, alignItems: 'center' }]}
164
+ >
165
+ <Text
166
+ style={[
167
+ {
168
+ fontSize: 14,
169
+ color: cancelTextColor,
170
+ fontFamily,
171
+ },
172
+ voiceRecorderStyles?.slideText,
173
+ ]}
174
+ >
175
+ {'< Slide to cancel'}
176
+ </Text>
177
+ </Animated.View>
178
+
179
+ {/* ── Lock + Chevron column ── */}
180
+ <View
181
+ style={[
182
+ { alignItems: 'center', marginRight: 4 },
183
+ voiceRecorderStyles?.lockContainer,
184
+ ]}
185
+ >
186
+ <Animated.View style={lockStyle}>
187
+ <LockIcon style={{ width: 18, height: 18 }} color="#6b7280" />
188
+ </Animated.View>
189
+ <Animated.View style={chevronStyle}>
190
+ <ChevronUpIcon style={{ width: 18, height: 18 }} color="#6b7280" />
191
+ </Animated.View>
192
+ </View>
193
+ </View>
194
+ );
195
+ };
@@ -0,0 +1,156 @@
1
+ import React from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+ import { PaperPlaneIcon } from '../../assets/Icons/PaperPlaneIcon';
4
+ import { PauseIcon } from '../../assets/Icons/PauseIcon';
5
+ import { PlayIcon } from '../../assets/Icons/PlayIcon';
6
+ import { TrashIcon } from '../../assets/Icons/TrashIcon';
7
+ import { formatDuration } from '../../utils/datefunc';
8
+ import {
9
+ RecordingUIProps,
10
+ VoiceRecorderStyleOverrides,
11
+ } from '../../types';
12
+ import { WaveformAnimation } from './WaveformAnimation';
13
+
14
+ interface NormalRecordingProps {
15
+ isRecording: boolean;
16
+ isPaused: boolean;
17
+ duration: number;
18
+ onCancel: () => void;
19
+ onSend: () => void;
20
+ onPause: () => void;
21
+ onResume: () => void;
22
+ containerHeight?: number;
23
+ fontFamily?: string;
24
+ sendButtonColor?: string;
25
+ sendIconColor?: string;
26
+ enablePauseResume?: boolean;
27
+ voiceRecorderStyles?: VoiceRecorderStyleOverrides;
28
+ recordingUIProps?: RecordingUIProps;
29
+ }
30
+
31
+ export const NormalRecording: React.FC<NormalRecordingProps> = ({
32
+ isRecording,
33
+ isPaused,
34
+ duration,
35
+ onCancel,
36
+ onSend,
37
+ onPause,
38
+ onResume,
39
+ containerHeight = 50,
40
+ fontFamily,
41
+ sendButtonColor = '#16a34a',
42
+ sendIconColor = '#ffffff',
43
+ enablePauseResume = true,
44
+ voiceRecorderStyles,
45
+ recordingUIProps,
46
+ }) => {
47
+ const waveColor = recordingUIProps?.waveformColor ?? 'rgba(0,0,0,0.45)';
48
+ const cancelColor = recordingUIProps?.cancelTextColor ?? '#ef4444';
49
+ const timerColor = '#374151';
50
+ const bg = recordingUIProps?.recordingBackground ?? 'transparent';
51
+ const timerTextStyle = recordingUIProps?.timerTextStyle;
52
+
53
+ return (
54
+ <View
55
+ style={[
56
+ {
57
+ flexDirection: 'row',
58
+ alignItems: 'center',
59
+ height: containerHeight,
60
+ paddingHorizontal: 4,
61
+ backgroundColor: bg,
62
+ gap: 8,
63
+ },
64
+ voiceRecorderStyles?.container,
65
+ ]}
66
+ >
67
+ {/* ── Cancel / Trash ── */}
68
+ <Pressable
69
+ onPress={onCancel}
70
+ style={[
71
+ {
72
+ width: containerHeight,
73
+ height: containerHeight,
74
+ borderRadius: containerHeight / 2,
75
+ justifyContent: 'center',
76
+ alignItems: 'center',
77
+ backgroundColor: `${cancelColor}18`,
78
+ },
79
+ voiceRecorderStyles?.trashButton,
80
+ ]}
81
+ hitSlop={6}
82
+ >
83
+ <TrashIcon
84
+ style={{ width: containerHeight * 0.44, height: containerHeight * 0.44 }}
85
+ color={cancelColor}
86
+ />
87
+ </Pressable>
88
+
89
+ {/* ── Timer ── */}
90
+ <Text
91
+ style={[
92
+ {
93
+ fontSize: 15,
94
+ fontWeight: '600',
95
+ color: timerColor,
96
+ minWidth: 40,
97
+ fontFamily,
98
+ },
99
+ voiceRecorderStyles?.timer,
100
+ timerTextStyle,
101
+ ]}
102
+ >
103
+ {formatDuration(duration)}
104
+ </Text>
105
+
106
+ {/* ── Waveform ── */}
107
+ <WaveformAnimation
108
+ isActive={isRecording && !isPaused}
109
+ color={waveColor}
110
+ height={Math.round(containerHeight * 0.52)}
111
+ style={[{ flex: 1 }, voiceRecorderStyles?.waveform]}
112
+ />
113
+
114
+ {/* ── Pause / Resume ── */}
115
+ {enablePauseResume && (
116
+ <Pressable
117
+ onPress={isPaused ? onResume : onPause}
118
+ style={{
119
+ width: 36,
120
+ height: 36,
121
+ borderRadius: 18,
122
+ backgroundColor: 'rgba(0,0,0,0.08)',
123
+ justifyContent: 'center',
124
+ alignItems: 'center',
125
+ }}
126
+ hitSlop={6}
127
+ >
128
+ {isPaused ? (
129
+ <PlayIcon style={{ width: 18, height: 18 }} color="#374151" />
130
+ ) : (
131
+ <PauseIcon style={{ width: 18, height: 18 }} color="#374151" />
132
+ )}
133
+ </Pressable>
134
+ )}
135
+
136
+ {/* ── Send ── */}
137
+ <Pressable
138
+ onPress={onSend}
139
+ style={{
140
+ width: containerHeight,
141
+ height: containerHeight,
142
+ borderRadius: containerHeight / 2,
143
+ backgroundColor: sendButtonColor,
144
+ justifyContent: 'center',
145
+ alignItems: 'center',
146
+ }}
147
+ hitSlop={4}
148
+ >
149
+ <PaperPlaneIcon
150
+ style={{ width: containerHeight * 0.44, height: containerHeight * 0.44 }}
151
+ color={sendIconColor}
152
+ />
153
+ </Pressable>
154
+ </View>
155
+ );
156
+ };
@@ -0,0 +1,56 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { StyleProp, View, ViewStyle } from 'react-native';
3
+
4
+ const BAR_COUNT = 22;
5
+
6
+ function randomBars(): number[] {
7
+ return Array.from({ length: BAR_COUNT }, () => 0.15 + Math.random() * 0.85);
8
+ }
9
+
10
+ interface WaveformAnimationProps {
11
+ isActive: boolean;
12
+ color?: string;
13
+ height?: number;
14
+ style?: StyleProp<ViewStyle>;
15
+ }
16
+
17
+ export const WaveformAnimation: React.FC<WaveformAnimationProps> = ({
18
+ isActive,
19
+ color = 'rgba(0,0,0,0.45)',
20
+ height = 26,
21
+ style,
22
+ }) => {
23
+ const [bars, setBars] = useState<number[]>(() =>
24
+ Array.from({ length: BAR_COUNT }, () => 0.3)
25
+ );
26
+
27
+ useEffect(() => {
28
+ if (!isActive) {
29
+ setBars(Array.from({ length: BAR_COUNT }, () => 0.3));
30
+ return;
31
+ }
32
+ const id = setInterval(() => setBars(randomBars()), 110);
33
+ return () => clearInterval(id);
34
+ }, [isActive]);
35
+
36
+ return (
37
+ <View
38
+ style={[
39
+ { flexDirection: 'row', alignItems: 'center', height, gap: 2 },
40
+ style,
41
+ ]}
42
+ >
43
+ {bars.map((amp, i) => (
44
+ <View
45
+ key={i}
46
+ style={{
47
+ flex: 1,
48
+ height: Math.max(3, Math.round(amp * height)),
49
+ borderRadius: 2,
50
+ backgroundColor: color,
51
+ }}
52
+ />
53
+ ))}
54
+ </View>
55
+ );
56
+ };
@@ -0,0 +1,206 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { RecordingResult } from '../types';
3
+
4
+ export type RecordingStatus = 'idle' | 'recording' | 'paused';
5
+
6
+ interface UseVoiceRecorderOptions {
7
+ maxDuration?: number;
8
+ onRecordStart?: () => void;
9
+ onRecordEnd?: (result: RecordingResult) => void;
10
+ }
11
+
12
+ export function useVoiceRecorder({
13
+ maxDuration = 300,
14
+ onRecordStart,
15
+ onRecordEnd,
16
+ }: UseVoiceRecorderOptions = {}) {
17
+ const [status, setStatus] = useState<RecordingStatus>('idle');
18
+ const [duration, setDuration] = useState(0);
19
+
20
+ const recordingRef = useRef<any>(null);
21
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
22
+ const durationRef = useRef(0);
23
+
24
+ // Keep callback refs stable so the PanResponder (created once) always calls the latest version
25
+ const onRecordEndRef = useRef(onRecordEnd);
26
+ onRecordEndRef.current = onRecordEnd;
27
+ const onRecordStartRef = useRef(onRecordStart);
28
+ onRecordStartRef.current = onRecordStart;
29
+ const maxDurationRef = useRef(maxDuration);
30
+ maxDurationRef.current = maxDuration;
31
+
32
+ const stopTimer = useCallback(() => {
33
+ if (timerRef.current) {
34
+ clearInterval(timerRef.current);
35
+ timerRef.current = null;
36
+ }
37
+ }, []);
38
+
39
+ const startTimer = useCallback(
40
+ (onMaxDuration: () => void) => {
41
+ stopTimer();
42
+ timerRef.current = setInterval(() => {
43
+ durationRef.current += 1;
44
+ setDuration(durationRef.current);
45
+ if (durationRef.current >= maxDurationRef.current) {
46
+ onMaxDuration();
47
+ }
48
+ }, 1000);
49
+ },
50
+ [stopTimer]
51
+ );
52
+
53
+ const startRecording = useCallback(async () => {
54
+ let Audio: any;
55
+ try {
56
+ Audio = require('expo-av').Audio;
57
+ } catch {
58
+ console.error(
59
+ '[movius-chats] Voice recording requires expo-av. ' +
60
+ 'Install it with: bun add expo-av'
61
+ );
62
+ return;
63
+ }
64
+
65
+ try {
66
+ const { status: permStatus } = await Audio.requestPermissionsAsync();
67
+ if (permStatus !== 'granted') {
68
+ console.warn('[movius-chats] Microphone permission denied.');
69
+ return;
70
+ }
71
+
72
+ await Audio.setAudioModeAsync({
73
+ allowsRecordingIOS: true,
74
+ playsInSilentModeIOS: true,
75
+ });
76
+
77
+ const recording = new Audio.Recording();
78
+ await recording.prepareToRecordAsync(
79
+ Audio.RecordingOptionsPresets.HIGH_QUALITY
80
+ );
81
+ await recording.startAsync();
82
+
83
+ recordingRef.current = recording;
84
+ durationRef.current = 0;
85
+ setDuration(0);
86
+ setStatus('recording');
87
+
88
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
89
+ startTimer(() => stopRecording());
90
+
91
+ onRecordStartRef.current?.();
92
+ } catch (e) {
93
+ console.warn('[movius-chats] Failed to start recording:', e);
94
+ }
95
+ }, [startTimer]); // stopRecording defined below — used via ref
96
+
97
+ const pauseRecording = useCallback(async () => {
98
+ if (!recordingRef.current) return;
99
+ try {
100
+ await recordingRef.current.pauseAsync();
101
+ setStatus('paused');
102
+ stopTimer();
103
+ } catch (e) {
104
+ console.warn('[movius-chats] Failed to pause:', e);
105
+ }
106
+ }, [stopTimer]);
107
+
108
+ const resumeRecording = useCallback(async () => {
109
+ if (!recordingRef.current) return;
110
+ try {
111
+ await recordingRef.current.startAsync();
112
+ setStatus('recording');
113
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
114
+ startTimer(() => stopRecording());
115
+ } catch (e) {
116
+ console.warn('[movius-chats] Failed to resume:', e);
117
+ }
118
+ }, [startTimer]);
119
+
120
+ // Exposed as a stable ref so the timer callback (closed at creation) can call it
121
+ const stopRecordingImpl = useCallback(async (): Promise<RecordingResult | null> => {
122
+ const rec = recordingRef.current;
123
+ if (!rec) return null;
124
+
125
+ stopTimer();
126
+ recordingRef.current = null;
127
+ const capturedDuration = durationRef.current;
128
+ durationRef.current = 0;
129
+ setStatus('idle');
130
+ setDuration(0);
131
+
132
+ try {
133
+ await rec.stopAndUnloadAsync();
134
+ const uri = rec.getURI() as string | null;
135
+ if (!uri) return null;
136
+
137
+ let durMs = capturedDuration * 1000;
138
+ try {
139
+ const st = await rec.getStatusAsync();
140
+ if (st?.durationMillis) durMs = st.durationMillis;
141
+ } catch {}
142
+
143
+ const result: RecordingResult = {
144
+ uri,
145
+ duration: Math.max(1, Math.round(durMs / 1000)),
146
+ mimeType: 'audio/m4a',
147
+ };
148
+
149
+ onRecordEndRef.current?.(result);
150
+ return result;
151
+ } catch (e) {
152
+ console.warn('[movius-chats] Failed to stop recording:', e);
153
+ return null;
154
+ }
155
+ }, [stopTimer]);
156
+
157
+ // Stable ref so the timer closure can call the latest implementation
158
+ const stopRecordingRef = useRef(stopRecordingImpl);
159
+ stopRecordingRef.current = stopRecordingImpl;
160
+
161
+ const stopRecording = useCallback(
162
+ () => stopRecordingRef.current(),
163
+ []
164
+ );
165
+
166
+ const cancelRecording = useCallback(async () => {
167
+ const rec = recordingRef.current;
168
+ stopTimer();
169
+ recordingRef.current = null;
170
+ durationRef.current = 0;
171
+ setStatus('idle');
172
+ setDuration(0);
173
+
174
+ if (rec) {
175
+ try {
176
+ await rec.stopAndUnloadAsync();
177
+ const uri = rec.getURI() as string | null;
178
+ if (uri) {
179
+ try {
180
+ const { deleteAsync } = require('expo-file-system');
181
+ await deleteAsync(uri, { idempotent: true });
182
+ } catch {}
183
+ }
184
+ } catch {}
185
+ }
186
+ }, [stopTimer]);
187
+
188
+ useEffect(() => {
189
+ return () => {
190
+ stopTimer();
191
+ recordingRef.current?.stopAndUnloadAsync().catch(() => {});
192
+ };
193
+ }, [stopTimer]);
194
+
195
+ return {
196
+ status,
197
+ duration,
198
+ isRecording: status === 'recording',
199
+ isPaused: status === 'paused',
200
+ startRecording,
201
+ pauseRecording,
202
+ resumeRecording,
203
+ stopRecording,
204
+ cancelRecording,
205
+ };
206
+ }