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,34 +1,22 @@
1
- import React, { useEffect, useRef, useState } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Image, PanResponder, Pressable, Text, View } from 'react-native';
3
- import Animated, {
4
- useAnimatedStyle,
5
- useSharedValue,
6
- withSpring,
7
- } from 'react-native-reanimated';
8
3
 
9
4
  // ─── New-Architecture compatibility shim ─────────────────────────────────────
10
- // react-native-sound imports resolveAssetSource via the old internal path.
11
- // On New Architecture that path returns an Object, not a function, causing:
12
- // TypeError: resolveAssetSource is not a function (it is Object)
13
- // If the module is already cached as a non-function we swap in Image.resolveAssetSource
14
- // so that the Sound constructor doesn't throw.
15
- // The movius-chats postinstall script permanently patches Sound.js on `npm install`;
16
- // this runtime shim is a second safety net for apps that haven't reinstalled yet.
5
+ // react-native-sound imports resolveAssetSource the old way; on New Architecture
6
+ // that path returns an Object not a function → "resolveAssetSource is not a function".
7
+ // We patch the cached module here as a runtime safety net.
8
+ // The movius-chats postinstall script applies the permanent file-level fix.
17
9
  try {
18
10
  const ras = require('react-native/Libraries/Image/resolveAssetSource');
19
11
  if (typeof ras !== 'function') {
20
12
  const fn = Image.resolveAssetSource.bind(Image);
21
- // Overwrite every exported key so any destructure or default-import also works.
22
13
  Object.keys(ras).forEach((k) => {
23
14
  try { (ras as any)[k] = (fn as any)[k]; } catch {}
24
15
  });
25
- // Copy the function's own properties onto the object so calling it works too.
26
16
  Object.defineProperty(ras, '__esModule', { value: false, configurable: true });
27
- // Make the object itself callable — not possible in JS, but we expose a helper
28
- // via the global that react-native-sound can fall back to if it checks typeof.
29
17
  (global as any).__moviusRAS = fn;
30
18
  }
31
- } catch { /* module may not exist on some RN versions — safe to skip */ }
19
+ } catch { /* safe to skip */ }
32
20
 
33
21
  import Sound from 'react-native-sound';
34
22
  import tw from 'twrnc';
@@ -40,25 +28,67 @@ import { formatDuration } from '../../utils/datefunc';
40
28
  import { withFontFamily } from '../../utils/theme';
41
29
  import { AudioPlayerProps } from './types';
42
30
 
31
+ // ─── Waveform generator ───────────────────────────────────────────────────────
32
+ // Generates a deterministic pseudo-random bar-height array from the audio URL
33
+ // so the same message always shows the same waveform shape.
34
+ const WAVEFORM_BARS = 34;
35
+ const WAVEFORM_H = 34; // total container height in px
36
+
37
+ function generateWaveform(url: string, count: number): number[] {
38
+ // djb2 hash seeded from URL
39
+ let h = 5381;
40
+ for (let i = 0; i < url.length; i++) {
41
+ h = ((h << 5) + h + url.charCodeAt(i)) | 0;
42
+ }
43
+ return Array.from({ length: count }, (_, i) => {
44
+ // Mix the index in so adjacent bars differ
45
+ h = (Math.imul(h ^ (h >>> 16), 0x45d9f3b + i * 31337)) | 0;
46
+ h = h ^ (h >>> 13);
47
+ const raw = Math.abs(h) % 100;
48
+ // Shape: bias toward mid heights (more natural look)
49
+ // minimum 18 % so very short bars still show
50
+ return 0.18 + (raw / 100) * 0.82;
51
+ });
52
+ }
53
+
54
+ // ─── Component ───────────────────────────────────────────────────────────────
43
55
  const AudioPlayer: React.FC<AudioPlayerProps> = ({
44
56
  audioUrl,
45
57
  audioId,
46
58
  isVideoPlaying,
59
+ isCurrentUser,
47
60
  }) => {
48
61
  const { theme, CustomPlayIcon, CustomPauseIcon } = useChatContext();
49
62
  const { currentlyPlayingId, setCurrentlyPlayingId } = useAudio();
63
+
50
64
  const [sound, setSound] = useState<Sound | null>(null);
51
65
  const [isPlaying, setIsPlaying] = useState(false);
52
66
  const [currentTime, setCurrentTime] = useState(0);
53
67
  const [duration, setDuration] = useState(0);
54
68
  const [isDragging, setIsDragging] = useState(false);
55
- const progressRef = useRef<View>(null);
56
- const progressWidth = useRef(0);
57
- const progressX = useRef(0);
58
- const startX = useRef(0);
59
- const knobPosition = useSharedValue(0);
60
69
 
61
- // Initialize sound
70
+ const waveformWidth = useRef(0);
71
+ const soundRef = useRef<Sound | null>(null);
72
+
73
+ // Pre-compute waveform shape once per URL
74
+ const waveform = useMemo(() => generateWaveform(audioUrl, WAVEFORM_BARS), [audioUrl]);
75
+
76
+ // ── Resolved colors (sent vs received defaults) ─────────────────────────
77
+ const inactiveBarColor =
78
+ theme?.colors?.audioWaveformColor ??
79
+ (isCurrentUser ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.20)');
80
+
81
+ const activeBarColor =
82
+ theme?.colors?.audioWaveformActiveColor ??
83
+ (isCurrentUser ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.60)');
84
+
85
+ const timestampColor =
86
+ (isCurrentUser
87
+ ? theme?.colors?.sentAudioTimestampColor
88
+ : theme?.colors?.receivedAudioTimestampColor) ??
89
+ (isCurrentUser ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.45)');
90
+
91
+ // ── Initialize sound ────────────────────────────────────────────────────
62
92
  useEffect(() => {
63
93
  let mounted = true;
64
94
  let newSound: Sound | null = null;
@@ -70,111 +100,83 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
70
100
  }
71
101
  });
72
102
  setSound(newSound);
73
- } catch (e) {
103
+ soundRef.current = newSound;
104
+ } catch {
74
105
  console.warn(
75
- '[movius-chats] AudioPlayer: Could not initialize react-native-sound.\n' +
76
- 'Run `npx expo run:android` (or ios) after a fresh install to apply ' +
77
- 'the resolveAssetSource compatibility patch automatically.'
106
+ '[movius-chats] AudioPlayer: Could not initialize react-native-sound. ' +
107
+ 'Reinstall the package to apply the resolveAssetSource patch automatically.'
78
108
  );
79
109
  }
80
110
 
81
111
  return () => {
82
112
  mounted = false;
83
- if (newSound) {
84
- newSound.pause();
85
- newSound.release();
86
- }
113
+ newSound?.pause();
114
+ newSound?.release();
115
+ soundRef.current = null;
87
116
  };
88
117
  }, [audioUrl]);
89
118
 
90
- // Handle stopping playback when another audio starts
119
+ // ── Stop when another audio/video starts ────────────────────────────────
91
120
  useEffect(() => {
92
- if (
93
- currentlyPlayingId &&
94
- currentlyPlayingId !== audioId &&
95
- isPlaying &&
96
- sound
97
- ) {
121
+ if (currentlyPlayingId && currentlyPlayingId !== audioId && isPlaying && sound) {
98
122
  sound.pause();
99
123
  setIsPlaying(false);
100
124
  setCurrentTime(0);
101
- knobPosition.value = 0;
102
125
  }
103
126
  }, [currentlyPlayingId, audioId, isPlaying, sound]);
104
127
 
105
- // Update progress
128
+ useEffect(() => {
129
+ if (isVideoPlaying && isPlaying && sound) {
130
+ sound.pause(() => {
131
+ setIsPlaying(false);
132
+ setCurrentlyPlayingId(null);
133
+ });
134
+ }
135
+ }, [isVideoPlaying]);
136
+
137
+ // ── Progress polling ─────────────────────────────────────────────────────
106
138
  useEffect(() => {
107
139
  let interval: ReturnType<typeof setInterval>;
108
140
  if (isPlaying && sound && !isDragging) {
109
141
  interval = setInterval(() => {
110
- sound.getCurrentTime((seconds) => {
111
- if (typeof seconds === 'number' && !isNaN(seconds)) {
112
- setCurrentTime(seconds);
113
- if (progressWidth.current > 0 && duration > 0) {
114
- const progress = (seconds / duration) * progressWidth.current;
115
- if (!isNaN(progress)) {
116
- knobPosition.value = withSpring(progress, {
117
- damping: 15,
118
- stiffness: 100,
119
- });
120
- }
121
- }
142
+ sound.getCurrentTime((sec) => {
143
+ if (typeof sec === 'number' && !isNaN(sec)) {
144
+ setCurrentTime(sec);
122
145
  }
123
146
  });
124
- }, 100);
147
+ }, 80);
125
148
  }
126
- return () => {
127
- if (interval) clearInterval(interval);
128
- };
149
+ return () => { if (interval) clearInterval(interval); };
129
150
  }, [isPlaying, sound, isDragging, duration]);
130
151
 
152
+ // ── Seek helper ──────────────────────────────────────────────────────────
153
+ const seekTo = (x: number) => {
154
+ const w = waveformWidth.current;
155
+ if (w <= 0 || duration <= 0) return;
156
+ const ratio = Math.max(0, Math.min(x / w, 1));
157
+ const t = ratio * duration;
158
+ setCurrentTime(t);
159
+ soundRef.current?.setCurrentTime(t);
160
+ };
161
+
162
+ // ── PanResponder on waveform ─────────────────────────────────────────────
131
163
  const panResponder = PanResponder.create({
132
164
  onStartShouldSetPanResponder: () => true,
133
165
  onMoveShouldSetPanResponder: () => true,
134
166
  onPanResponderGrant: (evt) => {
135
167
  setIsDragging(true);
136
- startX.current = evt.nativeEvent.pageX - knobPosition.value;
168
+ seekTo(evt.nativeEvent.locationX);
137
169
  },
138
170
  onPanResponderMove: (evt) => {
139
- if (progressWidth.current > 0) {
140
- const newPosition = evt.nativeEvent.pageX - startX.current;
141
- const boundedPosition = Math.max(
142
- 0,
143
- Math.min(newPosition, progressWidth.current)
144
- );
145
- knobPosition.value = boundedPosition;
146
-
147
- const percentage = boundedPosition / progressWidth.current;
148
- const newTime = percentage * duration;
149
- if (!isNaN(newTime)) {
150
- setCurrentTime(newTime);
151
- }
152
- }
153
- },
154
- onPanResponderRelease: () => {
155
- setIsDragging(false);
156
- if (sound && progressWidth.current > 0) {
157
- const percentage = knobPosition.value / progressWidth.current;
158
- const newTime = percentage * duration;
159
- if (!isNaN(newTime)) {
160
- sound.setCurrentTime(newTime);
161
- }
162
- }
163
- },
164
- onPanResponderTerminate: () => {
165
- setIsDragging(false);
171
+ seekTo(evt.nativeEvent.locationX);
166
172
  },
173
+ onPanResponderRelease: () => { setIsDragging(false); },
174
+ onPanResponderTerminate: () => { setIsDragging(false); },
167
175
  });
168
176
 
169
- const animatedStyle = useAnimatedStyle(() => {
170
- return {
171
- transform: [{ translateX: knobPosition.value }],
172
- };
173
- });
174
-
177
+ // ── Play / pause ─────────────────────────────────────────────────────────
175
178
  const togglePlay = () => {
176
179
  if (!sound) return;
177
-
178
180
  if (isPlaying) {
179
181
  sound.pause(() => {
180
182
  setIsPlaying(false);
@@ -186,7 +188,6 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
186
188
  if (success) {
187
189
  setIsPlaying(false);
188
190
  setCurrentTime(0);
189
- knobPosition.value = withSpring(0);
190
191
  setCurrentlyPlayingId(null);
191
192
  }
192
193
  });
@@ -194,103 +195,86 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
194
195
  }
195
196
  };
196
197
 
197
- // Stop audio when video starts playing
198
- useEffect(() => {
199
- if (isVideoPlaying && isPlaying && sound) {
200
- sound.pause(() => {
201
- setIsPlaying(false);
202
- setCurrentlyPlayingId(null);
203
- });
204
- }
205
- }, [isVideoPlaying]);
198
+ // ── Progress ratio ───────────────────────────────────────────────────────
199
+ const progress = duration > 0 ? currentTime / duration : 0;
206
200
 
207
201
  return (
208
- <View style={tw`rounded-lg w-56`}>
209
- <View style={tw`flex-row items-center gap-2 px-2 pt-2`}>
202
+ <View style={tw`rounded-lg w-60`}>
203
+ <View style={tw`flex-row items-center gap-2 px-2 pt-2 pb-1`}>
204
+ {/* Play / Pause button */}
210
205
  <Pressable
211
206
  onPress={togglePlay}
212
207
  style={[
213
- tw`bg-black/40 rounded-full p-2`,
208
+ tw`bg-black/35 rounded-full p-2 shrink-0`,
214
209
  theme?.messageStyle?.audioPlayButtonStyle,
215
210
  ]}
216
211
  >
217
212
  {isPlaying ? (
218
- CustomPauseIcon ? (
219
- <CustomPauseIcon />
220
- ) : (
213
+ CustomPauseIcon ? <CustomPauseIcon /> : (
221
214
  <PauseIcon
222
- style={tw.style('h-6 w-6')}
215
+ style={tw.style('h-5 w-5')}
223
216
  color={theme?.colors?.audioPauseIconColor || 'white'}
224
217
  />
225
218
  )
226
- ) : CustomPlayIcon ? (
227
- <CustomPlayIcon />
228
- ) : (
219
+ ) : CustomPlayIcon ? <CustomPlayIcon /> : (
229
220
  <PlayIcon
230
- style={tw.style('h-6 w-6')}
221
+ style={tw.style('h-5 w-5')}
231
222
  color={theme?.colors?.audioPlayIconColor || 'white'}
232
223
  />
233
224
  )}
234
225
  </Pressable>
235
226
 
236
- <View
237
- ref={progressRef}
238
- onLayout={(e) => {
239
- const { width } = e.nativeEvent.layout;
240
- progressWidth.current = width;
241
- progressRef.current?.measure((_, __, ___, ____, pageX) => {
242
- progressX.current = pageX;
243
- });
244
- }}
245
- style={[
246
- tw`relative h-1 bg-zinc-400 rounded overflow-visible w-[75%]`,
247
- theme?.messageStyle?.progressBarStyle,
248
- ]}
249
- >
227
+ {/* Waveform + timestamp column */}
228
+ <View style={{ flex: 1 }}>
229
+ {/* Waveform bars */}
250
230
  <View
251
231
  style={[
252
- tw`absolute h-full bg-slate-200`,
253
232
  {
254
- width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
233
+ height: WAVEFORM_H,
234
+ flexDirection: 'row',
235
+ alignItems: 'flex-end',
255
236
  },
256
- theme?.messageStyle?.activeProgressBarStyle,
237
+ theme?.messageStyle?.progressBarStyle,
257
238
  ]}
258
- />
259
- <Animated.View
239
+ onLayout={(e) => { waveformWidth.current = e.nativeEvent.layout.width; }}
260
240
  {...panResponder.panHandlers}
261
- style={[
262
- animatedStyle,
263
- {
264
- position: 'absolute',
265
- top: -6,
266
- width: 16,
267
- height: 16,
268
- borderRadius: 8,
269
- backgroundColor: 'white',
270
- shadowColor: '#000',
271
- shadowOffset: { width: 0, height: 2 },
272
- shadowOpacity: 0.25,
273
- shadowRadius: 3.84,
274
- elevation: 5,
275
- },
276
- { ...theme?.messageStyle?.audioKnobStyle },
277
- ]}
278
- />
241
+ >
242
+ {waveform.map((amp, i) => {
243
+ const barProgress = (i + 0.5) / WAVEFORM_BARS;
244
+ const active = barProgress <= progress;
245
+ return (
246
+ <View
247
+ key={i}
248
+ style={[
249
+ {
250
+ flex: 1,
251
+ marginHorizontal: 1,
252
+ height: Math.max(3, Math.round(amp * WAVEFORM_H)),
253
+ borderRadius: 2,
254
+ backgroundColor: active ? activeBarColor : inactiveBarColor,
255
+ },
256
+ active ? theme?.messageStyle?.activeProgressBarStyle : undefined,
257
+ ]}
258
+ />
259
+ );
260
+ })}
261
+ </View>
262
+
263
+ {/* Duration */}
264
+ <Text
265
+ style={withFontFamily(
266
+ [
267
+ tw`text-[10px] mt-0.5`,
268
+ { color: timestampColor },
269
+ theme?.messageStyle?.audioDurationStyle,
270
+ ],
271
+ theme?.fontFamily
272
+ )}
273
+ >
274
+ {!isNaN(currentTime) ? formatDuration(currentTime) : '0:00'}
275
+ </Text>
279
276
  </View>
280
277
  </View>
281
- <View style={tw`px-4 py-1`}>
282
- <Text
283
- style={withFontFamily(
284
- [
285
- tw`text-xs text-gray-500`,
286
- theme?.messageStyle?.audioDurationStyle,
287
- ],
288
- theme?.fontFamily
289
- )}
290
- >
291
- {!isNaN(currentTime) ? formatDuration(currentTime) : '0:00'}
292
- </Text>
293
- </View>
294
278
  </View>
295
279
  );
296
280
  };
@@ -2,4 +2,5 @@ export interface AudioPlayerProps {
2
2
  audioUrl: string;
3
3
  audioId: string;
4
4
  isVideoPlaying: boolean;
5
+ isCurrentUser: boolean;
5
6
  }
@@ -42,12 +42,15 @@ const VideoThumbCell: React.FC<{
42
42
  const [error, setError] = React.useState(false);
43
43
 
44
44
  return (
45
- <View style={[cellStyle, roundedStyle]}>
45
+ <View style={[cellStyle, roundedStyle]} pointerEvents="none">
46
46
  <Video
47
47
  source={{ uri }}
48
48
  ref={videoRef}
49
49
  paused
50
50
  muted
51
+ playInBackground={false}
52
+ playWhenInactive={false}
53
+ pointerEvents="none"
51
54
  style={[roundedStyle, { width: '100%', height: '100%' }]}
52
55
  resizeMode="cover"
53
56
  onLoadStart={() => {
@@ -63,6 +63,7 @@ const MessageContent: React.FC<MessageContentProps> = ({
63
63
  audioUrl={message.audio}
64
64
  audioId={message.id}
65
65
  isVideoPlaying={isVideoPlaying as boolean}
66
+ isCurrentUser={isCurrentUser}
66
67
  />
67
68
  </View>
68
69
  )}