@ethora/chat-component-rn 26.7.8 → 26.7.10

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 (232) hide show
  1. package/CHANGELOG.md +3 -2
  2. package/README.md +10 -0
  3. package/lib/commonjs/assets/icons.js +2 -2
  4. package/lib/commonjs/assets/icons.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatRoom.js +76 -19
  6. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/MediaMessage.js +8 -5
  8. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/MessageContainer.js +5 -1
  10. package/lib/commonjs/components/MainComponents/MessageContainer.js.map +1 -1
  11. package/lib/commonjs/components/MainComponents/MessageList.js +2 -1
  12. package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
  13. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +1 -51
  14. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  15. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +51 -12
  16. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  17. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js +12 -1
  18. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  19. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js +13 -2
  20. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  21. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js +13 -2
  22. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  23. package/lib/commonjs/components/styled/MessageImage.js +3 -1
  24. package/lib/commonjs/components/styled/MessageImage.js.map +1 -1
  25. package/lib/commonjs/components/styled/UnsupportedType.js +14 -4
  26. package/lib/commonjs/components/styled/UnsupportedType.js.map +1 -1
  27. package/lib/commonjs/components/styled/VideoMessage.js +3 -1
  28. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  29. package/lib/commonjs/context/xmppProvider.js +95 -36
  30. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  31. package/lib/commonjs/helpers/createMessageFromXml.js +19 -1
  32. package/lib/commonjs/helpers/createMessageFromXml.js.map +1 -1
  33. package/lib/commonjs/helpers/getDisplayFileName.js +79 -0
  34. package/lib/commonjs/helpers/getDisplayFileName.js.map +1 -0
  35. package/lib/commonjs/helpers/getServerReadTimestamp.js +188 -0
  36. package/lib/commonjs/helpers/getServerReadTimestamp.js.map +1 -0
  37. package/lib/commonjs/helpers/initXmppRooms.js +9 -7
  38. package/lib/commonjs/helpers/initXmppRooms.js.map +1 -1
  39. package/lib/commonjs/helpers/insertMessageWithDelimiter.js +51 -9
  40. package/lib/commonjs/helpers/insertMessageWithDelimiter.js.map +1 -1
  41. package/lib/commonjs/helpers/isOwnMessage.js +75 -0
  42. package/lib/commonjs/helpers/isOwnMessage.js.map +1 -0
  43. package/lib/commonjs/helpers/keyboardLayout.js +12 -2
  44. package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
  45. package/lib/commonjs/helpers/mimeToExtension.js +6 -14
  46. package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
  47. package/lib/commonjs/helpers/msgSortableMs.js +37 -0
  48. package/lib/commonjs/helpers/msgSortableMs.js.map +1 -0
  49. package/lib/commonjs/hooks/useChatRoomFocus.js +45 -12
  50. package/lib/commonjs/hooks/useChatRoomFocus.js.map +1 -1
  51. package/lib/commonjs/hooks/useChatWrapperInit.js +12 -7
  52. package/lib/commonjs/hooks/useChatWrapperInit.js.map +1 -1
  53. package/lib/commonjs/hooks/useLogout.js +8 -0
  54. package/lib/commonjs/hooks/useLogout.js.map +1 -1
  55. package/lib/commonjs/i18n/strings.js +0 -18
  56. package/lib/commonjs/i18n/strings.js.map +1 -1
  57. package/lib/commonjs/networking/api-requests/rooms.api.js +0 -13
  58. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  59. package/lib/commonjs/networking/stanzaHandlers.js +20 -2
  60. package/lib/commonjs/networking/stanzaHandlers.js.map +1 -1
  61. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js +45 -11
  62. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  63. package/lib/commonjs/networking/xmppClient.js +36 -2
  64. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  65. package/lib/commonjs/roomStore/index.js +35 -2
  66. package/lib/commonjs/roomStore/index.js.map +1 -1
  67. package/lib/commonjs/roomStore/persistence.js +200 -11
  68. package/lib/commonjs/roomStore/persistence.js.map +1 -1
  69. package/lib/commonjs/roomStore/roomsSlice.js +148 -37
  70. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  71. package/lib/module/assets/icons.js +1 -1
  72. package/lib/module/assets/icons.js.map +1 -1
  73. package/lib/module/components/MainComponents/ChatRoom.js +77 -20
  74. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  75. package/lib/module/components/MainComponents/MediaMessage.js +9 -6
  76. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  77. package/lib/module/components/MainComponents/MessageContainer.js +5 -1
  78. package/lib/module/components/MainComponents/MessageContainer.js.map +1 -1
  79. package/lib/module/components/MainComponents/MessageList.js +2 -1
  80. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  81. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +3 -53
  82. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  83. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +52 -13
  84. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  85. package/lib/module/components/RoomComponents/styled/LastMessageFile.js +12 -1
  86. package/lib/module/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  87. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js +13 -2
  88. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  89. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js +13 -2
  90. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  91. package/lib/module/components/styled/MessageImage.js +3 -1
  92. package/lib/module/components/styled/MessageImage.js.map +1 -1
  93. package/lib/module/components/styled/UnsupportedType.js +14 -4
  94. package/lib/module/components/styled/UnsupportedType.js.map +1 -1
  95. package/lib/module/components/styled/VideoMessage.js +3 -1
  96. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  97. package/lib/module/context/xmppProvider.js +96 -37
  98. package/lib/module/context/xmppProvider.js.map +1 -1
  99. package/lib/module/helpers/createMessageFromXml.js +19 -1
  100. package/lib/module/helpers/createMessageFromXml.js.map +1 -1
  101. package/lib/module/helpers/getDisplayFileName.js +73 -0
  102. package/lib/module/helpers/getDisplayFileName.js.map +1 -0
  103. package/lib/module/helpers/getServerReadTimestamp.js +181 -0
  104. package/lib/module/helpers/getServerReadTimestamp.js.map +1 -0
  105. package/lib/module/helpers/initXmppRooms.js +9 -7
  106. package/lib/module/helpers/initXmppRooms.js.map +1 -1
  107. package/lib/module/helpers/insertMessageWithDelimiter.js +52 -9
  108. package/lib/module/helpers/insertMessageWithDelimiter.js.map +1 -1
  109. package/lib/module/helpers/isOwnMessage.js +70 -0
  110. package/lib/module/helpers/isOwnMessage.js.map +1 -0
  111. package/lib/module/helpers/keyboardLayout.js +12 -2
  112. package/lib/module/helpers/keyboardLayout.js.map +1 -1
  113. package/lib/module/helpers/mimeToExtension.js +6 -13
  114. package/lib/module/helpers/mimeToExtension.js.map +1 -1
  115. package/lib/module/helpers/msgSortableMs.js +32 -0
  116. package/lib/module/helpers/msgSortableMs.js.map +1 -0
  117. package/lib/module/hooks/useChatRoomFocus.js +46 -13
  118. package/lib/module/hooks/useChatRoomFocus.js.map +1 -1
  119. package/lib/module/hooks/useChatWrapperInit.js +12 -7
  120. package/lib/module/hooks/useChatWrapperInit.js.map +1 -1
  121. package/lib/module/hooks/useLogout.js +8 -0
  122. package/lib/module/hooks/useLogout.js.map +1 -1
  123. package/lib/module/i18n/strings.js +0 -18
  124. package/lib/module/i18n/strings.js.map +1 -1
  125. package/lib/module/networking/api-requests/rooms.api.js +0 -12
  126. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  127. package/lib/module/networking/stanzaHandlers.js +20 -2
  128. package/lib/module/networking/stanzaHandlers.js.map +1 -1
  129. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js +45 -11
  130. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  131. package/lib/module/networking/xmppClient.js +36 -2
  132. package/lib/module/networking/xmppClient.js.map +1 -1
  133. package/lib/module/roomStore/index.js +37 -4
  134. package/lib/module/roomStore/index.js.map +1 -1
  135. package/lib/module/roomStore/persistence.js +197 -11
  136. package/lib/module/roomStore/persistence.js.map +1 -1
  137. package/lib/module/roomStore/roomsSlice.js +136 -33
  138. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  139. package/lib/typescript/assets/icons.d.ts +0 -8
  140. package/lib/typescript/assets/icons.d.ts.map +1 -1
  141. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  142. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  143. package/lib/typescript/components/MainComponents/MessageContainer.d.ts.map +1 -1
  144. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  145. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  146. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  147. package/lib/typescript/components/RoomComponents/styled/LastMessageFile.d.ts +1 -1
  148. package/lib/typescript/components/RoomComponents/styled/LastMessageFile.d.ts.map +1 -1
  149. package/lib/typescript/components/RoomComponents/styled/LastMessagePhoto.d.ts +1 -1
  150. package/lib/typescript/components/RoomComponents/styled/LastMessagePhoto.d.ts.map +1 -1
  151. package/lib/typescript/components/RoomComponents/styled/LastMessageVideo.d.ts +1 -1
  152. package/lib/typescript/components/RoomComponents/styled/LastMessageVideo.d.ts.map +1 -1
  153. package/lib/typescript/components/styled/MessageImage.d.ts +6 -0
  154. package/lib/typescript/components/styled/MessageImage.d.ts.map +1 -1
  155. package/lib/typescript/components/styled/UnsupportedType.d.ts.map +1 -1
  156. package/lib/typescript/components/styled/VideoMessage.d.ts +6 -0
  157. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  158. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  159. package/lib/typescript/helpers/createMessageFromXml.d.ts.map +1 -1
  160. package/lib/typescript/helpers/getAvatarColor.d.ts +1 -1
  161. package/lib/typescript/helpers/getDisplayFileName.d.ts +49 -0
  162. package/lib/typescript/helpers/getDisplayFileName.d.ts.map +1 -0
  163. package/lib/typescript/helpers/getServerReadTimestamp.d.ts +108 -0
  164. package/lib/typescript/helpers/getServerReadTimestamp.d.ts.map +1 -0
  165. package/lib/typescript/helpers/initXmppRooms.d.ts.map +1 -1
  166. package/lib/typescript/helpers/insertMessageWithDelimiter.d.ts +1 -1
  167. package/lib/typescript/helpers/insertMessageWithDelimiter.d.ts.map +1 -1
  168. package/lib/typescript/helpers/isOwnMessage.d.ts +36 -0
  169. package/lib/typescript/helpers/isOwnMessage.d.ts.map +1 -0
  170. package/lib/typescript/helpers/keyboardLayout.d.ts +5 -1
  171. package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
  172. package/lib/typescript/helpers/mimeToExtension.d.ts +0 -15
  173. package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
  174. package/lib/typescript/helpers/msgSortableMs.d.ts +2 -0
  175. package/lib/typescript/helpers/msgSortableMs.d.ts.map +1 -0
  176. package/lib/typescript/hooks/useChatRoomFocus.d.ts +4 -1
  177. package/lib/typescript/hooks/useChatRoomFocus.d.ts.map +1 -1
  178. package/lib/typescript/hooks/useChatWrapperInit.d.ts.map +1 -1
  179. package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
  180. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  181. package/lib/typescript/networking/api-requests/rooms.api.d.ts +0 -1
  182. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  183. package/lib/typescript/networking/stanzaHandlers.d.ts.map +1 -1
  184. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts +9 -2
  185. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts.map +1 -1
  186. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  187. package/lib/typescript/roomStore/index.d.ts.map +1 -1
  188. package/lib/typescript/roomStore/persistence.d.ts +18 -1
  189. package/lib/typescript/roomStore/persistence.d.ts.map +1 -1
  190. package/lib/typescript/roomStore/roomsSlice.d.ts +42 -3
  191. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  192. package/lib/typescript/types/models/room.model.d.ts +0 -1
  193. package/lib/typescript/types/models/room.model.d.ts.map +1 -1
  194. package/lib/typescript/types/types.d.ts +1 -1
  195. package/lib/typescript/types/types.d.ts.map +1 -1
  196. package/package.json +1 -1
  197. package/src/assets/icons.tsx +0 -24
  198. package/src/components/MainComponents/ChatRoom.tsx +81 -19
  199. package/src/components/MainComponents/MediaMessage.tsx +8 -5
  200. package/src/components/MainComponents/MessageContainer.tsx +5 -1
  201. package/src/components/MainComponents/MessageList.tsx +2 -1
  202. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +2 -36
  203. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +63 -26
  204. package/src/components/RoomComponents/styled/LastMessageFile.tsx +14 -2
  205. package/src/components/RoomComponents/styled/LastMessagePhoto.tsx +14 -2
  206. package/src/components/RoomComponents/styled/LastMessageVideo.tsx +14 -2
  207. package/src/components/styled/MessageImage.tsx +8 -1
  208. package/src/components/styled/UnsupportedType.tsx +14 -4
  209. package/src/components/styled/VideoMessage.tsx +8 -1
  210. package/src/context/xmppProvider.tsx +122 -30
  211. package/src/helpers/createMessageFromXml.ts +20 -1
  212. package/src/helpers/getDisplayFileName.ts +87 -0
  213. package/src/helpers/getServerReadTimestamp.ts +197 -0
  214. package/src/helpers/initXmppRooms.tsx +9 -9
  215. package/src/helpers/insertMessageWithDelimiter.ts +55 -19
  216. package/src/helpers/isOwnMessage.ts +90 -0
  217. package/src/helpers/keyboardLayout.ts +23 -2
  218. package/src/helpers/mimeToExtension.ts +6 -22
  219. package/src/helpers/msgSortableMs.ts +25 -0
  220. package/src/hooks/useChatRoomFocus.ts +49 -9
  221. package/src/hooks/useChatWrapperInit.ts +12 -7
  222. package/src/hooks/useLogout.tsx +14 -0
  223. package/src/i18n/strings.ts +0 -18
  224. package/src/networking/api-requests/rooms.api.ts +0 -9
  225. package/src/networking/stanzaHandlers.ts +20 -6
  226. package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +65 -18
  227. package/src/networking/xmppClient.ts +41 -2
  228. package/src/roomStore/index.ts +40 -4
  229. package/src/roomStore/persistence.ts +197 -7
  230. package/src/roomStore/roomsSlice.ts +158 -28
  231. package/src/types/models/room.model.ts +0 -1
  232. package/src/types/types.ts +14 -1
@@ -6,10 +6,12 @@ import { useDispatch } from 'react-redux';
6
6
  import MessageList from './MessageList';
7
7
  import SendInput from '../styled/SendInput';
8
8
  import {
9
+ clearReadBoundary,
9
10
  clearVisibleRoom,
10
11
  deleteRoomMessage,
11
12
  setEditAction,
12
13
  setLastViewedTimestamp,
14
+ setReadBoundary,
13
15
  setVisibleRoom,
14
16
  } from '../../roomStore/roomsSlice';
15
17
  import Loader from '../styled/Loader';
@@ -28,6 +30,7 @@ import { IConfig } from '../../types/models/config.model';
28
30
  import { useRoomInitialization } from '../../hooks/useRoomInitialization';
29
31
  import { useRoomState } from '../../hooks/useRoomState';
30
32
  import { useChatSettingState } from '../../hooks/useChatSettingState';
33
+ import { isOwnMessage } from '../../helpers/isOwnMessage';
31
34
  import CustomTypingIndicator from '../styled/StyledInputComponents/CustomTypingIndicator';
32
35
  // import {PanGestureHandler} from 'react-native-gesture-handler';
33
36
  import { FlatList } from 'react-native';
@@ -46,6 +49,10 @@ import {
46
49
  } from 'react-native-keyboard-controller';
47
50
  import useComposing from '../../hooks/useComposing';
48
51
  import { store } from '../../roomStore';
52
+ import {
53
+ getFlushBoundaryTs,
54
+ getReadMarkerTimestamp,
55
+ } from '../../helpers/getServerReadTimestamp';
49
56
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
50
57
  import {
51
58
  getInputDockPaddingBottom,
@@ -216,47 +223,100 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
216
223
  // Tracks what the user actually saw: `null` while they're at the
217
224
  // bottom (safe to mark everything read), or the timestamp of the
218
225
  // newest message visible when they scrolled away from it. Reported
219
- // by MessageList via onReadBoundaryChange. Without this, leaving a
220
- // room (or backgrounding) while scrolled up stamped `now()` as read
221
- // and silently discarded genuinely-unread messages. Customer-
222
- // reported #33.
223
- const readBoundaryRef = useRef<number | null>(null);
226
+ // by MessageList via onReadBoundaryChange, and mirrored into redux
227
+ // (`rooms.readBoundaries`) rather than kept only in a local ref - it
228
+ // is the single source of truth every "leaving this room" path
229
+ // consults (xmppProvider's AppState background handler, its
230
+ // `isVisible=false` handler, the live `advance()` effect, and
231
+ // `useChatRoomFocus`'s `leaveRoom`), not just this component's own
232
+ // unmount cleanup below. Without a boundary, leaving a room (or
233
+ // backgrounding) while scrolled up stamped `now()` (bug #33) or,
234
+ // after the #38 fix, the newest acked message regardless of scroll
235
+ // position (bug #42) - both silently discard genuinely-unread
236
+ // messages.
237
+ //
238
+ // MessageList reports the boundary from onScroll, which fires on
239
+ // every frame of a drag (the "back at the bottom" branch re-reports
240
+ // `null` each time). Mirror only actual CHANGES into redux: an
241
+ // unconditional dispatch per scroll frame would run the whole
242
+ // middleware chain and, worse, reset persistenceMiddleware's 200 ms
243
+ // debounce on every frame, starving the persisted write for as long
244
+ // as the user keeps scrolling.
245
+ const lastBoundarySentRef = useRef<number | null>(null);
224
246
  const handleReadBoundaryChange = useCallback((boundaryTs: number | null) => {
225
- readBoundaryRef.current = boundaryTs;
226
- }, []);
247
+ if (!activeRoomJID) {
248
+ return;
249
+ }
250
+ const next = boundaryTs && boundaryTs > 0 ? boundaryTs : null;
251
+ if (lastBoundarySentRef.current === next) {
252
+ return;
253
+ }
254
+ lastBoundarySentRef.current = next;
255
+ dispatch(setReadBoundary({ jid: activeRoomJID, ts: next }));
256
+ }, [activeRoomJID, dispatch]);
227
257
 
228
258
  useEffect(() => {
229
259
  if (!activeRoomJID) {
230
260
  return;
231
261
  }
232
262
 
233
- readBoundaryRef.current = null;
263
+ lastBoundarySentRef.current = null;
264
+ dispatch(setReadBoundary({ jid: activeRoomJID, ts: null }));
234
265
  dispatch(setVisibleRoom({ roomJID: activeRoomJID }));
235
266
  setIsLoadingMore(false);
236
267
  return () => {
237
- const timestamp = readBoundaryRef.current ?? new Date().getTime();
238
- dispatch(
239
- setLastViewedTimestamp({
240
- chatJID: activeRoomJID,
241
- timestamp,
242
- }),
268
+ const state = store.getState();
269
+ const rooms = state.rooms?.rooms;
270
+ const heapState = state.roomHeapSlice;
271
+ const boundaryTs = state.rooms?.readBoundaries?.[activeRoomJID] ?? null;
272
+ // getReadMarkerTimestamp honours the boundary (the newest message
273
+ // the user actually reached) when one is set, and otherwise falls
274
+ // back to the newest server-acked message - never the device
275
+ // clock: a fast device clock would write a future marker that the
276
+ // forward-only private-store merge could never correct (bug #38).
277
+ const timestamp = getReadMarkerTimestamp(
278
+ rooms?.[activeRoomJID],
279
+ heapState,
280
+ boundaryTs,
243
281
  );
282
+ if (timestamp > 0) {
283
+ dispatch(
284
+ setLastViewedTimestamp({
285
+ chatJID: activeRoomJID,
286
+ timestamp,
287
+ }),
288
+ );
289
+ }
244
290
  dispatch(clearVisibleRoom());
245
291
  const liveClient = clientRef.current;
246
292
  if (liveClient) {
247
293
  liveClient
248
- .flushLastViewedToPrivateStoreStanza(store.getState().rooms?.rooms, {
294
+ .flushLastViewedToPrivateStoreStanza(rooms, {
249
295
  visibleRoomJID: activeRoomJID,
250
296
  // Carry the same boundary to the SERVER marker. Without
251
- // this the flush defaults to Date.now() for the visible
297
+ // this the flush defaults to "everything" for the visible
252
298
  // room, so messages the user never scrolled down to come
253
- // back as read on the next login — the local count was
299
+ // back as read on the next login - the local count was
254
300
  // right but the server overrode it.
255
- visibleRoomTs: readBoundaryRef.current,
301
+ // Clamped through the same helper as the local stamp -
302
+ // an unclamped boundary would bypass the newest-acked
303
+ // ceiling on the one path where a bad value is permanent
304
+ // (the private-store merge is forward-only, bug #38).
305
+ visibleRoomTs: getFlushBoundaryTs(
306
+ rooms?.[activeRoomJID],
307
+ heapState,
308
+ boundaryTs,
309
+ ),
256
310
  })
257
311
  .catch(() => {});
258
312
  }
259
313
  dispatch(deleteRoomMessage({ roomJID: activeRoomJID, messageId: 'delimiter-new' }));
314
+ // The room is genuinely being left (unmount, or activeRoomJID
315
+ // changed to a different room) - release the boundary now that
316
+ // it's been consumed, so a stale value can't leak into the next
317
+ // time this room becomes visible.
318
+ dispatch(clearReadBoundary({ jid: activeRoomJID }));
319
+ lastBoundarySentRef.current = null;
260
320
  setIsLoadingMore(false);
261
321
  };
262
322
  }, [activeRoomJID, dispatch]);
@@ -292,6 +352,8 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
292
352
  const inputDockPaddingBottom = getInputDockPaddingBottom({
293
353
  platform: Platform.OS,
294
354
  bottomInset: insets.bottom,
355
+ configuredPadding: configWithEventHandlers?.inputDockPaddingBottom,
356
+ hostOwnsLayout: !!configWithEventHandlers?.disableKeyboardAvoidingView,
295
357
  });
296
358
 
297
359
  // Keyboard avoidance is delegated to react-native-keyboard-controller's
@@ -440,7 +502,7 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
440
502
  CustomMessageComponent ? (
441
503
  <CustomMessageComponent
442
504
  message={decorated.message}
443
- isUser={decorated.message.user.id === user.xmppUsername}
505
+ isUser={isOwnMessage(decorated.message, user)}
444
506
  isReply={false}
445
507
  />
446
508
  ) : null
@@ -11,7 +11,8 @@ import {
11
11
  Text,
12
12
  View,
13
13
  } from 'react-native';
14
- import { deriveDisplayFilename, isLikelyAudio } from '../../helpers/mimeToExtension';
14
+ import { isLikelyAudio } from '../../helpers/mimeToExtension';
15
+ import { getDisplayFileName } from '../../helpers/getDisplayFileName';
15
16
  import { FileIcon, PlayIcon } from '../../assets/icons';
16
17
  import { defaultMediaDims } from '../../helpers/mediaDimensions';
17
18
  import { useChatSettingState } from '../../hooks/useChatSettingState';
@@ -148,11 +149,11 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
148
149
  const messageText = appendFileToken(rawMessageText, fileToken);
149
150
 
150
151
  if (mimeType) {
151
- const displayName = deriveDisplayFilename({
152
- fileName: message?.fileName,
152
+ const displayName = getDisplayFileName({
153
153
  originalName: (message as any)?.originalName,
154
- url: rawLocation || rawMessageText,
155
- mime: mimeType,
154
+ fileName: message?.fileName,
155
+ location: rawLocation || rawMessageText,
156
+ mimetype: mimeType,
156
157
  });
157
158
  const isAudioPayload = isLikelyAudio(
158
159
  mimeType,
@@ -180,6 +181,7 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
180
181
  return (
181
182
  <CustomMessageImage
182
183
  fileName={displayName}
184
+ originalName={(message as any)?.originalName}
183
185
  // Full-size original: opened by the preview modal.
184
186
  fileURL={location || messageText || ''}
185
187
  // ...and the thumbnail the bubble actually renders, which is
@@ -192,6 +194,7 @@ const MediaMessage: React.FC<MediaMessageProps> = ({
192
194
  return (
193
195
  <CustomMessageVideo
194
196
  fileName={displayName}
197
+ originalName={(message as any)?.originalName}
195
198
  fileURL={location || ''}
196
199
  // `messageText` is the message's locationPreview — the poster
197
200
  // frame the backend generated for this video. Handing it over
@@ -1,5 +1,6 @@
1
1
  import React, { FC, Fragment, useMemo } from 'react';
2
2
  import { IConfig, IMessage } from '../../types/types';
3
+ import { isOwnMessage } from '../../helpers/isOwnMessage';
3
4
  import DateLabel from '../styled/DateLabel';
4
5
  import SystemMessage from './SystemMessage';
5
6
  import NewMessageLabel from '../styled/NewMessageLabel';
@@ -46,7 +47,10 @@ export const MessageContainer: FC<MessageContainerProps> = ({
46
47
  isReply,
47
48
  className,
48
49
  }) => {
49
- const isUser = message.user.id === walletAddress;
50
+ // Bug #41: `message.user.id === walletAddress` used to treat two
51
+ // empty/undefined ids as a match, briefly rendering catch-up messages
52
+ // from other users as our own. isOwnMessage() never does that.
53
+ const isUser = isOwnMessage(message, walletAddress);
50
54
 
51
55
  const messageDate = new Date(message.date);
52
56
 
@@ -29,6 +29,7 @@ import { ArowDownIcon } from '../../assets/icons';
29
29
  import CustomTypingIndicator from '../styled/StyledInputComponents/CustomTypingIndicator';
30
30
  import { getIconColor } from '../../helpers/getIconColor';
31
31
  import { getChatBackgroundColor } from '../../helpers/getChatBackground';
32
+ import { isOwnMessage } from '../../helpers/isOwnMessage';
32
33
 
33
34
  interface MessageListProps<TMessage extends IMessage> {
34
35
  CustomMessage?: React.ComponentType<{
@@ -438,7 +439,7 @@ const MessageList = <TMessage extends IMessage>({
438
439
  {CustomMessage && (
439
440
  <CustomMessage
440
441
  message={activeMessage}
441
- isUser={activeMessage.user.id === user.walletAddress}
442
+ isUser={isOwnMessage(activeMessage, user)}
442
443
  isReply={isReply}
443
444
  />
444
445
  )}
@@ -26,8 +26,6 @@ import { deleteRoom, updateRoom } from '../../../roomStore/roomsSlice';
26
26
  import Loader from '../../styled/Loader';
27
27
  import {
28
28
  AddNewIcon,
29
- BellIcon,
30
- BellOffIcon,
31
29
  DeleteIcon,
32
30
  EditIcon,
33
31
  LeaveIcon,
@@ -38,7 +36,7 @@ import { useChatSettingState } from '../../../hooks/useChatSettingState';
38
36
  import { chatTextStyle } from '../../../helpers/typography';
39
37
  import { getElementFont } from '../../../helpers/getElementFont';
40
38
  import { getIconColor } from '../../../helpers/getIconColor';
41
- import { deleteRoomMember, setRoomMuted } from '../../../networking/api-requests/rooms.api';
39
+ import { deleteRoomMember } from '../../../networking/api-requests/rooms.api';
42
40
  import { RoomMember } from '../../../types/models/room.model';
43
41
  import { setActiveModal, setSelectedUser } from '../../../roomStore/chatSettingsSlice';
44
42
  import { MODAL_TYPES } from '../../../helpers/constants/MODAL_TYPES';
@@ -357,40 +355,8 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
357
355
  return () => sub.remove();
358
356
  }, [isSearchOpen]); // eslint-disable-line react-hooks/exhaustive-deps
359
357
 
360
- const muteInFlightRef = useRef(false);
361
- const toggleMuted = async () => {
362
- if (!activeRoom?.jid || !activeRoom?.name || muteInFlightRef.current) {return;}
363
- const next = !activeRoom.muted;
364
- muteInFlightRef.current = true;
365
- dispatch(updateRoom({ jid: activeRoom.jid, updates: { muted: next } }));
366
- try {
367
- const confirmed = await setRoomMuted(activeRoom.name, next);
368
- if (confirmed !== next) {
369
- dispatch(updateRoom({ jid: activeRoom.jid, updates: { muted: confirmed } }));
370
- }
371
- } catch (error) {
372
- dispatch(updateRoom({ jid: activeRoom.jid, updates: { muted: !next } }));
373
- console.warn('[ethora-rn] mute toggle failed', error);
374
- showToast({
375
- id: Date.now().toString(),
376
- title: 'Error',
377
- message: t('toast.muteFailed'),
378
- type: 'error',
379
- });
380
- } finally {
381
- muteInFlightRef.current = false;
382
- }
383
- };
384
-
385
358
  const heroActions: HeroAction[] = useMemo(
386
359
  () => [
387
- {
388
- key: 'mute',
389
- label: activeRoom?.muted ? t('action.muted') : t('action.unmuted'),
390
- icon: (color: string) =>
391
- activeRoom?.muted ? <BellOffIcon color={color} /> : <BellIcon color={color} />,
392
- onPress: toggleMuted,
393
- },
394
360
  {
395
361
  key: 'search',
396
362
  label: t('action.search'),
@@ -410,7 +376,7 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
410
376
  onPress: () => setIsReportOpen(true),
411
377
  },
412
378
  ],
413
- [t, activeRoom?.jid, activeRoom?.muted] // eslint-disable-line react-hooks/exhaustive-deps
379
+ [t, activeRoom?.jid] // eslint-disable-line react-hooks/exhaustive-deps
414
380
  );
415
381
 
416
382
  // "…" menu. Edit is the chat picture — the only chat detail this SDK can
@@ -31,7 +31,12 @@ import { useToast } from '../../../context/ToastContext';
31
31
  import PdfViewer from './PdfView';
32
32
  import DocumentViewer from './DocumentViewer';
33
33
  import AudioMessage from '../../styled/AudioMessage';
34
- import { ensureFilenameHasExtension, isLikelyAudio } from '../../../helpers/mimeToExtension';
34
+ import { isLikelyAudio } from '../../../helpers/mimeToExtension';
35
+ import {
36
+ getDisplayFileName,
37
+ getUniqueFileName,
38
+ sanitizeFileNameForPath,
39
+ } from '../../../helpers/getDisplayFileName';
35
40
 
36
41
  // MIME types Google's gview embed renders reliably. Everything else
37
42
  // falls through to the info-card so the user can still download.
@@ -128,6 +133,19 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
128
133
 
129
134
  if (!activeFile) {return null;}
130
135
 
136
+ // Bug #40: prefer the sender's original name over the server's stored
137
+ // hash name everywhere this modal shows or saves/shares the file.
138
+ // Sanitized so it's safe to use as an actual path segment (strips path
139
+ // separators/control chars) when writing to disk below.
140
+ const displayFileName = sanitizeFileNameForPath(
141
+ getDisplayFileName({
142
+ originalName: activeFile.originalName,
143
+ fileName: activeFile.fileName,
144
+ location: activeFile.fileURL,
145
+ mimetype: activeFile.mimetype,
146
+ })
147
+ );
148
+
131
149
  const requestStoragePermission = async () => {
132
150
  const MediaLibrary = getMediaLibrary();
133
151
  if (!MediaLibrary?.requestPermissionsAsync) {return false;}
@@ -151,12 +169,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
151
169
  }
152
170
 
153
171
  try {
154
- const fileName = ensureFilenameHasExtension(
155
- activeFile.fileName,
156
- activeFile.mimetype
157
- );
158
-
159
- const filePath = FileSystem.cacheDirectory + fileName;
172
+ const filePath = FileSystem.cacheDirectory + displayFileName;
160
173
  const download = await FileSystem.downloadAsync(withFileToken(activeFile.fileURL), filePath);
161
174
 
162
175
  if (download.status === 200) {
@@ -171,17 +184,39 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
171
184
  Alert.alert('Error', 'Failed to save the file.');
172
185
  }
173
186
  } catch (err) {
174
- Alert.alert('Error', `Failed to save the file: ${activeFile.fileName}`);
187
+ Alert.alert('Error', `Failed to save the file: ${displayFileName}`);
188
+ }
189
+ };
190
+
191
+ // Best-effort existing-name lookup for the Android SAF branch below: SAF
192
+ // hands back content URIs, not plain names, so this decodes each one and
193
+ // takes its last path segment. If the provider's URI shape doesn't match
194
+ // what we expect, we fail open (empty set) rather than block the save -
195
+ // worst case we skip de-duplication, we never crash the save flow.
196
+ const listSafDirectoryNames = async (
197
+ saf: any,
198
+ dirUri: string
199
+ ): Promise<Set<string>> => {
200
+ try {
201
+ const uris: string[] = await saf.readDirectoryAsync(dirUri);
202
+ const names = uris.map((uri) => {
203
+ try {
204
+ const decoded = decodeURIComponent(uri);
205
+ const last = decoded.split('/').pop() || '';
206
+ return last.includes(':') ? last.split(':').pop() || '' : last;
207
+ } catch {
208
+ return '';
209
+ }
210
+ });
211
+ return new Set(names.filter(Boolean));
212
+ } catch {
213
+ return new Set<string>();
175
214
  }
176
215
  };
177
216
 
178
217
  const saveFileToDownloads = async () => {
179
218
  try {
180
- const fileName = ensureFilenameHasExtension(
181
- activeFile.fileName,
182
- activeFile.mimetype
183
- );
184
- const filePath = FileSystem.cacheDirectory + fileName;
219
+ const filePath = FileSystem.cacheDirectory + displayFileName;
185
220
  const download = await FileSystem.downloadAsync(withFileToken(activeFile.fileURL), filePath);
186
221
 
187
222
  if (download.status !== 200) {
@@ -205,7 +240,16 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
205
240
  safDirUriRef.current = dirUri;
206
241
  }
207
242
 
208
- const baseName = fileName.replace(/\.[^/.]+$/, '');
243
+ // Avoid silently overwriting a different attachment that happens
244
+ // to share the same display name (common now that we prefer the
245
+ // human-picked name over the unique server hash - bug #40) by
246
+ // suffixing " (1)", " (2)", ... on a collision.
247
+ const existingNames = await listSafDirectoryNames(saf, dirUri as string);
248
+ const uniqueName = await getUniqueFileName(
249
+ displayFileName,
250
+ async (candidate) => existingNames.has(candidate)
251
+ );
252
+ const baseName = uniqueName.replace(/\.[^/.]+$/, '');
209
253
  const mime = activeFile.mimetype || 'application/octet-stream';
210
254
  const base64 = await FileSystem.readAsStringAsync(download.uri, {
211
255
  encoding: FileSystem.EncodingType.Base64,
@@ -232,7 +276,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
232
276
  // user may cancel), so we don't show a "Save successful" toast here.
233
277
  await Share.share({
234
278
  url: download.uri,
235
- title: fileName,
279
+ title: displayFileName,
236
280
  });
237
281
  } catch (err) {
238
282
  console.error('Error saving file:', err);
@@ -279,7 +323,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
279
323
  'https://as2.ftcdn.net/v2/jpg/02/51/95/53/1000_F_251955356_FAQH0U1y1TZw3ZcdPGybwUkH90a3VAhb.jpg',
280
324
  }}
281
325
  resizeMode="contain"
282
- accessibilityLabel={activeFile.fileName}
326
+ accessibilityLabel={displayFileName}
283
327
  />
284
328
  );
285
329
  case activeFile.mimetype.startsWith('video/'):
@@ -333,10 +377,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
333
377
  }}
334
378
  >
335
379
  <Text style={{ fontSize: 16, fontWeight: '600' }}>
336
- {ensureFilenameHasExtension(
337
- activeFile.originalName || activeFile.fileName,
338
- activeFile.mimetype
339
- )}
380
+ {displayFileName}
340
381
  </Text>
341
382
  <AudioMessage
342
383
  src={withFileToken(activeFile.fileURL)}
@@ -351,10 +392,6 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
351
392
  case activeFile.mimetype === 'application/pdf':
352
393
  return <PdfViewer pdfUrl={withFileToken(activeFile.fileURL)} />;
353
394
  default: {
354
- const displayName = ensureFilenameHasExtension(
355
- activeFile.fileName,
356
- activeFile.mimetype
357
- );
358
395
  // Office docs (.docx / .xlsx / .pptx / .doc / .xls / .ppt /
359
396
  // .txt / .csv / .rtf) → render inline via Google's gview embed
360
397
  // — fixes the "blank preview" complaint for docs (bug #9).
@@ -362,7 +399,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
362
399
  return (
363
400
  <DocumentViewer
364
401
  url={activeFile.fileURL}
365
- fileName={displayName}
402
+ fileName={displayFileName}
366
403
  />
367
404
  );
368
405
  }
@@ -379,7 +416,7 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
379
416
  }}
380
417
  >
381
418
  <Text style={{ fontSize: 16, fontWeight: '600' }}>
382
- {displayName}
419
+ {displayFileName}
383
420
  </Text>
384
421
  <Text style={{ color: '#666' }}>
385
422
  {activeFile.mimetype || 'unknown type'}
@@ -14,9 +14,13 @@ import {
14
14
  isSecureFileUrl,
15
15
  requestFileTokenRecovery,
16
16
  } from '../../../helpers/secureFileUrl';
17
+ import { getDisplayFileName } from '../../../helpers/getDisplayFileName';
17
18
 
18
19
  interface LastMessageFileProps
19
- extends Pick<LastMessage, 'user' | 'originalName' | 'locationPreview'> {}
20
+ extends Pick<
21
+ LastMessage,
22
+ 'user' | 'originalName' | 'fileName' | 'mimetype' | 'locationPreview'
23
+ > {}
20
24
 
21
25
  const fallbackImage =
22
26
  'https://as2.ftcdn.net/v2/jpg/02/51/95/53/1000_F_251955356_FAQH0U1y1TZw3ZcdPGybwUkH90a3VAhb.jpg';
@@ -24,10 +28,18 @@ const fallbackImage =
24
28
  const LastMessageFile: FC<LastMessageFileProps> = ({
25
29
  user,
26
30
  originalName,
31
+ fileName,
32
+ mimetype,
27
33
  locationPreview,
28
34
  }) => {
29
35
  const [imgSrc, setImgSrc] = useState(locationPreview);
30
36
  const fileToken = useFileToken();
37
+ // Bug #40: prefer the sender's original name over the stored hash
38
+ // name; only fall back to the generic "file" label when neither is
39
+ // present at all (avoids a synthetic "media_<timestamp>.bin" here).
40
+ const displayName = originalName || fileName
41
+ ? getDisplayFileName({ originalName, fileName, mimetype })
42
+ : 'file';
31
43
 
32
44
  return (
33
45
  <LastRoomMessageContainer>
@@ -57,7 +69,7 @@ const LastMessageFile: FC<LastMessageFileProps> = ({
57
69
  ) : (
58
70
  <FileIcon style={{ width: '20px', height: '20px' }} />
59
71
  )}
60
- <LastRoomMessageText>{originalName || 'file'}</LastRoomMessageText>
72
+ <LastRoomMessageText>{displayName}</LastRoomMessageText>
61
73
  </View>
62
74
  </LastRoomMessageContainer>
63
75
  );
@@ -11,6 +11,7 @@ import {
11
11
  ShadeWrapper,
12
12
  } from './StyledRoomComponents';
13
13
  import { View } from 'react-native';
14
+ import { getDisplayFileName } from '../../../helpers/getDisplayFileName';
14
15
 
15
16
  const PhotoContainer = styled.View`
16
17
  position: relative;
@@ -25,14 +26,25 @@ const PhotoContainer = styled.View`
25
26
  `;
26
27
 
27
28
  interface LastMessagePhotoProps
28
- extends Pick<LastMessage, 'user' | 'originalName' | 'locationPreview'> {}
29
+ extends Pick<
30
+ LastMessage,
31
+ 'user' | 'originalName' | 'fileName' | 'mimetype' | 'locationPreview'
32
+ > {}
29
33
 
30
34
  const LastMessagePhoto: FC<LastMessagePhotoProps> = ({
31
35
  user,
32
36
  locationPreview,
33
37
  originalName,
38
+ fileName,
39
+ mimetype,
34
40
  }) => {
35
41
  const fileToken = useFileToken();
42
+ // Bug #40: prefer the sender's original name over the stored hash
43
+ // name; only fall back to the generic "file" label when neither is
44
+ // present at all (avoids a synthetic "media_<timestamp>.bin" here).
45
+ const displayName = originalName || fileName
46
+ ? getDisplayFileName({ originalName, fileName, mimetype })
47
+ : 'file';
36
48
  return (
37
49
  <LastRoomMessageContainer>
38
50
  <LastRoomMessageName>{user?.name || ''}:</LastRoomMessageName>
@@ -48,7 +60,7 @@ const LastMessagePhoto: FC<LastMessagePhotoProps> = ({
48
60
  <LastMessageImg src={appendFileToken(locationPreview, fileToken)} />
49
61
  </ShadeWrapper>
50
62
  </PhotoContainer>
51
- <LastRoomMessageText>{originalName || 'file'}</LastRoomMessageText>
63
+ <LastRoomMessageText>{displayName}</LastRoomMessageText>
52
64
  </View>
53
65
  </LastRoomMessageContainer>
54
66
  );
@@ -10,6 +10,7 @@ import {
10
10
  ShadeWrapper,
11
11
  } from './StyledRoomComponents';
12
12
  import { Text, View } from 'react-native';
13
+ import { getDisplayFileName } from '../../../helpers/getDisplayFileName';
13
14
 
14
15
  const VideoContainer = styled.View`
15
16
  position: relative;
@@ -40,14 +41,25 @@ const PlayButton = styled.View`
40
41
  `;
41
42
 
42
43
  interface LastMessageVideoProps
43
- extends Pick<LastMessage, 'user' | 'location' | 'originalName'> {}
44
+ extends Pick<
45
+ LastMessage,
46
+ 'user' | 'location' | 'originalName' | 'fileName' | 'mimetype'
47
+ > {}
44
48
 
45
49
  const LastMessageVideo: FC<LastMessageVideoProps> = ({
46
50
  user,
47
51
  location,
48
52
  originalName,
53
+ fileName,
54
+ mimetype,
49
55
  }) => {
50
56
  const fileToken = useFileToken();
57
+ // Bug #40: prefer the sender's original name over the stored hash
58
+ // name; only fall back to the generic "file" label when neither is
59
+ // present at all (avoids a synthetic "media_<timestamp>.bin" here).
60
+ const displayName = originalName || fileName
61
+ ? getDisplayFileName({ originalName, fileName, mimetype })
62
+ : 'file';
51
63
  return (
52
64
  <LastRoomMessageContainer>
53
65
  <LastRoomMessageName>{user?.name || ''}:</LastRoomMessageName>
@@ -64,7 +76,7 @@ const LastMessageVideo: FC<LastMessageVideoProps> = ({
64
76
  </ShadeWrapper>
65
77
  <PlayButton><Text>▶</Text></PlayButton>
66
78
  </VideoContainer>
67
- <LastRoomMessageText>{originalName || 'file'}</LastRoomMessageText>
79
+ <LastRoomMessageText>{displayName}</LastRoomMessageText>
68
80
  </View>
69
81
  </LastRoomMessageContainer>
70
82
  );
@@ -22,6 +22,12 @@ interface CustomMessageImageProps {
22
22
  /** Full-size file. Opened in the preview modal, NOT rendered inline. */
23
23
  fileURL: string;
24
24
  fileName: string;
25
+ /**
26
+ * The sender's original file name (bug #40) - kept alongside `fileName`
27
+ * so the preview modal's save/share/download logic can prefer it over
28
+ * the server's stored/hashed name.
29
+ */
30
+ originalName?: string;
25
31
  mimetype: string;
26
32
  /**
27
33
  * Server-generated thumbnail — what the bubble actually renders, same
@@ -42,6 +48,7 @@ const FALLBACK_IMAGE =
42
48
  const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
43
49
  fileURL,
44
50
  fileName,
51
+ originalName,
45
52
  mimetype,
46
53
  locationPreview,
47
54
  }) => {
@@ -84,7 +91,7 @@ const CustomMessageImage: React.FC<CustomMessageImageProps> = ({
84
91
  }, [displayURL]);
85
92
 
86
93
  const handleOpen = () => {
87
- dispatch(setActiveFile({ fileName, fileURL, mimetype }));
94
+ dispatch(setActiveFile({ fileName, fileURL, mimetype, originalName }));
88
95
  dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
89
96
  };
90
97