@ethora/chat-component-rn 26.5.10 → 26.5.12

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 (209) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/README.md +30 -1
  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/ChatHeader.js +31 -16
  6. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/ChatRoom.js +34 -4
  8. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/ChatWrapper.js +17 -14
  10. package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
  11. package/lib/commonjs/components/MainComponents/MediaMessage.js +298 -12
  12. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  13. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js +119 -0
  14. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js.map +1 -0
  15. package/lib/commonjs/components/MessageBubble/Message.js +39 -3
  16. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  17. package/lib/commonjs/components/MessageBubble/MessageInteractions.js +87 -39
  18. package/lib/commonjs/components/MessageBubble/MessageInteractions.js.map +1 -1
  19. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +153 -62
  20. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  21. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +20 -5
  22. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  23. package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js +5 -44
  24. package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
  25. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +40 -3
  26. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  27. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js +149 -0
  28. package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
  29. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js +4 -17
  30. package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  31. package/lib/commonjs/components/Modals/ModalHeaderComponent.js +10 -0
  32. package/lib/commonjs/components/Modals/ModalHeaderComponent.js.map +1 -1
  33. package/lib/commonjs/components/Modals/styledModalComponents.js +13 -4
  34. package/lib/commonjs/components/Modals/styledModalComponents.js.map +1 -1
  35. package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js +19 -33
  36. package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
  37. package/lib/commonjs/components/styled/AudioMessage.js +643 -66
  38. package/lib/commonjs/components/styled/AudioMessage.js.map +1 -1
  39. package/lib/commonjs/components/styled/Button.js +9 -3
  40. package/lib/commonjs/components/styled/Button.js.map +1 -1
  41. package/lib/commonjs/components/styled/SendInput.js +239 -40
  42. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  43. package/lib/commonjs/components/styled/StyledComponents.js +6 -3
  44. package/lib/commonjs/components/styled/StyledComponents.js.map +1 -1
  45. package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js +31 -18
  46. package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
  47. package/lib/commonjs/components/styled/UnsupportedType.js +47 -5
  48. package/lib/commonjs/components/styled/UnsupportedType.js.map +1 -1
  49. package/lib/commonjs/components/styled/VideoMessage.js +44 -25
  50. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  51. package/lib/commonjs/context/xmppProvider.js +5 -2
  52. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  53. package/lib/commonjs/helpers/keyboardLayout.js +5 -3
  54. package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
  55. package/lib/commonjs/helpers/mimeToExtension.js +123 -0
  56. package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
  57. package/lib/commonjs/hooks/useEventHandlers.js +1 -1
  58. package/lib/commonjs/hooks/useEventHandlers.js.map +1 -1
  59. package/lib/commonjs/hooks/useLogout.js +33 -8
  60. package/lib/commonjs/hooks/useLogout.js.map +1 -1
  61. package/lib/commonjs/hooks/useSendMessage.js +6 -3
  62. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  63. package/lib/commonjs/networking/api-requests/rooms.api.js +29 -4
  64. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  65. package/lib/commonjs/roomStore/roomsSlice.js +6 -0
  66. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  67. package/lib/module/assets/icons.js +7 -1
  68. package/lib/module/assets/icons.js.map +1 -1
  69. package/lib/module/components/MainComponents/ChatHeader.js +31 -16
  70. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  71. package/lib/module/components/MainComponents/ChatRoom.js +36 -6
  72. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  73. package/lib/module/components/MainComponents/ChatWrapper.js +17 -14
  74. package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
  75. package/lib/module/components/MainComponents/MediaMessage.js +301 -15
  76. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  77. package/lib/module/components/MessageBubble/InteractionsOverlay.js +112 -0
  78. package/lib/module/components/MessageBubble/InteractionsOverlay.js.map +1 -0
  79. package/lib/module/components/MessageBubble/Message.js +40 -4
  80. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  81. package/lib/module/components/MessageBubble/MessageInteractions.js +89 -41
  82. package/lib/module/components/MessageBubble/MessageInteractions.js.map +1 -1
  83. package/lib/module/components/Modals/AttachSheet/AttachSheet.js +155 -64
  84. package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  85. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +20 -5
  86. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  87. package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js +5 -45
  88. package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
  89. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +41 -4
  90. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  91. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js +144 -0
  92. package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
  93. package/lib/module/components/Modals/FilePreviewModal/PdfView.js +3 -17
  94. package/lib/module/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
  95. package/lib/module/components/Modals/ModalHeaderComponent.js +10 -0
  96. package/lib/module/components/Modals/ModalHeaderComponent.js.map +1 -1
  97. package/lib/module/components/Modals/styledModalComponents.js +13 -4
  98. package/lib/module/components/Modals/styledModalComponents.js.map +1 -1
  99. package/lib/module/components/RoomComponents/styled/LastAudioMessage.js +20 -34
  100. package/lib/module/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
  101. package/lib/module/components/styled/AudioMessage.js +644 -67
  102. package/lib/module/components/styled/AudioMessage.js.map +1 -1
  103. package/lib/module/components/styled/Button.js +9 -3
  104. package/lib/module/components/styled/Button.js.map +1 -1
  105. package/lib/module/components/styled/SendInput.js +240 -42
  106. package/lib/module/components/styled/SendInput.js.map +1 -1
  107. package/lib/module/components/styled/StyledComponents.js +6 -3
  108. package/lib/module/components/styled/StyledComponents.js.map +1 -1
  109. package/lib/module/components/styled/StyledInputComponents/MediaComponents.js +31 -18
  110. package/lib/module/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
  111. package/lib/module/components/styled/UnsupportedType.js +47 -5
  112. package/lib/module/components/styled/UnsupportedType.js.map +1 -1
  113. package/lib/module/components/styled/VideoMessage.js +45 -26
  114. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  115. package/lib/module/context/xmppProvider.js +5 -2
  116. package/lib/module/context/xmppProvider.js.map +1 -1
  117. package/lib/module/helpers/keyboardLayout.js +4 -2
  118. package/lib/module/helpers/keyboardLayout.js.map +1 -1
  119. package/lib/module/helpers/mimeToExtension.js +119 -0
  120. package/lib/module/helpers/mimeToExtension.js.map +1 -1
  121. package/lib/module/hooks/useEventHandlers.js +1 -1
  122. package/lib/module/hooks/useEventHandlers.js.map +1 -1
  123. package/lib/module/hooks/useLogout.js +33 -8
  124. package/lib/module/hooks/useLogout.js.map +1 -1
  125. package/lib/module/hooks/useSendMessage.js +6 -3
  126. package/lib/module/hooks/useSendMessage.js.map +1 -1
  127. package/lib/module/networking/api-requests/rooms.api.js +29 -4
  128. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  129. package/lib/module/roomStore/roomsSlice.js +6 -0
  130. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  131. package/lib/typescript/assets/icons.d.ts.map +1 -1
  132. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  133. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  134. package/lib/typescript/components/MainComponents/ChatWrapper.d.ts.map +1 -1
  135. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  136. package/lib/typescript/components/MessageBubble/InteractionsOverlay.d.ts +13 -0
  137. package/lib/typescript/components/MessageBubble/InteractionsOverlay.d.ts.map +1 -0
  138. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  139. package/lib/typescript/components/MessageBubble/MessageInteractions.d.ts.map +1 -1
  140. package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
  141. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  142. package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts +0 -7
  143. package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts.map +1 -1
  144. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  145. package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts +8 -0
  146. package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts.map +1 -0
  147. package/lib/typescript/components/Modals/FilePreviewModal/PdfView.d.ts.map +1 -1
  148. package/lib/typescript/components/Modals/ModalHeaderComponent.d.ts.map +1 -1
  149. package/lib/typescript/components/Modals/styledModalComponents.d.ts.map +1 -1
  150. package/lib/typescript/components/RoomComponents/styled/LastAudioMessage.d.ts.map +1 -1
  151. package/lib/typescript/components/styled/AudioMessage.d.ts +8 -2
  152. package/lib/typescript/components/styled/AudioMessage.d.ts.map +1 -1
  153. package/lib/typescript/components/styled/Button.d.ts.map +1 -1
  154. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  155. package/lib/typescript/components/styled/StyledComponents.d.ts.map +1 -1
  156. package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts +5 -1
  157. package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts.map +1 -1
  158. package/lib/typescript/components/styled/UnsupportedType.d.ts +5 -0
  159. package/lib/typescript/components/styled/UnsupportedType.d.ts.map +1 -1
  160. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  161. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  162. package/lib/typescript/helpers/keyboardLayout.d.ts +3 -1
  163. package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
  164. package/lib/typescript/helpers/mimeToExtension.d.ts +38 -0
  165. package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
  166. package/lib/typescript/hooks/useLogout.d.ts +23 -1
  167. package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
  168. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  169. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  170. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  171. package/lib/typescript/types/models/modal.model.d.ts +3 -0
  172. package/lib/typescript/types/models/modal.model.d.ts.map +1 -1
  173. package/lib/typescript/types/types.d.ts +29 -0
  174. package/lib/typescript/types/types.d.ts.map +1 -1
  175. package/package.json +11 -4
  176. package/src/assets/icons.tsx +30 -25
  177. package/src/components/MainComponents/ChatHeader.tsx +46 -15
  178. package/src/components/MainComponents/ChatRoom.tsx +38 -3
  179. package/src/components/MainComponents/ChatWrapper.tsx +28 -21
  180. package/src/components/MainComponents/MediaMessage.tsx +304 -11
  181. package/src/components/MessageBubble/InteractionsOverlay.tsx +121 -0
  182. package/src/components/MessageBubble/Message.tsx +44 -3
  183. package/src/components/MessageBubble/MessageInteractions.tsx +103 -52
  184. package/src/components/Modals/AttachSheet/AttachSheet.tsx +144 -51
  185. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +25 -3
  186. package/src/components/Modals/FilePreviewModal/DocumentViewer.tsx +3 -40
  187. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +48 -4
  188. package/src/components/Modals/FilePreviewModal/GViewWebView.tsx +142 -0
  189. package/src/components/Modals/FilePreviewModal/PdfView.tsx +2 -18
  190. package/src/components/Modals/ModalHeaderComponent.tsx +8 -1
  191. package/src/components/Modals/styledModalComponents.tsx +13 -4
  192. package/src/components/RoomComponents/styled/LastAudioMessage.tsx +20 -35
  193. package/src/components/styled/AudioMessage.tsx +724 -59
  194. package/src/components/styled/Button.tsx +20 -11
  195. package/src/components/styled/SendInput.tsx +255 -46
  196. package/src/components/styled/StyledComponents.ts +6 -3
  197. package/src/components/styled/StyledInputComponents/MediaComponents.tsx +29 -18
  198. package/src/components/styled/UnsupportedType.tsx +60 -6
  199. package/src/components/styled/VideoMessage.tsx +21 -5
  200. package/src/context/xmppProvider.tsx +6 -2
  201. package/src/helpers/keyboardLayout.ts +5 -1
  202. package/src/helpers/mimeToExtension.ts +178 -0
  203. package/src/hooks/useEventHandlers.tsx +1 -1
  204. package/src/hooks/useLogout.tsx +33 -10
  205. package/src/hooks/useSendMessage.tsx +12 -6
  206. package/src/networking/api-requests/rooms.api.ts +31 -7
  207. package/src/roomStore/roomsSlice.ts +7 -0
  208. package/src/types/models/modal.model.ts +3 -0
  209. package/src/types/types.ts +29 -0
@@ -70,6 +70,7 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
70
70
  const insets = useSafeAreaInsets();
71
71
 
72
72
  const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
73
+ const [keyboardVisible, setKeyboardVisible] = useState(false);
73
74
 
74
75
  const { user, config: storeConfig } = useChatSettingState();
75
76
 
@@ -154,6 +155,19 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
154
155
  dispatch(setEditAction({ isEdit: false }));
155
156
  };
156
157
 
158
+ // Read the latest client through a ref so this effect's setup phase
159
+ // does NOT re-run on every `client` identity change. With `client` in
160
+ // the deps array, a reconnect or any provider re-render would re-fire
161
+ // setup → `dispatch(setVisibleRoom(...))` ~one tick after the host
162
+ // cleared visibility via the `isVisible` prop (XmppProvider) — racing
163
+ // and clobbering it, so `useUnread()` reported 0 in tab-mounted hosts.
164
+ // Customer-reported #19. The cleanup still needs the live client for
165
+ // flushLastViewedToPrivateStoreStanza, which is what the ref provides.
166
+ const clientRef = useRef(client);
167
+ useEffect(() => {
168
+ clientRef.current = client;
169
+ }, [client]);
170
+
157
171
  useEffect(() => {
158
172
  if (!activeRoomJID) {
159
173
  return;
@@ -170,8 +184,9 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
170
184
  }),
171
185
  );
172
186
  dispatch(clearVisibleRoom());
173
- if (client) {
174
- client
187
+ const liveClient = clientRef.current;
188
+ if (liveClient) {
189
+ liveClient
175
190
  .flushLastViewedToPrivateStoreStanza(store.getState().rooms?.rooms, {
176
191
  visibleRoomJID: activeRoomJID,
177
192
  })
@@ -180,7 +195,7 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
180
195
  dispatch(deleteRoomMessage({ roomJID: activeRoomJID, messageId: 'delimiter-new' }));
181
196
  setIsLoadingMore(false);
182
197
  };
183
- }, [activeRoomJID, client, dispatch]);
198
+ }, [activeRoomJID, dispatch]);
184
199
 
185
200
  // hooks useEffects
186
201
  // useRoomUrl(activeRoomJID || "", roomsList, config);
@@ -213,8 +228,28 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
213
228
  const inputDockPaddingBottom = getInputDockPaddingBottom({
214
229
  platform: Platform.OS,
215
230
  bottomInset: insets.bottom,
231
+ keyboardVisible,
216
232
  });
217
233
 
234
+ useEffect(() => {
235
+ const showEvent =
236
+ Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
237
+ const hideEvent =
238
+ Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
239
+
240
+ const showSub = Keyboard.addListener(showEvent, () => {
241
+ setKeyboardVisible(true);
242
+ });
243
+ const hideSub = Keyboard.addListener(hideEvent, () => {
244
+ setKeyboardVisible(false);
245
+ });
246
+
247
+ return () => {
248
+ showSub.remove();
249
+ hideSub.remove();
250
+ };
251
+ }, []);
252
+
218
253
  return (
219
254
  <KeyboardAvoidingView
220
255
  style={{ flex: 1 }}
@@ -32,6 +32,7 @@ import {useChatSettingState} from '../../hooks/useChatSettingState';
32
32
  import {DeviceEventEmitter, Pressable, Text, View} from 'react-native';
33
33
  import {pushLog as devPushLog} from '../../utils/devLogger';
34
34
  import {normalizeRoomJid} from '../../helpers/normalizeRoomJid';
35
+ import {InteractionsOverlayProvider} from '../MessageBubble/InteractionsOverlay';
35
36
 
36
37
  interface ChatWrapperProps {
37
38
  token?: string;
@@ -398,28 +399,34 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
398
399
  style={{
399
400
  ...MainComponentStyles,
400
401
  }}>
401
- {showRoomList ? (
402
- <RoomList
403
- chats={Object.values(rooms)}
404
- onRoomClick={handleChangeChat}
405
- />
406
- ) : (
407
- <ChatWrapperBox
408
- style={{
409
- ...MainComponentStyles,
410
- }}>
411
- <ChatRoom
412
- CustomMessageComponent={CustomMessageComponent || Message}
413
- handleBackClick={
414
- roomJID || config?.disableRooms
415
- ? undefined
416
- : () => {
417
- dispatch(setCurrentRoom({ roomJID: '' }));
418
- }
419
- }
402
+ {/* Host the message context menu in-tree (not a RN Modal) so
403
+ opening it doesn't steal focus / dismiss the keyboard on
404
+ Android. Wraps only the chat area — the global Modal /
405
+ ModalWrapper below stay above it. */}
406
+ <InteractionsOverlayProvider>
407
+ {showRoomList ? (
408
+ <RoomList
409
+ chats={Object.values(rooms)}
410
+ onRoomClick={handleChangeChat}
420
411
  />
421
- </ChatWrapperBox>
422
- )}
412
+ ) : (
413
+ <ChatWrapperBox
414
+ style={{
415
+ ...MainComponentStyles,
416
+ }}>
417
+ <ChatRoom
418
+ CustomMessageComponent={CustomMessageComponent || Message}
419
+ handleBackClick={
420
+ roomJID || config?.disableRooms
421
+ ? undefined
422
+ : () => {
423
+ dispatch(setCurrentRoom({ roomJID: '' }));
424
+ }
425
+ }
426
+ />
427
+ </ChatWrapperBox>
428
+ )}
429
+ </InteractionsOverlayProvider>
423
430
  <Modal
424
431
  modal={activeModal}
425
432
  setOpenModal={(value?: ModalType) =>
@@ -4,8 +4,17 @@ import FileDownload from '../styled/UnsupportedType';
4
4
  import CustomMessageImage from '../styled/MessageImage';
5
5
  import CustomMessageVideo from '../styled/VideoMessage';
6
6
  import AudioMessage from '../styled/AudioMessage';
7
- import { Text } from 'react-native';
8
- import { deriveDisplayFilename } from '../../helpers/mimeToExtension';
7
+ import {
8
+ ActivityIndicator,
9
+ Image,
10
+ StyleSheet,
11
+ Text,
12
+ View,
13
+ } from 'react-native';
14
+ import { deriveDisplayFilename, isLikelyAudio } from '../../helpers/mimeToExtension';
15
+ import { FileIcon, PlayIcon } from '../../assets/icons';
16
+ import { defaultMediaDims } from '../../helpers/mediaDimensions';
17
+ import { useChatSettingState } from '../../hooks/useChatSettingState';
9
18
 
10
19
  interface MediaMessageProps {
11
20
  mimeType?: string;
@@ -15,6 +24,116 @@ interface MediaMessageProps {
15
24
  isUser: boolean;
16
25
  }
17
26
 
27
+ const pendingImageDims = defaultMediaDims();
28
+
29
+ const getPendingStatusLabel = (mimeType?: string) => {
30
+ if (mimeType?.startsWith('image/')) {
31
+ return 'Uploading image';
32
+ }
33
+ if (mimeType?.startsWith('video/')) {
34
+ return 'Uploading video';
35
+ }
36
+ if (
37
+ mimeType?.startsWith('audio/') ||
38
+ mimeType?.includes('application/octet-stream')
39
+ ) {
40
+ return 'Uploading audio';
41
+ }
42
+ return 'Uploading file';
43
+ };
44
+
45
+ const PendingMediaMessage: React.FC<{
46
+ fileName: string;
47
+ mimeType?: string;
48
+ previewUri?: string;
49
+ isUser: boolean;
50
+ size?: string;
51
+ }> = ({ fileName, mimeType, previewUri, isUser, size }) => {
52
+ const { config } = useChatSettingState();
53
+ const statusLabel = getPendingStatusLabel(mimeType);
54
+ const isImage = mimeType?.startsWith('image/');
55
+ const isVideo = mimeType?.startsWith('video/');
56
+ const isAudio =
57
+ mimeType?.startsWith('audio/') ||
58
+ mimeType?.includes('application/octet-stream');
59
+ const primaryColor = config?.colors?.primary || '#0A84FF';
60
+
61
+ if (isImage && previewUri) {
62
+ return (
63
+ <View style={styles.pendingImageWrapper}>
64
+ <Image
65
+ source={{ uri: previewUri }}
66
+ style={styles.pendingImage}
67
+ resizeMode="cover"
68
+ />
69
+ <View style={styles.pendingImageOverlay}>
70
+ <ActivityIndicator size="small" color="#FFFFFF" />
71
+ <Text style={styles.pendingOverlayText}>{statusLabel}</Text>
72
+ </View>
73
+ </View>
74
+ );
75
+ }
76
+
77
+ if (isVideo) {
78
+ return (
79
+ <View style={styles.pendingVideoCard}>
80
+ <View style={styles.pendingVideoPreview}>
81
+ <View style={styles.pendingVideoIcon}>
82
+ <PlayIcon width={24} height={24} />
83
+ </View>
84
+ <ActivityIndicator size="small" color="#5B6B8C" />
85
+ </View>
86
+ <Text style={styles.pendingVideoLabel}>{fileName}</Text>
87
+ <Text style={styles.pendingVideoStatus}>{statusLabel}</Text>
88
+ </View>
89
+ );
90
+ }
91
+
92
+ if (isAudio) {
93
+ return (
94
+ <View style={styles.pendingAudioCard}>
95
+ <View
96
+ style={[
97
+ styles.pendingAudioButton,
98
+ { backgroundColor: primaryColor },
99
+ ]}
100
+ >
101
+ <PlayIcon width={18} height={18} color="#fff" />
102
+ </View>
103
+ <View style={styles.pendingAudioContent}>
104
+ <View style={styles.pendingAudioTrack}>
105
+ <View
106
+ style={[
107
+ styles.pendingAudioTrackFill,
108
+ { backgroundColor: primaryColor },
109
+ ]}
110
+ />
111
+ </View>
112
+ <View style={styles.pendingAudioMeta}>
113
+ <Text style={styles.pendingAudioTime}>0:00</Text>
114
+ <View style={styles.pendingAudioStatusPill}>
115
+ <ActivityIndicator size="small" color="#5B6B8C" />
116
+ <Text style={styles.pendingAudioStatusText}>{statusLabel}</Text>
117
+ </View>
118
+ </View>
119
+ </View>
120
+ </View>
121
+ );
122
+ }
123
+
124
+ return (
125
+ <FileDownload
126
+ fileURL={previewUri || ''}
127
+ fileName={fileName}
128
+ mimetype={mimeType || 'application/octet-stream'}
129
+ size={size}
130
+ isUser={isUser}
131
+ pending
132
+ placeholderIcon={<FileIcon width={36} height={36} />}
133
+ />
134
+ );
135
+ };
136
+
18
137
  const MediaMessage: React.FC<MediaMessageProps> = ({
19
138
  mimeType,
20
139
  location,
@@ -29,12 +148,33 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
29
148
  url: location || messageText,
30
149
  mime: mimeType,
31
150
  });
151
+ const isAudioPayload = isLikelyAudio(
152
+ mimeType,
153
+ displayName,
154
+ location,
155
+ {
156
+ duration: (message as any)?.duration,
157
+ waveForm: (message as any)?.waveForm,
158
+ originalName: (message as any)?.originalName,
159
+ }
160
+ );
161
+ if (message?.pending) {
162
+ return (
163
+ <PendingMediaMessage
164
+ fileName={displayName}
165
+ mimeType={mimeType}
166
+ previewUri={messageText || location || ''}
167
+ isUser={isUser}
168
+ size={message?.size}
169
+ />
170
+ );
171
+ }
32
172
  switch (true) {
33
173
  case mimeType.startsWith('image/'):
34
174
  return (
35
175
  <CustomMessageImage
36
176
  fileName={displayName}
37
- fileURL={messageText || ''}
177
+ fileURL={location || messageText || ''}
38
178
  mimetype={mimeType}
39
179
  />
40
180
  );
@@ -47,16 +187,49 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
47
187
  />
48
188
  );
49
189
  case mimeType.startsWith('audio/'): {
50
- return <AudioMessage src={location || ''} />;
190
+ return (
191
+ <AudioMessage
192
+ src={location || ''}
193
+ mimeType={mimeType}
194
+ fileName={displayName}
195
+ originalName={(message as any)?.originalName}
196
+ duration={(message as any)?.duration}
197
+ waveForm={(message as any)?.waveForm}
198
+ />
199
+ );
200
+ }
201
+ case mimeType.includes('application/octet-stream'): {
202
+ return (
203
+ <AudioMessage
204
+ src={location || ''}
205
+ mimeType={mimeType}
206
+ fileName={displayName}
207
+ originalName={(message as any)?.originalName}
208
+ duration={(message as any)?.duration}
209
+ waveForm={(message as any)?.waveForm}
210
+ />
211
+ );
51
212
  }
52
213
  default: {
53
- // Many backends tag binary payloads (PDFs, DOCX, archives) as
54
- // application/octet-stream; sniff the filename extension for the
55
- // legitimate audio case before falling through to FileDownload.
56
- if (mimeType.includes('application/octet-stream')) {
57
- if (/\.(mp3|m4a|wav|aac|ogg|flac)$/i.test(displayName)) {
58
- return <AudioMessage src={location || ''} />;
59
- }
214
+ // Some senders (notably the web app's voice-message uploader)
215
+ // ship audio as `application/octet-stream` with no audio file
216
+ // extension. `isLikelyAudio` recognises those via the URL path,
217
+ // filename, or voice-message naming hints (voicemail-,
218
+ // voice-note-, recording-, …) so we still render the AudioMessage
219
+ // player instead of falling through to the generic FileDownload
220
+ // card (where the voicemail rendered as a broken `.bin`
221
+ // attachment). Customer-reported #9 voicemail fix.
222
+ if (isAudioPayload) {
223
+ return (
224
+ <AudioMessage
225
+ src={location || ''}
226
+ mimeType={mimeType}
227
+ fileName={displayName}
228
+ originalName={(message as any)?.originalName}
229
+ duration={(message as any)?.duration}
230
+ waveForm={(message as any)?.waveForm}
231
+ />
232
+ );
60
233
  }
61
234
  return (
62
235
  <FileDownload
@@ -64,6 +237,9 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
64
237
  fileName={displayName}
65
238
  mimetype={mimeType}
66
239
  isUser={isUser}
240
+ originalName={(message as any)?.originalName}
241
+ duration={(message as any)?.duration}
242
+ waveForm={(message as any)?.waveForm}
67
243
  />
68
244
  );
69
245
  }
@@ -73,3 +249,120 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
73
249
  };
74
250
 
75
251
  export default MediaMessage;
252
+
253
+ const styles = StyleSheet.create({
254
+ pendingImageWrapper: {
255
+ width: pendingImageDims.width,
256
+ height: pendingImageDims.height,
257
+ borderRadius: 12,
258
+ overflow: 'hidden',
259
+ backgroundColor: '#EAF0FB',
260
+ },
261
+ pendingImage: {
262
+ width: '100%',
263
+ height: '100%',
264
+ },
265
+ pendingImageOverlay: {
266
+ ...StyleSheet.absoluteFillObject,
267
+ alignItems: 'center',
268
+ justifyContent: 'center',
269
+ gap: 8,
270
+ backgroundColor: 'rgba(17, 24, 39, 0.28)',
271
+ },
272
+ pendingOverlayText: {
273
+ color: '#FFFFFF',
274
+ fontSize: 13,
275
+ fontWeight: '600',
276
+ },
277
+ pendingVideoCard: {
278
+ width: 220,
279
+ padding: 12,
280
+ borderRadius: 12,
281
+ backgroundColor: '#F3F6FC',
282
+ gap: 10,
283
+ },
284
+ pendingVideoPreview: {
285
+ height: 144,
286
+ borderRadius: 10,
287
+ backgroundColor: '#DCE6F7',
288
+ alignItems: 'center',
289
+ justifyContent: 'center',
290
+ gap: 10,
291
+ },
292
+ pendingVideoIcon: {
293
+ width: 48,
294
+ height: 48,
295
+ borderRadius: 24,
296
+ backgroundColor: 'rgba(17, 24, 39, 0.45)',
297
+ alignItems: 'center',
298
+ justifyContent: 'center',
299
+ },
300
+ pendingVideoLabel: {
301
+ color: '#141414',
302
+ fontSize: 14,
303
+ fontWeight: '600',
304
+ },
305
+ pendingVideoStatus: {
306
+ color: '#5B6B8C',
307
+ fontSize: 12,
308
+ fontWeight: '500',
309
+ },
310
+ pendingAudioCard: {
311
+ minWidth: 252,
312
+ maxWidth: 288,
313
+ flexDirection: 'row',
314
+ alignItems: 'center',
315
+ gap: 12,
316
+ paddingHorizontal: 12,
317
+ paddingVertical: 10,
318
+ borderRadius: 12,
319
+ backgroundColor: '#F3F6FC',
320
+ },
321
+ pendingAudioButton: {
322
+ width: 44,
323
+ height: 44,
324
+ borderRadius: 22,
325
+ alignItems: 'center',
326
+ justifyContent: 'center',
327
+ },
328
+ pendingAudioContent: {
329
+ flex: 1,
330
+ gap: 8,
331
+ },
332
+ pendingAudioTrack: {
333
+ height: 6,
334
+ borderRadius: 999,
335
+ overflow: 'hidden',
336
+ backgroundColor: '#D0D7E6',
337
+ },
338
+ pendingAudioTrackFill: {
339
+ width: '32%',
340
+ height: '100%',
341
+ borderRadius: 999,
342
+ },
343
+ pendingAudioMeta: {
344
+ flexDirection: 'row',
345
+ alignItems: 'center',
346
+ justifyContent: 'space-between',
347
+ gap: 12,
348
+ },
349
+ pendingAudioTime: {
350
+ color: '#667085',
351
+ fontSize: 12,
352
+ fontWeight: '500',
353
+ },
354
+ pendingAudioStatusPill: {
355
+ flexDirection: 'row',
356
+ alignItems: 'center',
357
+ gap: 6,
358
+ paddingHorizontal: 10,
359
+ paddingVertical: 5,
360
+ borderRadius: 999,
361
+ backgroundColor: '#FFFFFF',
362
+ },
363
+ pendingAudioStatusText: {
364
+ color: '#5B6B8C',
365
+ fontSize: 12,
366
+ fontWeight: '600',
367
+ },
368
+ });
@@ -0,0 +1,121 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import { StyleSheet, View } from 'react-native';
10
+
11
+ /**
12
+ * In-tree overlay host for the message context menu (MessageInteractions).
13
+ *
14
+ * Why this exists: MessageInteractions used to render inside a React Native
15
+ * `<Modal>`. On Android a Modal opens its own window, which steals focus
16
+ * from the chat input — so opening the menu dismissed the keyboard. Hosting
17
+ * the menu in the SAME view tree (no extra window) keeps the keyboard up.
18
+ *
19
+ * Coordinate note: the menu is positioned from `UIManager.measure` WINDOW
20
+ * coordinates (pageX/pageY), but this host sits below the status bar / any
21
+ * safe-area + header insets, so its own origin is offset from the window.
22
+ * The host measures that offset (`measureInWindow`) and exposes it so the
23
+ * menu can convert window coords → host-local coords. The host is mounted
24
+ * ABOVE ChatRoom's KeyboardAvoidingView, so its origin doesn't move when the
25
+ * keyboard opens (only the measured bubble's pageY does).
26
+ */
27
+ interface OverlayEntry {
28
+ id: string;
29
+ node: React.ReactNode;
30
+ }
31
+
32
+ interface InteractionsOverlayCtx {
33
+ present: (id: string, node: React.ReactNode) => void;
34
+ dismiss: (id: string) => void;
35
+ originX: number;
36
+ originY: number;
37
+ }
38
+
39
+ const Ctx = createContext<InteractionsOverlayCtx>({
40
+ present: () => {},
41
+ dismiss: () => {},
42
+ originX: 0,
43
+ originY: 0,
44
+ });
45
+
46
+ export const useInteractionsOverlay = () => useContext(Ctx);
47
+
48
+ export const InteractionsOverlayProvider: React.FC<{
49
+ children: React.ReactNode;
50
+ }> = ({ children }) => {
51
+ const [entries, setEntries] = useState<OverlayEntry[]>([]);
52
+ const [origin, setOrigin] = useState({ x: 0, y: 0 });
53
+ const hostRef = useRef<View>(null);
54
+
55
+ const measure = useCallback(() => {
56
+ hostRef.current?.measureInWindow?.((x, y) => {
57
+ if (typeof x !== 'number' || typeof y !== 'number') {return;}
58
+ setOrigin((prev) => (prev.x === x && prev.y === y ? prev : { x, y }));
59
+ });
60
+ }, []);
61
+
62
+ const present = useCallback(
63
+ (id: string, node: React.ReactNode) => {
64
+ // Re-measure on present: the keyboard / layout may have shifted us
65
+ // since the last onLayout.
66
+ measure();
67
+ setEntries((prev) => {
68
+ const next = prev.filter((e) => e.id !== id);
69
+ next.push({ id, node });
70
+ return next;
71
+ });
72
+ },
73
+ [measure]
74
+ );
75
+
76
+ const dismiss = useCallback((id: string) => {
77
+ setEntries((prev) =>
78
+ prev.some((e) => e.id === id) ? prev.filter((e) => e.id !== id) : prev
79
+ );
80
+ }, []);
81
+
82
+ // MUST be memoized. Otherwise every host re-render (e.g. from present()'s
83
+ // setEntries) creates a new context value → all consumers re-render →
84
+ // MessageInteractions' present-effect fires again → setEntries → … →
85
+ // "Maximum update depth exceeded". present/dismiss are stable (useCallback)
86
+ // and origin rarely changes, so this value is stable across entry changes.
87
+ const ctxValue = useMemo(
88
+ () => ({ present, dismiss, originX: origin.x, originY: origin.y }),
89
+ [present, dismiss, origin.x, origin.y]
90
+ );
91
+
92
+ return (
93
+ // `children` is a stable element from the caller, so re-rendering this
94
+ // host (present/dismiss/origin state) does NOT re-render the chat
95
+ // subtree — React reconciliation skips the unchanged children element.
96
+ <View
97
+ ref={hostRef}
98
+ style={styles.host}
99
+ onLayout={measure}
100
+ collapsable={false}
101
+ >
102
+ <Ctx.Provider value={ctxValue}>
103
+ {children}
104
+ {entries.length > 0 && (
105
+ <View style={styles.overlay} pointerEvents="box-none">
106
+ {entries.map((e) => (
107
+ <React.Fragment key={e.id}>{e.node}</React.Fragment>
108
+ ))}
109
+ </View>
110
+ )}
111
+ </Ctx.Provider>
112
+ </View>
113
+ );
114
+ };
115
+
116
+ const styles = StyleSheet.create({
117
+ host: { flex: 1 },
118
+ overlay: { ...StyleSheet.absoluteFillObject, zIndex: 9999, elevation: 9999 },
119
+ });
120
+
121
+ export default InteractionsOverlayProvider;
@@ -6,6 +6,7 @@ import {
6
6
  findNodeHandle,
7
7
  UIManager,
8
8
  Text,
9
+ Keyboard,
9
10
  } from 'react-native';
10
11
  import { useDispatch, useSelector } from 'react-redux';
11
12
  import { RootState } from '../../roomStore';
@@ -241,8 +242,25 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
241
242
  // onLayout — more accurate than estimating it here, and it's what keeps
242
243
  // the menu adjacent to the message instead of floating far above (#13).
243
244
  const handleLongPress = () => {
244
- const target = bubbleRef.current ?? messageRef.current;
245
- if (target) {
245
+ const target = (bubbleRef.current ?? messageRef.current) as any;
246
+ if (target?.measureInWindow) {
247
+ // Use measureInWindow — the SAME API the overlay host uses to find
248
+ // its own window origin. Both are then in identical window
249
+ // coordinates, so MessageInteractions' host-local conversion
250
+ // (subtract origin) is exact. Mixing this with UIManager.measure
251
+ // (pageX/pageY) drifted on Android (status-bar offset) and pushed the
252
+ // "below" menu too far from the message.
253
+ target.measureInWindow(
254
+ (x: number, y: number, width: number, height: number) => {
255
+ setContextMenuPosition({
256
+ left: x,
257
+ right: x + width,
258
+ top: y,
259
+ bottom: y + height,
260
+ });
261
+ }
262
+ );
263
+ } else if (target) {
246
264
  const nodeHandle = findNodeHandle(target);
247
265
  if (nodeHandle) {
248
266
  UIManager.measure(nodeHandle, (_x, _y, width, height, pageX, pageY) => {
@@ -291,7 +309,19 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
291
309
  >
292
310
  {!isUser && (
293
311
  <CustomMessagePhotoContainer
294
- onPress={() => handleUserAvatarClick(message.user)}
312
+ // `disableProfilesInteractions` gates every entry point to the
313
+ // user-profile popup: this in-bubble avatar AND the chat-title
314
+ // press in ChatHeader. When disabled we still render the avatar
315
+ // visually but make it non-interactive (no press, no a11y
316
+ // affordance) — consumers who want it gone entirely can return
317
+ // null from a custom message component.
318
+ onPress={
319
+ config?.disableProfilesInteractions
320
+ ? undefined
321
+ : () => handleUserAvatarClick(message.user)
322
+ }
323
+ disabled={!!config?.disableProfilesInteractions}
324
+ accessible={!config?.disableProfilesInteractions}
295
325
  >
296
326
  {message.user?.profileImage ? (
297
327
  <CustomMessagePhoto source={{ uri: message.user.profileImage }} />
@@ -310,6 +340,17 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
310
340
  flex: 1,
311
341
  alignItems: isUser ? 'flex-end' : 'flex-start',
312
342
  }}
343
+ // A plain tap anywhere on a message bubble dismisses the
344
+ // keyboard. The FlatList's keyboardShouldPersistTaps="handled"
345
+ // already dismisses taps on the empty GAPS between bubbles, but
346
+ // the bubble Pressable itself is a touch responder, so taps that
347
+ // landed on a message were "handled" and the keyboard stayed —
348
+ // which is exactly the "closes only every other time" symptom
349
+ // (hit a gap → close, hit a bubble → stay). Explicitly dismissing
350
+ // here makes it reliable across the whole list. onPress does NOT
351
+ // fire when onLongPress fires, so opening MessageInteractions via
352
+ // long-press never triggers this dismiss.
353
+ onPress={() => Keyboard.dismiss()}
313
354
  // disableInteractions hides the long-press → context menu
314
355
  // (delete / edit / reply / react). Mirrors web's config gate.
315
356
  onLongPress={