@ethora/chat-component-rn 26.5.11 → 26.5.13

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 (154) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -0
  3. package/lib/commonjs/assets/icons.js +7 -1
  4. package/lib/commonjs/assets/icons.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatRoom.js +58 -22
  6. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/MediaMessage.js +288 -5
  8. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/ReduxWrapper.js +24 -15
  10. package/lib/commonjs/components/MainComponents/ReduxWrapper.js.map +1 -1
  11. package/lib/commonjs/components/MessageBubble/MessageInteractions.js +23 -3
  12. package/lib/commonjs/components/MessageBubble/MessageInteractions.js.map +1 -1
  13. package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js +5 -44
  14. package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
  15. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +36 -3
  16. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  17. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js +149 -0
  18. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
  19. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js +4 -17
  20. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  21. package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js +19 -33
  22. package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
  23. package/lib/commonjs/components/styled/AudioMessage.js +643 -66
  24. package/lib/commonjs/components/styled/AudioMessage.js.map +1 -1
  25. package/lib/commonjs/components/styled/Button.js +9 -3
  26. package/lib/commonjs/components/styled/Button.js.map +1 -1
  27. package/lib/commonjs/components/styled/SendInput.js +239 -40
  28. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  29. package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js +31 -18
  30. package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
  31. package/lib/commonjs/components/styled/UnsupportedType.js +47 -5
  32. package/lib/commonjs/components/styled/UnsupportedType.js.map +1 -1
  33. package/lib/commonjs/context/xmppProvider.js +68 -2
  34. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  35. package/lib/commonjs/helpers/keyboardLayout.js +5 -3
  36. package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
  37. package/lib/commonjs/helpers/mimeToExtension.js +70 -3
  38. package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
  39. package/lib/commonjs/hooks/useEventHandlers.js +1 -1
  40. package/lib/commonjs/hooks/useEventHandlers.js.map +1 -1
  41. package/lib/commonjs/hooks/useSendMessage.js +6 -3
  42. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  43. package/lib/commonjs/networking/xmppClient.js +26 -1
  44. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  45. package/lib/commonjs/roomStore/Middleware/unreadMidlleware.js +10 -10
  46. package/lib/commonjs/roomStore/Middleware/unreadMidlleware.js.map +1 -1
  47. package/lib/commonjs/roomStore/roomsSlice.js +65 -4
  48. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  49. package/lib/module/assets/icons.js +7 -1
  50. package/lib/module/assets/icons.js.map +1 -1
  51. package/lib/module/components/MainComponents/ChatRoom.js +59 -23
  52. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  53. package/lib/module/components/MainComponents/MediaMessage.js +290 -7
  54. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  55. package/lib/module/components/MainComponents/ReduxWrapper.js +24 -15
  56. package/lib/module/components/MainComponents/ReduxWrapper.js.map +1 -1
  57. package/lib/module/components/MessageBubble/MessageInteractions.js +24 -4
  58. package/lib/module/components/MessageBubble/MessageInteractions.js.map +1 -1
  59. package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js +5 -45
  60. package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
  61. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +36 -3
  62. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  63. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js +144 -0
  64. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
  65. package/lib/module/components/Modals/FilePreviewModal/PdfView.js +3 -17
  66. package/lib/module/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  67. package/lib/module/components/RoomComponents/styled/LastAudioMessage.js +20 -34
  68. package/lib/module/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
  69. package/lib/module/components/styled/AudioMessage.js +644 -67
  70. package/lib/module/components/styled/AudioMessage.js.map +1 -1
  71. package/lib/module/components/styled/Button.js +9 -3
  72. package/lib/module/components/styled/Button.js.map +1 -1
  73. package/lib/module/components/styled/SendInput.js +240 -42
  74. package/lib/module/components/styled/SendInput.js.map +1 -1
  75. package/lib/module/components/styled/StyledInputComponents/MediaComponents.js +31 -18
  76. package/lib/module/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
  77. package/lib/module/components/styled/UnsupportedType.js +47 -5
  78. package/lib/module/components/styled/UnsupportedType.js.map +1 -1
  79. package/lib/module/context/xmppProvider.js +70 -4
  80. package/lib/module/context/xmppProvider.js.map +1 -1
  81. package/lib/module/helpers/keyboardLayout.js +4 -2
  82. package/lib/module/helpers/keyboardLayout.js.map +1 -1
  83. package/lib/module/helpers/mimeToExtension.js +67 -3
  84. package/lib/module/helpers/mimeToExtension.js.map +1 -1
  85. package/lib/module/hooks/useEventHandlers.js +1 -1
  86. package/lib/module/hooks/useEventHandlers.js.map +1 -1
  87. package/lib/module/hooks/useSendMessage.js +6 -3
  88. package/lib/module/hooks/useSendMessage.js.map +1 -1
  89. package/lib/module/networking/xmppClient.js +26 -1
  90. package/lib/module/networking/xmppClient.js.map +1 -1
  91. package/lib/module/roomStore/Middleware/unreadMidlleware.js +10 -10
  92. package/lib/module/roomStore/Middleware/unreadMidlleware.js.map +1 -1
  93. package/lib/module/roomStore/roomsSlice.js +63 -3
  94. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  95. package/lib/typescript/assets/icons.d.ts.map +1 -1
  96. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  97. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  98. package/lib/typescript/components/MainComponents/ReduxWrapper.d.ts.map +1 -1
  99. package/lib/typescript/components/MessageBubble/MessageInteractions.d.ts.map +1 -1
  100. package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts +0 -7
  101. package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts.map +1 -1
  102. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  103. package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts +8 -0
  104. package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts.map +1 -0
  105. package/lib/typescript/components/Modals/FilePreviewModal/PdfView.d.ts.map +1 -1
  106. package/lib/typescript/components/RoomComponents/styled/LastAudioMessage.d.ts.map +1 -1
  107. package/lib/typescript/components/styled/AudioMessage.d.ts +8 -2
  108. package/lib/typescript/components/styled/AudioMessage.d.ts.map +1 -1
  109. package/lib/typescript/components/styled/Button.d.ts.map +1 -1
  110. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  111. package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts +5 -1
  112. package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts.map +1 -1
  113. package/lib/typescript/components/styled/UnsupportedType.d.ts +5 -0
  114. package/lib/typescript/components/styled/UnsupportedType.d.ts.map +1 -1
  115. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  116. package/lib/typescript/helpers/keyboardLayout.d.ts +3 -1
  117. package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
  118. package/lib/typescript/helpers/mimeToExtension.d.ts +31 -1
  119. package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
  120. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  121. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  122. package/lib/typescript/roomStore/Middleware/unreadMidlleware.d.ts.map +1 -1
  123. package/lib/typescript/roomStore/roomsSlice.d.ts +18 -1
  124. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  125. package/lib/typescript/types/models/modal.model.d.ts +3 -0
  126. package/lib/typescript/types/models/modal.model.d.ts.map +1 -1
  127. package/lib/typescript/types/types.d.ts +15 -0
  128. package/lib/typescript/types/types.d.ts.map +1 -1
  129. package/package.json +5 -1
  130. package/src/assets/icons.tsx +30 -25
  131. package/src/components/MainComponents/ChatRoom.tsx +61 -23
  132. package/src/components/MainComponents/MediaMessage.tsx +295 -5
  133. package/src/components/MainComponents/ReduxWrapper.tsx +23 -9
  134. package/src/components/MessageBubble/MessageInteractions.tsx +35 -3
  135. package/src/components/Modals/FilePreviewModal/DocumentViewer.tsx +3 -40
  136. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +39 -3
  137. package/src/components/Modals/FilePreviewModal/GViewWebView.tsx +142 -0
  138. package/src/components/Modals/FilePreviewModal/PdfView.tsx +2 -18
  139. package/src/components/RoomComponents/styled/LastAudioMessage.tsx +20 -35
  140. package/src/components/styled/AudioMessage.tsx +724 -59
  141. package/src/components/styled/Button.tsx +20 -11
  142. package/src/components/styled/SendInput.tsx +255 -46
  143. package/src/components/styled/StyledInputComponents/MediaComponents.tsx +29 -18
  144. package/src/components/styled/UnsupportedType.tsx +60 -6
  145. package/src/context/xmppProvider.tsx +61 -3
  146. package/src/helpers/keyboardLayout.ts +5 -1
  147. package/src/helpers/mimeToExtension.ts +116 -2
  148. package/src/hooks/useEventHandlers.tsx +1 -1
  149. package/src/hooks/useSendMessage.tsx +12 -6
  150. package/src/networking/xmppClient.ts +24 -1
  151. package/src/roomStore/Middleware/unreadMidlleware.tsx +11 -8
  152. package/src/roomStore/roomsSlice.ts +66 -2
  153. package/src/types/models/modal.model.ts +3 -0
  154. package/src/types/types.ts +32 -0
@@ -79,6 +79,8 @@ const Button: React.FC<ButtonProps> = ({
79
79
  testID,
80
80
  accessibilityLabel,
81
81
  }) => {
82
+ const iconOnly = !loading && !children && !text && !StartIcon && !!EndIcon;
83
+
82
84
  return (
83
85
  <CustomButton
84
86
  testID={testID}
@@ -91,17 +93,19 @@ const Button: React.FC<ButtonProps> = ({
91
93
  >
92
94
  {loading && <Loader size={24} />}
93
95
  {!loading && StartIcon && <View style={styles.icon}>{StartIcon}</View>}
94
- {!loading &&
95
- (children || (
96
- <ButtonText
97
- style={[{ color: color || 'black' }]}
98
- variant={variant}
99
- backgroundColor={backgroundColor}
100
- >
101
- {text}
102
- </ButtonText>
103
- ))}
104
- {!loading && EndIcon && <View style={styles.icon}>{EndIcon}</View>}
96
+ {!loading && children}
97
+ {!loading && !children && typeof text !== 'undefined' && (
98
+ <ButtonText
99
+ style={[{ color: color || 'black' }]}
100
+ variant={variant}
101
+ backgroundColor={backgroundColor}
102
+ >
103
+ {text}
104
+ </ButtonText>
105
+ )}
106
+ {!loading && EndIcon && (
107
+ <View style={iconOnly ? styles.iconOnly : styles.icon}>{EndIcon}</View>
108
+ )}
105
109
  </CustomButton>
106
110
  );
107
111
  };
@@ -111,6 +115,11 @@ const styles = StyleSheet.create({
111
115
  marginHorizontal: 4,
112
116
  // maxWidth: '20%',
113
117
  },
118
+ iconOnly: {
119
+ marginHorizontal: 0,
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ },
114
123
  });
115
124
 
116
125
  export default Button;
@@ -8,13 +8,16 @@ import {
8
8
  } from './StyledInputComponents/StyledInputComponents';
9
9
  import { IConfig, MediaFile } from '../../types/types';
10
10
  import Button from './Button';
11
- import { SendIcon, AttachIcon } from '../../assets/icons';
12
- import { KeyboardAvoidingView, View, TouchableOpacity, Alert, Linking } from 'react-native';
11
+ import { SendIcon, AttachIcon, RecordIcon } from '../../assets/icons';
12
+ import { KeyboardAvoidingView, Platform, View, Text, TouchableOpacity, Alert, Linking } from 'react-native';
13
13
  import AttachSheet from '../Modals/AttachSheet/AttachSheet';
14
14
  import { MediaFilePreview } from './MediaFilePreview';
15
15
  import * as ImagePicker from 'expo-image-picker';
16
16
  import * as DocumentPicker from 'expo-document-picker';
17
17
  import * as ImageManipulator from 'expo-image-manipulator';
18
+ // `Audio` namespace gives us permissions, audio mode, presets, and the
19
+ // Recording class — same pattern AudioMessage uses for Audio.Sound.
20
+ import { Audio } from 'expo-av';
18
21
 
19
22
  // iOS photos default to HEIC; many web backends (incl. ours) 500 on
20
23
  // HEIC uploads because they can't decode it. Convert to JPEG before
@@ -85,10 +88,23 @@ const SendInput: React.FC<SendInputProps> = ({
85
88
  }) => {
86
89
  const [message, setMessage] = useState('');
87
90
  const [isRecording, setIsRecording] = useState(false);
91
+ // Live counter (seconds) for the recording overlay. Updated by a
92
+ // 250ms interval started in `startRecording`.
93
+ const [recordingDuration, setRecordingDuration] = useState(0);
88
94
  const [isFocused, setIsFocused] = useState(false);
89
95
  const [filePreviews, setFilePreviews] = useState<MediaFile[]>([]);
90
96
  const [showMediaMenu, setShowMediaMenu] = useState(false);
91
97
 
98
+ // expo-av's Audio.Recording instance for the in-progress recording.
99
+ // Kept in a ref (not state) because handlers fire synchronously and we
100
+ // need the latest instance without waiting for a re-render.
101
+ // Typed via InstanceType<typeof Audio.Recording> because direct
102
+ // `Audio.Recording` as a type-position lookup fails under bundler
103
+ // resolution even though the class is in the Audio namespace at
104
+ // runtime. InstanceType resolves through the typeof of the value.
105
+ const recordingRef = useRef<InstanceType<typeof Audio.Recording> | null>(null);
106
+ const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
107
+
92
108
  // Refs shadow `message` and `filePreviews` so handleSendClick can:
93
109
  // (a) read the LATEST typed value even when React state hasn't
94
110
  // flushed yet (rapid tap-burst race),
@@ -245,6 +261,148 @@ const SendInput: React.FC<SendInputProps> = ({
245
261
  setShowMediaMenu(true);
246
262
  };
247
263
 
264
+ // ─── Voice recording ──────────────────────────────────────────────
265
+ //
266
+ // Mirrors the web experience: tap mic → start recording → either tap
267
+ // X to discard, or tap send to stop + immediately upload + post the
268
+ // voice message. Uses expo-av's Audio.Recording (already a peer dep
269
+ // for AudioMessage playback). Filename is `voice-<timestamp>.m4a` with
270
+ // mimetype `audio/m4a` so the receiver routes through the audio
271
+ // branch in MediaMessage directly, no octet-stream sniffing needed.
272
+
273
+ const stopRecordingTimer = () => {
274
+ if (recordingTimerRef.current) {
275
+ clearInterval(recordingTimerRef.current);
276
+ recordingTimerRef.current = null;
277
+ }
278
+ };
279
+
280
+ // Always reset the iOS audio mode after teardown so AudioMessage
281
+ // playback elsewhere in the app isn't routed to the earpiece.
282
+ const restoreAudioMode = async () => {
283
+ try {
284
+ await Audio.setAudioModeAsync({
285
+ allowsRecordingIOS: false,
286
+ playsInSilentModeIOS: true,
287
+ });
288
+ } catch {
289
+ /* non-fatal */
290
+ }
291
+ };
292
+
293
+ const handleStartRecording = async () => {
294
+ if (isRecording || recordingRef.current) {return;}
295
+ try {
296
+ const perm = await Audio.requestPermissionsAsync();
297
+ if (!perm.granted) {
298
+ promptOpenSettings(
299
+ 'Microphone permission is needed to record voice messages.'
300
+ );
301
+ return;
302
+ }
303
+ // iOS: recording requires `allowsRecordingIOS:true`; we'll
304
+ // flip it back in restoreAudioMode after stop/cancel.
305
+ await Audio.setAudioModeAsync({
306
+ allowsRecordingIOS: true,
307
+ playsInSilentModeIOS: true,
308
+ });
309
+ const rec = new Audio.Recording();
310
+ await rec.prepareToRecordAsync(
311
+ Audio.RecordingOptionsPresets.HIGH_QUALITY
312
+ );
313
+ await rec.startAsync();
314
+ recordingRef.current = rec;
315
+ setIsRecording(true);
316
+ setRecordingDuration(0);
317
+ const startedAt = Date.now();
318
+ // 250ms tick is fine for a seconds counter — keeps re-renders cheap.
319
+ recordingTimerRef.current = setInterval(() => {
320
+ setRecordingDuration(Math.floor((Date.now() - startedAt) / 1000));
321
+ }, 250);
322
+ } catch (err) {
323
+ console.warn('startRecording failed', err);
324
+ recordingRef.current = null;
325
+ setIsRecording(false);
326
+ setRecordingDuration(0);
327
+ await restoreAudioMode();
328
+ }
329
+ };
330
+
331
+ const handleCancelRecording = async () => {
332
+ stopRecordingTimer();
333
+ const rec = recordingRef.current;
334
+ recordingRef.current = null;
335
+ setIsRecording(false);
336
+ setRecordingDuration(0);
337
+ if (rec) {
338
+ try {
339
+ await rec.stopAndUnloadAsync();
340
+ } catch {
341
+ /* already stopped / unloaded — fine */
342
+ }
343
+ }
344
+ await restoreAudioMode();
345
+ };
346
+
347
+ const handleStopAndSendRecording = async () => {
348
+ stopRecordingTimer();
349
+ const rec = recordingRef.current;
350
+ recordingRef.current = null;
351
+ setIsRecording(false);
352
+ const seconds = recordingDuration;
353
+ setRecordingDuration(0);
354
+ if (!rec) {
355
+ await restoreAudioMode();
356
+ return;
357
+ }
358
+ // Guard against zero-length taps — if the user tapped mic + send
359
+ // within the same tick there's nothing audible to send.
360
+ if (seconds < 1) {
361
+ try {
362
+ await rec.stopAndUnloadAsync();
363
+ } catch {}
364
+ await restoreAudioMode();
365
+ return;
366
+ }
367
+ try {
368
+ await rec.stopAndUnloadAsync();
369
+ } catch (err) {
370
+ console.warn('stopAndUnloadAsync failed', err);
371
+ }
372
+ await restoreAudioMode();
373
+ const uri = rec.getURI();
374
+ if (!uri) {return;}
375
+ // HIGH_QUALITY preset writes AAC in an M4A container on both iOS
376
+ // and Android, so a single mimetype works. `voice-<ts>.m4a`
377
+ // matches the receiver's audio branch in MediaMessage (and
378
+ // isLikelyAudio for any backend that strips mimetype).
379
+ const ts = Date.now();
380
+ const file: MediaFile = {
381
+ uri,
382
+ type: 'audio/m4a',
383
+ name: `voice-${ts}.m4a`,
384
+ };
385
+ try {
386
+ await sendMedia(file, file.type);
387
+ } catch (err) {
388
+ console.error('sendMedia voice failed', err);
389
+ }
390
+ };
391
+
392
+ // Tear down a recording in progress when the input unmounts (chat
393
+ // close, navigation, etc.) so the mic isn't left hot.
394
+ useEffect(() => {
395
+ return () => {
396
+ stopRecordingTimer();
397
+ const rec = recordingRef.current;
398
+ recordingRef.current = null;
399
+ if (rec) {
400
+ rec.stopAndUnloadAsync().catch(() => {});
401
+ }
402
+ restoreAudioMode();
403
+ };
404
+ }, []);
405
+
248
406
  const handleRemoveImage = (index: number) => {
249
407
  const next = filePreviewsRef.current.filter((_, i) => i !== index);
250
408
  filePreviewsRef.current = next;
@@ -332,14 +490,6 @@ const SendInput: React.FC<SendInputProps> = ({
332
490
  </TouchableOpacity>
333
491
  )}
334
492
  <MessageInput
335
- // Stable testIDs so Maestro / Detox / Appium can drive
336
- // the chat-send flow reliably. accessibilityLabel
337
- // mirrors the testID so iOS accessibility-id lookups
338
- // also work. (TextInput in src/components/InputComponents
339
- // also carries the same testIDs as a fallback, but
340
- // SendInput is the input that actually ships in
341
- // <ChatRoom> / <Thread> so this is the one e2e drivers
342
- // hit in practice.)
343
493
  testID="chat-message-input"
344
494
  accessibilityLabel="chat-message-input"
345
495
  isFocused={isFocused}
@@ -348,9 +498,6 @@ const SendInput: React.FC<SendInputProps> = ({
348
498
  placeholderTextColor="#999"
349
499
  value={message}
350
500
  onChangeText={(text) => {
351
- // Mirror into the ref synchronously so handleSendClick
352
- // reads the latest value even before the React state
353
- // commit lands (the source of the spam-merge bug).
354
501
  messageRef.current = text;
355
502
  setMessage(text);
356
503
  }}
@@ -378,41 +525,103 @@ const SendInput: React.FC<SendInputProps> = ({
378
525
  />
379
526
  </>
380
527
  )}
381
- {/* Always show send button - it's needed for sending messages and media */}
382
- <Button
383
- testID="chat-send-button"
384
- accessibilityLabel="chat-send-button"
385
- onPress={handleSendClick}
386
- disabled={!message && filePreviews.length === 0}
387
- EndIcon={
388
- <SendIcon
389
- color={
390
- message || filePreviews.length > 0 ? '#FFFFFF' : '#D4D4D8'
528
+ {isRecording && (
529
+ <>
530
+ <TouchableOpacity
531
+ testID="chat-record-cancel"
532
+ accessibilityLabel="chat-record-cancel"
533
+ onPress={handleCancelRecording}
534
+ style={{
535
+ width: 40,
536
+ height: 40,
537
+ borderRadius: 20,
538
+ alignItems: 'center',
539
+ justifyContent: 'center',
540
+ backgroundColor: '#FEE2E2',
541
+ }}
542
+ activeOpacity={0.7}
543
+ >
544
+ <Text style={{ color: '#B91C1C', fontWeight: '700', fontSize: 18 }}>×</Text>
545
+ </TouchableOpacity>
546
+ {/* Live elapsed time + red recording dot. flex:1 so it
547
+ * occupies the middle slot the input used to have. */}
548
+ <View
549
+ style={{
550
+ flex: 1,
551
+ flexDirection: 'row',
552
+ alignItems: 'center',
553
+ gap: 8,
554
+ paddingHorizontal: 8,
555
+ minHeight: 40,
556
+ }}
557
+ >
558
+ <View
559
+ style={{
560
+ width: 10,
561
+ height: 10,
562
+ borderRadius: 5,
563
+ backgroundColor: '#EF4444',
564
+ }}
565
+ />
566
+ <Text style={{ fontSize: 16, color: '#1F2937', fontWeight: '500' }}>
567
+ {Math.floor(recordingDuration / 60)}:
568
+ {(recordingDuration % 60).toString().padStart(2, '0')}
569
+ </Text>
570
+ <Text style={{ fontSize: 13, color: '#6B7280' }}>Recording…</Text>
571
+ </View>
572
+ </>
573
+ )}
574
+ {/* Right-side action button — swaps based on context:
575
+ * • while recording: stop-and-send (red dot in overlay
576
+ * is the live indicator; this is the same primary
577
+ * button so a single tap stops + ships).
578
+ * • idle + nothing typed + voice enabled: MIC (tap →
579
+ * start recording).
580
+ * • idle + has text / attachments: SEND.
581
+ * • idle + nothing typed + voice disabled: SEND in its
582
+ * muted/disabled state (so the slot reserved for
583
+ * consistency).
584
+ * Matches the web UX where one button on the right swaps
585
+ * between mic and send based on whether you have content. */}
586
+ {(() => {
587
+ const hasContent = !!message || filePreviews.length > 0;
588
+ const showMic = !isRecording && !hasContent && !!config?.enableAudio;
589
+ const onPress = isRecording
590
+ ? handleStopAndSendRecording
591
+ : showMic
592
+ ? handleStartRecording
593
+ : handleSendClick;
594
+ const disabled = !isRecording && !showMic && !hasContent;
595
+ const filled = isRecording || hasContent || showMic;
596
+ return (
597
+ <Button
598
+ testID={showMic ? 'chat-record-button' : 'chat-send-button'}
599
+ accessibilityLabel={showMic ? 'chat-record-button' : 'chat-send-button'}
600
+ onPress={onPress}
601
+ disabled={disabled}
602
+ EndIcon={
603
+ showMic ? (
604
+ <RecordIcon color="#FFFFFF" />
605
+ ) : (
606
+ <SendIcon color={filled ? '#FFFFFF' : '#D4D4D8'} />
607
+ )
391
608
  }
609
+ style={{
610
+ width: 40,
611
+ height: 40,
612
+ borderRadius: 50,
613
+ alignItems: 'center',
614
+ justifyContent: 'center',
615
+ marginLeft: 0,
616
+ marginRight: 0,
617
+ backgroundColor: filled
618
+ ? config?.colors?.primary
619
+ : 'transparent',
620
+ opacity: filled ? 1 : 0.5,
621
+ }}
392
622
  />
393
- }
394
- style={{
395
- width: 40,
396
- height: 40,
397
- borderRadius: 20,
398
- alignItems: 'center',
399
- justifyContent: 'center',
400
- // The shared Button (CustomButton) bakes in `margin: 0 10px`,
401
- // which pushed the send button 10px further from the input
402
- // and from the right edge than the attach button (a raw
403
- // TouchableOpacity with no margin). Zero it so both sides of
404
- // the input use the row's uniform gap:8 + 16px container
405
- // padding. Longhand left/right to override the styled
406
- // longhand (marginHorizontal loses to marginLeft/Right in RN).
407
- marginLeft: 0,
408
- marginRight: 0,
409
- backgroundColor:
410
- message || filePreviews.length > 0
411
- ? config?.colors?.primary
412
- : 'transparent',
413
- opacity: message || filePreviews.length > 0 ? 1 : 0.5,
414
- }}
415
- />
623
+ );
624
+ })()}
416
625
  </MessageInputContainer>
417
626
  <AttachSheet
418
627
  visible={showMediaMenu}
@@ -49,52 +49,63 @@ export const IconButton = styled.TouchableOpacity`
49
49
  pointer-events: auto;
50
50
  `;
51
51
 
52
- export const UnsupportedContainer = styled.TouchableOpacity`
52
+ export const UnsupportedContainer = styled.TouchableOpacity<{ isUser?: boolean }>`
53
53
  display: flex;
54
54
  flex-direction: row;
55
55
  align-items: center;
56
- justify-content: space-between;
57
- border-radius: 8px;
58
- padding: 10px;
59
- margin: 5px 0;
56
+ justify-content: flex-start;
57
+ border-radius: 12px;
58
+ padding: 8px;
59
+ margin: 8px;
60
60
  cursor: pointer;
61
61
  gap: 8px;
62
+ background-color: ${({ isUser }) => (isUser ? '#EDF5FF' : '#F7F9FC')};
63
+ border-width: 1px;
64
+ border-color: ${({ isUser }) => (isUser ? '#D7E6FF' : '#E7ECF3')};
65
+ min-width: 196px;
66
+ max-width: 260px;
62
67
  `;
63
68
 
64
69
  export const BackgroundFile = styled.View`
65
- background-color: #f9f9f9;
66
- border-radius: 8px;
70
+ width: 54px;
71
+ height: 54px;
72
+ align-items: center;
73
+ justify-content: center;
74
+ background-color: #F3EAFB;
75
+ border-radius: 10px;
67
76
  `;
68
77
 
69
78
  export const FileInformation = styled.View`
79
+ flex: 1;
70
80
  align-items: flex-start;
71
81
  justify-content: space-between;
72
- gap: 8px;
82
+ gap: 6px;
73
83
  `;
74
84
 
75
85
  export const FileName = styled.Text<{
76
86
  isUser: boolean;
77
- colorIsUser: string| undefined;
87
+ colorIsUser: string | undefined;
78
88
  colorUsers: string | undefined;
79
89
  }>`
80
90
  font-size: 14px;
81
- font-weight: 500;
82
- flex-grow: 1;
83
- color: ${({isUser, colorIsUser, colorUsers}) => isUser? colorIsUser : colorUsers};
84
- overflow: hidden;
91
+ font-weight: 600;
92
+ flex-shrink: 1;
93
+ color: ${({ isUser, colorIsUser }) =>
94
+ isUser ? colorIsUser || '#1D4ED8' : '#1F2937'};
85
95
  `;
86
96
 
87
97
  export const FileSizeContainer = styled.View`
88
98
  align-items: flex-start;
89
99
  flex-direction: row;
90
- background-color: #f2e6f6;
91
- padding: 2px 8px;
92
- border-radius: 10px;
100
+ background-color: #FFFFFF;
101
+ padding: 3px 8px;
102
+ border-radius: 999px;
93
103
  `;
94
104
 
95
105
  export const FileSize = styled.Text`
96
- color: #53575a;
106
+ color: #667085;
97
107
  overflow: hidden;
98
108
  text-align: left;
99
- font-weight: 500;
109
+ font-size: 12px;
110
+ font-weight: 600;
100
111
  `;
@@ -14,9 +14,10 @@ import {
14
14
  setActiveFile,
15
15
  setActiveModal,
16
16
  } from '../../roomStore/chatSettingsSlice';
17
- import { Text, View } from 'react-native';
17
+ import { ActivityIndicator, View } from 'react-native';
18
18
  import { useChatSettingState } from '../../hooks/useChatSettingState';
19
19
  import { FileIcon } from '../../assets/icons';
20
+ import { getExtensionForMime } from '../../helpers/mimeToExtension';
20
21
 
21
22
  interface FileDownloadProps {
22
23
  fileName: string;
@@ -24,6 +25,11 @@ interface FileDownloadProps {
24
25
  mimetype: string;
25
26
  size?: string;
26
27
  isUser: boolean;
28
+ originalName?: string;
29
+ duration?: number | string;
30
+ waveForm?: string;
31
+ pending?: boolean;
32
+ placeholderIcon?: React.ReactNode;
27
33
  }
28
34
 
29
35
  const FileDownload: React.FC<FileDownloadProps> = ({
@@ -32,6 +38,11 @@ const FileDownload: React.FC<FileDownloadProps> = ({
32
38
  mimetype,
33
39
  size,
34
40
  isUser,
41
+ originalName,
42
+ duration,
43
+ waveForm,
44
+ pending = false,
45
+ placeholderIcon,
35
46
  }) => {
36
47
  const dispatch = useDispatch();
37
48
  const { config } = useChatSettingState();
@@ -73,24 +84,67 @@ const FileDownload: React.FC<FileDownloadProps> = ({
73
84
  };
74
85
 
75
86
  const handleOpen = () => {
76
- dispatch(setActiveFile({ fileName, fileURL, mimetype }));
87
+ if (!fileURL || pending) {
88
+ return;
89
+ }
90
+ dispatch(
91
+ setActiveFile({
92
+ fileName,
93
+ fileURL,
94
+ mimetype,
95
+ originalName,
96
+ duration,
97
+ waveForm,
98
+ })
99
+ );
77
100
  dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
78
101
  };
79
102
 
103
+ const extensionLabel = (() => {
104
+ const dotIndex = fileName.lastIndexOf('.');
105
+ if (dotIndex !== -1 && dotIndex < fileName.length - 1) {
106
+ return fileName.slice(dotIndex + 1).toUpperCase();
107
+ }
108
+ return getExtensionForMime(mimetype).replace('.', '').toUpperCase();
109
+ })();
110
+
80
111
  return (
81
- <UnsupportedContainer onPress={handleOpen}>
112
+ <UnsupportedContainer
113
+ isUser={isUser}
114
+ onPress={handleOpen}
115
+ activeOpacity={pending ? 1 : 0.7}
116
+ >
82
117
  <BackgroundFile>
83
- <FileIcon />
118
+ {placeholderIcon || <FileIcon width={36} height={36} />}
84
119
  </BackgroundFile>
85
120
  <FileInformation>
86
121
  <FileName
122
+ numberOfLines={1}
87
123
  isUser={isUser}
88
124
  colorIsUser={config?.colors?.primary}
89
125
  colorUsers={config?.colors?.secondary}
90
126
  >{formatFileName(fileName, 20)}</FileName>
91
- {size && (
127
+ {(pending || size) && (
128
+ <FileSizeContainer>
129
+ {pending ? (
130
+ <View
131
+ style={{
132
+ flexDirection: 'row',
133
+ alignItems: 'center',
134
+ gap: 6,
135
+ }}
136
+ >
137
+ <ActivityIndicator size="small" color="#5B6B8C" />
138
+ <FileSize>Uploading...</FileSize>
139
+ </View>
140
+ ) : (
141
+ <FileSize>{formatFileSize(size as string)}</FileSize>
142
+ )}
143
+ </FileSizeContainer>
144
+ )}
145
+ {!pending && !size && (
92
146
  <FileSizeContainer>
93
- <FileSize>{formatFileSize(size)}</FileSize>
147
+ <FileSize>{extensionLabel}</FileSize>
94
148
  </FileSizeContainer>
95
149
  )}
96
150
  </FileInformation>