@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
@@ -37,13 +37,14 @@ import { ensureScopedChatCache } from '../helpers/ensureScopedChatCache';
37
37
  import { getRooms as prefetchRoomsViaRest } from '../networking/api-requests/rooms.api';
38
38
  import { allRoomPresences } from '../networking/xmpp/allRoomPresences.xmpp';
39
39
  import { store } from '../roomStore';
40
- import { logout, setStoreClient } from '../roomStore/chatSettingsSlice';
40
+ import { logout, setStoreClient, setConfig } from '../roomStore/chatSettingsSlice';
41
41
  import {
42
42
  setLogoutState,
43
43
  setVisibleRoom,
44
44
  clearVisibleRoom,
45
45
  setLastViewedTimestamp,
46
46
  deleteRoomMessage,
47
+ msgSortableMs,
47
48
  } from '../roomStore/roomsSlice';
48
49
  import { runHistoryPreloadScheduler } from '../helpers/historyPreloadScheduler';
49
50
  import { asyncLocalStorage } from '../hooks/useLocalStorage';
@@ -343,6 +344,10 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
343
344
  devPushLog('rn', `initBeforeLoad: user resolved (${resolved.xmppUsername})`);
344
345
  applyResolvedUserToStore(resolved);
345
346
 
347
+ if (config) {
348
+ store.dispatch(setConfig(config));
349
+ }
350
+
346
351
  // Kick off REST /chats/my; allRoomPresences below needs the
347
352
  // room list to be in redux, otherwise it joins 0 MUCs and the
348
353
  // user receives zero realtime messages until they manually tap
@@ -598,17 +603,20 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
598
603
  // touch the chat store themselves. (Undefined = host unmounts on hide,
599
604
  // nothing to do.)
600
605
  // -----------------------------------------------------------
606
+ const chatWasVisibleRef = useRef(false);
601
607
  useEffect(() => {
602
608
  if (typeof isVisible !== 'boolean') {return;}
603
609
  const state = store.getState();
604
610
  const rooms = state.rooms?.rooms;
605
611
  const activeRoomJID = state.rooms?.activeRoomJID || null;
612
+ const wasVisible = chatWasVisibleRef.current;
613
+ chatWasVisibleRef.current = isVisible;
606
614
  if (isVisible) {
607
615
  if (activeRoomJID) {
608
616
  store.dispatch(setVisibleRoom({ roomJID: activeRoomJID }));
609
617
  }
610
618
  } else {
611
- if (activeRoomJID) {
619
+ if (wasVisible && activeRoomJID) {
612
620
  store.dispatch(
613
621
  setLastViewedTimestamp({ chatJID: activeRoomJID, timestamp: Date.now() })
614
622
  );
@@ -618,8 +626,9 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
618
626
  deleteRoomMessage({ roomJID: activeRoomJID, messageId: 'delimiter-new' })
619
627
  );
620
628
  }
629
+
621
630
  store.dispatch(clearVisibleRoom());
622
- if (client?.flushLastViewedToPrivateStoreStanza) {
631
+ if (wasVisible && client?.flushLastViewedToPrivateStoreStanza) {
623
632
  client
624
633
  .flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID: activeRoomJID })
625
634
  .catch(() => {});
@@ -627,6 +636,55 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
627
636
  }
628
637
  }, [isVisible, client]);
629
638
 
639
+ useEffect(() => {
640
+ if (isVisible !== true) {return;}
641
+ let lastMessagesRef: any = null;
642
+ let lastStampedMs = 0;
643
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
644
+
645
+ const advance = () => {
646
+ const s = store.getState();
647
+ const jid = s.rooms?.activeRoomJID;
648
+ if (!jid) {return;}
649
+ const room = s.rooms?.rooms?.[jid];
650
+ if (!room) {return;}
651
+ if (room.messages === lastMessagesRef) {return;}
652
+ lastMessagesRef = room.messages;
653
+
654
+ let newest = 0;
655
+ const list = room.messages || [];
656
+ for (const m of list) {
657
+ if (!m || m.id === 'delimiter-new') {continue;}
658
+ const ms = msgSortableMs(m);
659
+ if (ms > newest) {newest = ms;}
660
+ }
661
+ if (newest <= lastStampedMs) {return;}
662
+ lastStampedMs = newest;
663
+
664
+ store.dispatch(
665
+ setLastViewedTimestamp({ chatJID: jid, timestamp: Date.now() })
666
+ );
667
+
668
+ if (flushTimer) {clearTimeout(flushTimer);}
669
+ if (client?.flushLastViewedToPrivateStoreStanza) {
670
+ flushTimer = setTimeout(() => {
671
+ client
672
+ .flushLastViewedToPrivateStoreStanza(store.getState().rooms?.rooms, {
673
+ visibleRoomJID: jid,
674
+ })
675
+ .catch(() => {});
676
+ }, 2000);
677
+ }
678
+ };
679
+
680
+ advance();
681
+ const unsub = store.subscribe(advance);
682
+ return () => {
683
+ unsub();
684
+ if (flushTimer) {clearTimeout(flushTimer);}
685
+ };
686
+ }, [isVisible, client]);
687
+
630
688
  // -----------------------------------------------------------
631
689
  // NetInfo → proactive reconnect on network restore. Without this the
632
690
  // only reconnect trigger is xmpp.js noticing the socket is dead, which
@@ -2,8 +2,11 @@ type KeyboardLayoutArgs = {
2
2
  platform: string;
3
3
  configuredOffset?: number;
4
4
  bottomInset?: number;
5
+ keyboardVisible?: boolean;
5
6
  };
6
7
 
8
+ export const ANDROID_INPUT_DOCK_GAP = 12;
9
+
7
10
  export const getKeyboardVerticalOffset = ({
8
11
  platform,
9
12
  configuredOffset = 0,
@@ -14,5 +17,6 @@ export const getKeyboardVerticalOffset = ({
14
17
  export const getInputDockPaddingBottom = ({
15
18
  platform,
16
19
  bottomInset = 0,
20
+ keyboardVisible = false,
17
21
  }: Omit<KeyboardLayoutArgs, 'configuredOffset'>): number =>
18
- platform === 'ios' ? bottomInset : 0;
22
+ platform === 'ios' ? bottomInset : ANDROID_INPUT_DOCK_GAP;
@@ -75,10 +75,21 @@ const AUDIO_EXTENSIONS = [
75
75
  '.mp3', '.m4a', '.wav', '.aac', '.ogg', '.flac',
76
76
  '.weba', '.webm', '.opus', '.amr', '.3gp', '.3gpp',
77
77
  ] as const;
78
+ const IOS_AUDIO_PLAYBACK_EXTENSIONS = [
79
+ '.mp3', '.m4a', '.mp4', '.wav', '.aac', '.caf',
80
+ '.aif', '.aiff', '.amr', '.3gp', '.3gpp',
81
+ ] as const;
82
+ const IOS_AUDIO_UNSUPPORTED_EXTENSIONS = [
83
+ '.webm', '.weba', '.opus', '.ogg',
84
+ ] as const;
78
85
  const AUDIO_EXT_RE = new RegExp(
79
86
  `(${AUDIO_EXTENSIONS.map((e) => e.replace('.', '\\.')).join('|')})$`,
80
87
  'i'
81
88
  );
89
+ const IOS_AUDIO_PLAYBACK_EXT_RE = new RegExp(
90
+ `(${IOS_AUDIO_PLAYBACK_EXTENSIONS.map((e) => e.replace('.', '\\.')).join('|')})$`,
91
+ 'i'
92
+ );
82
93
  // Filename / URL substrings that strongly indicate a voice / audio
83
94
  // payload even without an explicit audio extension — e.g. a web-app
84
95
  // voicemail uploaded as application/octet-stream named "voicemail-…".
@@ -95,16 +106,36 @@ const AUDIO_NAME_HINT_RE =
95
106
  export function isLikelyAudio(
96
107
  mime: string | undefined | null,
97
108
  fileName?: string | null,
98
- url?: string | null
109
+ url?: string | null,
110
+ opts?: {
111
+ duration?: number | string | null;
112
+ waveForm?: string | null;
113
+ originalName?: string | null;
114
+ }
99
115
  ): boolean {
100
116
  const m = (mime || '').toLowerCase();
101
117
  if (m.startsWith('audio/')) {return true;}
118
+ const durationNum =
119
+ typeof opts?.duration === 'string'
120
+ ? Number(opts.duration)
121
+ : opts?.duration ?? 0;
122
+ if (Number.isFinite(durationNum) && durationNum > 0) {return true;}
123
+ if (typeof opts?.waveForm === 'string' && opts.waveForm.trim().length > 0) {
124
+ return true;
125
+ }
102
126
  // For octet-stream (and any other non-audio mime) fall through to the
103
127
  // filename / URL heuristics. We don't restrict to octet-stream — some
104
128
  // backends send no mime at all, or send text/plain by accident.
105
129
  const name = (fileName || '').toLowerCase();
130
+ const originalName = (opts?.originalName || '').toLowerCase();
106
131
  const urlLast = filenameFromUrl(url).toLowerCase();
107
- if (AUDIO_EXT_RE.test(name) || AUDIO_EXT_RE.test(urlLast)) {return true;}
132
+ if (
133
+ AUDIO_EXT_RE.test(name) ||
134
+ AUDIO_EXT_RE.test(originalName) ||
135
+ AUDIO_EXT_RE.test(urlLast)
136
+ ) {
137
+ return true;
138
+ }
108
139
  // Last resort: voice-message naming hint anywhere in the filename, the
109
140
  // URL's last segment, OR the URL's full path. Voicemails are commonly
110
141
  // hosted under `/voice/abc-123` or `/voicemail/abc-123` where the last
@@ -120,6 +151,7 @@ export function isLikelyAudio(
120
151
  })();
121
152
  if (
122
153
  AUDIO_NAME_HINT_RE.test(name) ||
154
+ AUDIO_NAME_HINT_RE.test(originalName) ||
123
155
  AUDIO_NAME_HINT_RE.test(urlLast) ||
124
156
  AUDIO_NAME_HINT_RE.test(urlPath)
125
157
  ) {
@@ -156,6 +188,88 @@ export function filenameFromUrl(url: string | undefined | null): string {
156
188
  }
157
189
  }
158
190
 
191
+ const getLowerExtension = (value: string | undefined | null) => {
192
+ if (!value) {return '';}
193
+ const match = value.toLowerCase().match(VALID_EXT_RE);
194
+ return match ? match[0] : '';
195
+ };
196
+
197
+ /**
198
+ * AVFoundation on iOS is stricter than browsers: a remote URL served as
199
+ * `application/octet-stream` with an opaque `.bin` name may fail before
200
+ * probing the actual audio payload. When the payload already looks like
201
+ * audio, cache it locally with an iOS-playable extension before playback.
202
+ */
203
+ export function shouldCacheAudioForIosPlayback(opts: {
204
+ src?: string | null;
205
+ mime?: string | null;
206
+ fileName?: string | null;
207
+ originalName?: string | null;
208
+ duration?: number | string | null;
209
+ waveForm?: string | null;
210
+ }): boolean {
211
+ const src = opts.src || '';
212
+ if (!/^https?:\/\//i.test(src)) {return false;}
213
+
214
+ const mime = (opts.mime || '').toLowerCase().split(';')[0]!.trim();
215
+ const urlName = filenameFromUrl(src);
216
+ const candidates = [urlName, opts.fileName || '', opts.originalName || ''];
217
+ const hasIosPlayableName = candidates.some((value) =>
218
+ IOS_AUDIO_PLAYBACK_EXT_RE.test(value)
219
+ );
220
+ if (hasIosPlayableName && mime.startsWith('audio/')) {return false;}
221
+
222
+ return isLikelyAudio(mime, opts.fileName, src, {
223
+ duration: opts.duration,
224
+ waveForm: opts.waveForm,
225
+ originalName: opts.originalName,
226
+ });
227
+ }
228
+
229
+ export function getIosAudioPlaybackCacheExtension(opts: {
230
+ mime?: string | null;
231
+ fileName?: string | null;
232
+ originalName?: string | null;
233
+ url?: string | null;
234
+ }): string {
235
+ const candidates = [
236
+ opts.fileName,
237
+ opts.originalName,
238
+ filenameFromUrl(opts.url),
239
+ getExtensionForMime(opts.mime),
240
+ ];
241
+ for (const candidate of candidates) {
242
+ const ext = getLowerExtension(candidate);
243
+ if (ext && IOS_AUDIO_PLAYBACK_EXT_RE.test(ext)) {
244
+ return ext === '.mp4' ? '.m4a' : ext;
245
+ }
246
+ }
247
+ return '.m4a';
248
+ }
249
+
250
+ export function isUnsupportedAudioForIosPlayback(opts: {
251
+ mime?: string | null;
252
+ fileName?: string | null;
253
+ originalName?: string | null;
254
+ url?: string | null;
255
+ }): boolean {
256
+ const mime = (opts.mime || '').toLowerCase().split(';')[0]!.trim();
257
+ if (mime === 'audio/webm' || mime === 'audio/ogg') {
258
+ return true;
259
+ }
260
+ const candidates = [
261
+ opts.fileName,
262
+ opts.originalName,
263
+ filenameFromUrl(opts.url),
264
+ ];
265
+ return candidates.some((candidate) => {
266
+ const ext = getLowerExtension(candidate);
267
+ return IOS_AUDIO_UNSUPPORTED_EXTENSIONS.includes(
268
+ ext as (typeof IOS_AUDIO_UNSUPPORTED_EXTENSIONS)[number]
269
+ );
270
+ });
271
+ }
272
+
159
273
  /**
160
274
  * Ensures `fileName` ends with a valid extension. Strategy:
161
275
  * 1. If the input already has an extension, return it unchanged.
@@ -57,7 +57,7 @@ export const useEventHandlers = (config?: IConfig): EventHandlersHook => {
57
57
  if (config?.eventHandlers?.onMessageFailed) {
58
58
  config.eventHandlers.onMessageFailed(event);
59
59
  }
60
- console.error('Message failed details:', {
60
+ console.warn('Message failed details:', {
61
61
  message: event.message,
62
62
  roomJID: event.roomJID,
63
63
  messageType: event.messageType,
@@ -313,6 +313,11 @@ export const useSendMessage = (_configOverride?: IConfig) => {
313
313
  `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || selfId;
314
314
  const fileSizeStr = data?.size != null ? String(data.size) : '';
315
315
 
316
+ const normalizedFileUri =
317
+ typeof data?.uri === 'string'
318
+ ? data.uri.replace(/^assets-library:\/\//, 'file://')
319
+ : '';
320
+
316
321
  if (!config?.disableSentLogic && !existingId) {
317
322
  dispatch(
318
323
  addRoomMessage({
@@ -335,8 +340,8 @@ export const useSendMessage = (_configOverride?: IConfig) => {
335
340
  isSystemMessage: 'false',
336
341
  isMediafile: 'true',
337
342
  fileName: data?.name,
338
- location: '',
339
- locationPreview: '',
343
+ location: normalizedFileUri,
344
+ locationPreview: normalizedFileUri,
340
345
  mimetype: type,
341
346
  originalName: data?.name,
342
347
  size: fileSizeStr,
@@ -408,10 +413,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
408
413
  // legacy library items — those can't be read as files. Coerce
409
414
  // to a file:// scheme when possible so the body is a real blob.
410
415
  const fileBlob = {
411
- uri:
412
- typeof data?.uri === 'string'
413
- ? data.uri.replace(/^assets-library:\/\//, 'file://')
414
- : data?.uri,
416
+ uri: normalizedFileUri || data?.uri,
415
417
  type:
416
418
  data?.type ||
417
419
  data?.mimeType ||
@@ -492,6 +494,10 @@ export const useSendMessage = (_configOverride?: IConfig) => {
492
494
  store.getState()?.chatSettingStore?.client,
493
495
  ].find((c: any) => c && c.status === 'online');
494
496
  if (effectiveMediaClient) {
497
+ effectiveMediaClient.setActiveRoomJid?.(activeRoomJID);
498
+ await Promise.resolve(
499
+ effectiveMediaClient.prioritizeRoomPresence?.(activeRoomJID)
500
+ ).catch(() => false);
495
501
  effectiveMediaClient.sendMediaMessageStanza(
496
502
  activeRoomJID,
497
503
  messagePayload,
@@ -23,6 +23,8 @@ import { getRooms } from './xmpp/getRooms.xmpp';
23
23
  import { handleStanza } from './xmpp/handleStanzas.xmpp';
24
24
  import { pushLog as devPushLog } from '../utils/devLogger';
25
25
  import { normalizeRoomJid } from '../helpers/normalizeRoomJid';
26
+ import { store } from '../roomStore';
27
+ import { applyPrivateStoreMarkers } from '../roomStore/roomsSlice';
26
28
  import {
27
29
  clearOutboundSends,
28
30
  enqueueOutboundSend,
@@ -870,7 +872,28 @@ export class XmppClient {
870
872
  getChatsPrivateStoreRequestStanza = async () => {
871
873
  if (this.disableLastRead) {return null;}
872
874
  try {
873
- return await getChatsPrivateStoreRequest(this.client);
875
+ const markers = await getChatsPrivateStoreRequest(this.client);
876
+ // Hydrate the fetched server-side read markers into redux. Without
877
+ // this the markers were fetched on every init/reconnect and then
878
+ // silently dropped, so any room the user hadn't locally opened+left
879
+ // this session kept `lastViewedTimestamp: 0` and the unread
880
+ // middleware's `> 0` gate skipped it forever — `useUnread()` stayed
881
+ // at 0 with no badge. Done here on the single READ method so every
882
+ // caller (provider init, reconnect, ChatWrapper, push handlers, …)
883
+ // hydrates automatically and a future caller can't reintroduce the
884
+ // drop. The write helpers call the lower-level
885
+ // `getChatsPrivateStoreRequest` directly and intentionally skip this.
886
+ if (markers && typeof markers === 'object') {
887
+ const normalized: Record<string, number> = {};
888
+ for (const jid of Object.keys(markers as Record<string, unknown>)) {
889
+ const n = Number((markers as Record<string, unknown>)[jid]);
890
+ if (jid && Number.isFinite(n) && n > 0) {normalized[jid] = n;}
891
+ }
892
+ if (Object.keys(normalized).length > 0) {
893
+ store.dispatch(applyPrivateStoreMarkers(normalized));
894
+ }
895
+ }
896
+ return markers;
874
897
  } catch (error) {
875
898
  console.log(error);
876
899
  return null;
@@ -64,6 +64,10 @@ const TRIGGER_ACTIONS = new Set([
64
64
  'roomMessages/setRoomMessages',
65
65
  'roomMessages/editRoomMessage',
66
66
  'roomMessages/setLastViewedTimestamp',
67
+ // Hydrating server-side read markers can move several rooms' baselines
68
+ // at once; recompute so the badges reflect the freshly-applied markers
69
+ // (with own-message filtering this middleware adds on top).
70
+ 'roomMessages/applyPrivateStoreMarkers',
67
71
  'roomMessages/setCurrentRoom',
68
72
  'roomMessages/setVisibleRoom',
69
73
  'roomMessages/clearVisibleRoom',
@@ -130,14 +134,13 @@ export const unreadMiddleware: Middleware =
130
134
  }
131
135
  if (!(room.lastViewedTimestamp > 0)) {return;}
132
136
 
133
- // Fingerprint = "what would affect the answer". If neither the
134
- // message count nor the lastViewedTimestamp changed since last
135
- // time we computed, the answer is stable; skip the walk. (The
136
- // previous version tracked only message count, so changing
137
- // lastViewedTimestamp without a new message dropped the
138
- // recompute silently — the unread badge could go stale.)
139
- const currentMessagesLength = room.messages?.length || 0;
140
- const fingerprint = `${currentMessagesLength}|${room.lastViewedTimestamp || 0}`;
137
+ const msgs = room.messages;
138
+ const currentMessagesLength = msgs?.length || 0;
139
+ const firstId = currentMessagesLength ? String(msgs![0]?.id ?? '') : '';
140
+ const lastId = currentMessagesLength
141
+ ? String(msgs![currentMessagesLength - 1]?.id ?? '')
142
+ : '';
143
+ const fingerprint = `${currentMessagesLength}|${room.lastViewedTimestamp || 0}|${firstId}|${lastId}`;
141
144
  if (triggerCache[jid] === fingerprint) {return;}
142
145
  triggerCache[jid] = fingerprint;
143
146
 
@@ -98,6 +98,12 @@ export interface RoomMessagesState {
98
98
  loadingText?: string;
99
99
  usersSet?: Record<string, any>;
100
100
  pendingNotificationJid?: string | null;
101
+ // Server-side read markers fetched from the XMPP private store
102
+ // (`{ roomJID: lastViewedMs }`). Kept here so a room that loads AFTER
103
+ // the markers were fetched still inherits its baseline via `addRoom`
104
+ // — see `applyPrivateStoreMarkers`. Re-fetched every init/reconnect,
105
+ // so it is intentionally NOT persisted.
106
+ privateStoreMarkers: Record<string, number>;
101
107
  }
102
108
 
103
109
  const initialState: RoomMessagesState = {
@@ -112,6 +118,7 @@ const initialState: RoomMessagesState = {
112
118
  text: '',
113
119
  },
114
120
  pendingNotificationJid: null,
121
+ privateStoreMarkers: {},
115
122
  };
116
123
 
117
124
  const isValidRoomJid = (jid: unknown): jid is string => {
@@ -160,11 +167,19 @@ const reducers = {
160
167
  // the previous session, so cold-start showed no unread badge for
161
168
  // messages received while the app was closed (bug #19/#20). A real
162
169
  // (non-zero) explicit value still wins; otherwise keep the existing
163
- // value; otherwise anchor to the newest message in the payload.
170
+ // value; otherwise fall back to the server-side read marker fetched
171
+ // from the private store (so a room the user has NEVER opened this
172
+ // session still gets a real baseline — without it the unread
173
+ // middleware's `lastViewedTimestamp > 0` gate skipped the room
174
+ // forever and the badge never lit up); otherwise anchor to the
175
+ // newest message in the payload.
176
+ const serverMarker = state.privateStoreMarkers?.[roomData.jid] || 0;
164
177
  if (roomData.lastViewedTimestamp != null && roomData.lastViewedTimestamp !== 0) {
165
178
  lastViewed = roomData.lastViewedTimestamp;
166
- } else if (existing?.lastViewedTimestamp != null) {
179
+ } else if (existing?.lastViewedTimestamp != null && existing.lastViewedTimestamp > 0) {
167
180
  lastViewed = existing.lastViewedTimestamp;
181
+ } else if (serverMarker > 0) {
182
+ lastViewed = serverMarker;
168
183
  } else {
169
184
  const msgs = roomData.messages || [];
170
185
  let newest = 0;
@@ -346,6 +361,7 @@ const reducers = {
346
361
  deleteAllRooms(state: WritableDraft<RoomMessagesState>) {
347
362
  state.rooms = {};
348
363
  state.visibleRoomJID = null;
364
+ state.privateStoreMarkers = {};
349
365
  },
350
366
  setComposing(
351
367
  state: WritableDraft<RoomMessagesState>,
@@ -392,6 +408,45 @@ const reducers = {
392
408
  }
393
409
  }
394
410
  },
411
+ /**
412
+ * Apply server-side read markers fetched from the XMPP private store
413
+ * (`{ roomJID: lastViewedMs }`) to redux. This is the hydration that
414
+ * was missing: `getChatsPrivateStoreRequest` fetched the markers on
415
+ * every init/reconnect and every caller then DISCARDED the result, so
416
+ * rooms kept `lastViewedTimestamp: 0` and the unread middleware's
417
+ * `> 0` gate skipped them forever — `useUnread()` stayed at 0 for any
418
+ * room the user hadn't locally opened+left this session. Centralised
419
+ * here (and consulted by `addRoom`) so BOTH already-loaded rooms and
420
+ * rooms that load later pick up the baseline.
421
+ *
422
+ * Monotonic: a marker only ever moves a baseline FORWARD, so a stale
423
+ * server value can't resurrect already-read messages and a more-recent
424
+ * local read (tab blur stamping Date.now()) always wins.
425
+ */
426
+ applyPrivateStoreMarkers: (
427
+ state: WritableDraft<RoomMessagesState>,
428
+ action: PayloadAction<Record<string, number>>
429
+ ) => {
430
+ const markers = action.payload;
431
+ if (!markers || typeof markers !== 'object') {return;}
432
+ if (!state.privateStoreMarkers) {state.privateStoreMarkers = {};}
433
+ for (const jid of Object.keys(markers)) {
434
+ const ts = Number(markers[jid]);
435
+ if (!jid || !Number.isFinite(ts) || ts <= 0) {continue;}
436
+ // Remember the marker so rooms that load LATER (via addRoom)
437
+ // inherit it even though they don't exist in the store yet.
438
+ if (ts > (state.privateStoreMarkers[jid] || 0)) {
439
+ state.privateStoreMarkers[jid] = ts;
440
+ }
441
+ // Upgrade an already-loaded room's baseline + recompute its badge.
442
+ // Only ever forward, so a later local read isn't clobbered.
443
+ const room = state.rooms[jid];
444
+ if (room && ts > (room.lastViewedTimestamp || 0)) {
445
+ room.lastViewedTimestamp = ts;
446
+ room.unreadMessages = countNewerMessages(room.messages, ts);
447
+ }
448
+ }
449
+ },
395
450
  setRoomRole: (
396
451
  state: WritableDraft<RoomMessagesState>,
397
452
  action: PayloadAction<{ chatJID: string; role: string }>
@@ -429,7 +484,14 @@ const reducers = {
429
484
  }
430
485
  },
431
486
  clearVisibleRoom: (state: WritableDraft<RoomMessagesState>) => {
487
+ const jid = state.visibleRoomJID;
432
488
  state.visibleRoomJID = null;
489
+
490
+ if (jid && state.rooms[jid]) {
491
+ const room = state.rooms[jid];
492
+ const t = room.lastViewedTimestamp || 0;
493
+ room.unreadMessages = t > 0 ? countNewerMessages(room.messages, t) : 0;
494
+ }
433
495
  },
434
496
  /**
435
497
  * Stash a JID that a push notification asked us to open before
@@ -476,6 +538,7 @@ const reducers = {
476
538
  state.activeRoomJID = null;
477
539
  state.visibleRoomJID = null;
478
540
  state.isLoading = false;
541
+ state.privateStoreMarkers = {};
479
542
  },
480
543
  setActiveMessage: (
481
544
  state: WritableDraft<RoomMessagesState>,
@@ -667,6 +730,7 @@ export const {
667
730
  setComposing,
668
731
  setIsLoading,
669
732
  setLastViewedTimestamp,
733
+ applyPrivateStoreMarkers,
670
734
  setRoomNoMessages,
671
735
  setCurrentRoom,
672
736
  setVisibleRoom,
@@ -6,4 +6,7 @@ export interface ModalFile {
6
6
  fileName: string;
7
7
  fileURL: string;
8
8
  mimetype: string;
9
+ originalName?: string;
10
+ duration?: number | string;
11
+ waveForm?: string;
9
12
  }
@@ -248,6 +248,16 @@ export interface IConfig {
248
248
  // ----- header / nav -----
249
249
  disableHeader?: boolean;
250
250
  disableMedia?: boolean;
251
+ /**
252
+ * Enable in-chat voice-message recording. **Opt-in — off by default.**
253
+ * When `true`, an idle input (no text, no attachments) shows a
254
+ * microphone in the send-button slot; tap → start recording → stop &
255
+ * send. iOS apps need `NSMicrophoneUsageDescription` in Info.plist
256
+ * (add via `expo-av`'s plugin block in `app.json`). Receiving voice
257
+ * messages from other clients (incl. legacy web `.bin` voicemails) is
258
+ * independent of this flag — incoming audio plays regardless.
259
+ */
260
+ enableAudio?: boolean;
251
261
  chatHeaderBurgerMenu?: boolean;
252
262
  // Hide the burger icon entirely (the icon that opens the chatHeader-
253
263
  // BurgerMenu dropdown). `chatHeaderBurgerMenu` controls dropdown
@@ -362,6 +372,25 @@ export interface IConfig {
362
372
  chatRoomStyles?: ViewStyle;
363
373
 
364
374
  keyboardVerticalOffset?: number;
375
+ // Opt OUT of the built-in keyboard handling. By default <Chat> mounts
376
+ // its own react-native-keyboard-controller KeyboardProvider +
377
+ // KeyboardAvoidingView (behavior="padding"). If the HOST app already
378
+ // wraps <Chat> in its own KeyboardProvider / KeyboardAvoidingView, the
379
+ // built-in one becomes a SECOND avoider over the same tree — two of them
380
+ // animating padding on keyboard open is the Android flicker in bug #6.
381
+ // Set this so the component renders a plain View instead and drops its
382
+ // KeyboardProvider, leaving the host to own keyboard handling outright.
383
+ disableKeyboardAvoidingView?: boolean;
384
+ // Built-in keyboard strategy: instead of the default KeyboardAvoidingView
385
+ // (which adds bottom padding to the WHOLE chat tree — the message list
386
+ // reflows on keyboard open, the Android "messages jump/flash" in bug #6),
387
+ // wrap ONLY the input dock in a react-native-keyboard-controller
388
+ // KeyboardStickyView so just the input tracks the keyboard and the list
389
+ // is never resized. Best for edge-to-edge Android hosts (where the OS
390
+ // doesn't also resize the window). Ignored when
391
+ // `disableKeyboardAvoidingView` is set. Needs on-device tuning of any
392
+ // `keyboardVerticalOffset`.
393
+ keyboardStickyInput?: boolean;
365
394
 
366
395
  // ----- interactions / messages -----
367
396
  disableInteractions?: boolean;
@@ -517,6 +546,9 @@ export interface ModalFile {
517
546
  fileName: string;
518
547
  fileURL: string;
519
548
  mimetype: string;
549
+ originalName?: string;
550
+ duration?: number | string;
551
+ waveForm?: string;
520
552
  }
521
553
 
522
554
  // Re-export so middleware/hooks that historically import from `types`