@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
@@ -18,6 +18,7 @@ import { ActivityIndicator, View } from 'react-native';
18
18
  import { useChatSettingState } from '../../hooks/useChatSettingState';
19
19
  import { FileIcon } from '../../assets/icons';
20
20
  import { getExtensionForMime } from '../../helpers/mimeToExtension';
21
+ import { getDisplayFileName } from '../../helpers/getDisplayFileName';
21
22
 
22
23
  interface FileDownloadProps {
23
24
  fileName: string;
@@ -47,6 +48,15 @@ const FileDownload: React.FC<FileDownloadProps> = ({
47
48
  const dispatch = useDispatch();
48
49
  const { config } = useChatSettingState();
49
50
 
51
+ // Bug #40: prefer the sender's original name over the server's stored
52
+ // hash name for both the visible label and the extension badge below.
53
+ const displayName = getDisplayFileName({
54
+ originalName,
55
+ fileName,
56
+ location: fileURL,
57
+ mimetype,
58
+ });
59
+
50
60
  const formatFileSize = (sizeInBytes: string): string => {
51
61
  const size = parseInt(sizeInBytes, 10);
52
62
 
@@ -101,9 +111,9 @@ const FileDownload: React.FC<FileDownloadProps> = ({
101
111
  };
102
112
 
103
113
  const extensionLabel = (() => {
104
- const dotIndex = fileName.lastIndexOf('.');
105
- if (dotIndex !== -1 && dotIndex < fileName.length - 1) {
106
- return fileName.slice(dotIndex + 1).toUpperCase();
114
+ const dotIndex = displayName.lastIndexOf('.');
115
+ if (dotIndex !== -1 && dotIndex < displayName.length - 1) {
116
+ return displayName.slice(dotIndex + 1).toUpperCase();
107
117
  }
108
118
  return getExtensionForMime(mimetype).replace('.', '').toUpperCase();
109
119
  })();
@@ -123,7 +133,7 @@ const FileDownload: React.FC<FileDownloadProps> = ({
123
133
  isUser={isUser}
124
134
  colorIsUser={config?.colors?.primary}
125
135
  colorUsers={config?.colors?.secondary}
126
- >{formatFileName(fileName, 20)}</FileName>
136
+ >{formatFileName(displayName, 20)}</FileName>
127
137
  {(pending || size) && (
128
138
  <FileSizeContainer>
129
139
  {pending ? (
@@ -19,6 +19,12 @@ import {
19
19
 
20
20
  interface CustomMessageVideoProps {
21
21
  fileName: string;
22
+ /**
23
+ * The sender's original file name (bug #40) - kept alongside `fileName`
24
+ * so the preview modal's save/share/download logic can prefer it over
25
+ * the server's stored/hashed name.
26
+ */
27
+ originalName?: string;
22
28
  fileURL: string;
23
29
  mimetype: string;
24
30
  /** The backend-generated poster frame (message.locationPreview). */
@@ -126,6 +132,7 @@ const PlaceholderVideo: React.FC<{
126
132
 
127
133
  const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
128
134
  fileName,
135
+ originalName,
129
136
  fileURL,
130
137
  mimetype,
131
138
  previewURL,
@@ -134,7 +141,7 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
134
141
  const [posterFailed, setPosterFailed] = useState(false);
135
142
 
136
143
  const handleOpen = () => {
137
- dispatch(setActiveFile({ fileName, fileURL, mimetype }));
144
+ dispatch(setActiveFile({ fileName, fileURL, mimetype, originalName }));
138
145
  dispatch(setActiveModal(MODAL_TYPES.FILE_PREVIEW));
139
146
  };
140
147
 
@@ -46,9 +46,12 @@ import {
46
46
  clearVisibleRoom,
47
47
  setLastViewedTimestamp,
48
48
  deleteRoomMessage,
49
- msgSortableMs,
50
49
  setUnreadSyncing,
51
50
  } from '../roomStore/roomsSlice';
51
+ import {
52
+ getFlushBoundaryTs,
53
+ getReadMarkerTimestamp,
54
+ } from '../helpers/getServerReadTimestamp';
52
55
  import { runHistoryPreloadScheduler } from '../helpers/historyPreloadScheduler';
53
56
  import { updateMessagesTillLast } from '../helpers/updateMessagesTillLast';
54
57
  import { secureUserStorage } from '../helpers/secureUserStorage';
@@ -638,16 +641,43 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
638
641
  // Mark the open room "not visible" while backgrounded so messages
639
642
  // that arrive (or MAM-replay on reconnect) count as unread instead
640
643
  // of being silently treated as read — the "mounted == visible ==
641
- // read" gap. Stamp lastViewed=now as the read baseline, clear
642
- // visibility, then flush that marker to the server private store.
643
- // Restored on the next 'active' transition above. Handled here in
644
- // the provider so consumers using the component as a package get
645
- // correct unread without reaching into the chat store themselves.
644
+ // read" gap. Stamp lastViewed=<the read boundary, if the user left
645
+ // scrolled up, else the newest server-acked message>, not
646
+ // Date.now(), as the read baseline: a device clock running ahead
647
+ // would otherwise write a future marker that the forward-only
648
+ // private-store merge can never correct again (bug #38). Then
649
+ // clear visibility and flush that marker to the server private
650
+ // store. Restored on the next 'active' transition above. Handled
651
+ // here in the provider so consumers using the component as a
652
+ // package get correct unread without reaching into the chat store
653
+ // themselves.
654
+ //
655
+ // The read boundary (rooms.readBoundaries) is deliberately left
656
+ // untouched here - MessageList normally stays mounted through a
657
+ // simple background/foreground cycle, so its own scroll-tracking
658
+ // ref (the only thing that could re-derive this value) is still
659
+ // intact, and clearing the redux copy would desync the two. It's
660
+ // only USED here for the one-shot stamp+flush, matching customer
661
+ // #42: leaving (or backgrounding) a room while scrolled up must not
662
+ // mark messages the user never reached as read.
646
663
  visibleBeforeBackground = visibleRoomJID;
664
+ let boundaryTs: number | null = null;
647
665
  if (visibleRoomJID) {
648
- store.dispatch(
649
- setLastViewedTimestamp({ chatJID: visibleRoomJID, timestamp: Date.now() })
666
+ boundaryTs = state.rooms?.readBoundaries?.[visibleRoomJID] ?? null;
667
+ const readTs = getReadMarkerTimestamp(
668
+ rooms?.[visibleRoomJID],
669
+ state.roomHeapSlice,
670
+ boundaryTs
650
671
  );
672
+ // Nothing to anchor to yet (no known messages, no prior marker)
673
+ // - skip the local stamp rather than fall back to the device
674
+ // clock. The flush below still runs so the visibility clear
675
+ // isn't blocked on this.
676
+ if (readTs > 0) {
677
+ store.dispatch(
678
+ setLastViewedTimestamp({ chatJID: visibleRoomJID, timestamp: readTs })
679
+ );
680
+ }
651
681
  // Drop the "New messages" divider for the room we're leaving so it
652
682
  // doesn't linger when the user comes back (mirrors ChatRoom's
653
683
  // unmount cleanup, which never runs while the pane stays mounted).
@@ -657,10 +687,21 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
657
687
  store.dispatch(clearVisibleRoom());
658
688
  }
659
689
  // Fire-and-forget — we're going to the background and don't
660
- // care about the resolution path.
661
- c.flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID }).catch(
662
- () => {}
663
- );
690
+ // care about the resolution path. `visibleRoomTs` carries the same
691
+ // boundary to the SERVER marker - without it the flush defaults to
692
+ // "everything" for the visible room, overriding a correct smaller
693
+ // local count.
694
+ c.flushLastViewedToPrivateStoreStanza(rooms, {
695
+ visibleRoomJID,
696
+ // Clamped through the same helper as the local stamp above.
697
+ visibleRoomTs: visibleRoomJID
698
+ ? getFlushBoundaryTs(
699
+ rooms?.[visibleRoomJID],
700
+ state.roomHeapSlice,
701
+ boundaryTs
702
+ )
703
+ : undefined,
704
+ }).catch(() => {});
664
705
  });
665
706
  return () => sub.remove();
666
707
  }, [client, config?.xmppSettings?.keepAliveInBackground]);
@@ -672,11 +713,11 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
672
713
  // "shown" from "hidden", so the unread middleware would keep the open
673
714
  // room's badge at 0 and messages arriving while you're elsewhere look
674
715
  // already-read. When the host passes `isVisible`, mirror the AppState
675
- // background/foreground handling: hidden → stamp lastViewed=now + clear
676
- // room visibility (so later messages count) + flush; shown → restore
677
- // visibility for the open room. Doing it here means consumers never
678
- // touch the chat store themselves. (Undefined = host unmounts on hide,
679
- // nothing to do.)
716
+ // background/foreground handling: hidden → stamp lastViewed=<newest
717
+ // server-acked message> + clear room visibility (so later messages
718
+ // count) + flush; shown → restore visibility for the open room. Doing
719
+ // it here means consumers never touch the chat store themselves.
720
+ // (Undefined = host unmounts on hide, nothing to do.)
680
721
  // -----------------------------------------------------------
681
722
  const chatWasVisibleRef = useRef(false);
682
723
  useEffect(() => {
@@ -691,10 +732,30 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
691
732
  store.dispatch(setVisibleRoom({ roomJID: activeRoomJID }));
692
733
  }
693
734
  } else {
735
+ // The read boundary (rooms.readBoundaries) is USED for the stamp
736
+ // and flush below but deliberately left in place - a host using
737
+ // `isVisible` keeps <Chat> mounted while hidden, so MessageList's
738
+ // own scroll-tracking ref is still intact and the redux copy must
739
+ // stay in sync with it. Only consumed, never Date.now(): a fast
740
+ // device clock would write a future marker that the forward-only
741
+ // private-store merge could then never correct (bug #38). And
742
+ // never "the newest acked message" unconditionally either - that
743
+ // silently marks messages the user scrolled past and never reached
744
+ // as read (bug #42, a regression of #33).
745
+ const boundaryTs = activeRoomJID
746
+ ? state.rooms?.readBoundaries?.[activeRoomJID] ?? null
747
+ : null;
694
748
  if (wasVisible && activeRoomJID) {
695
- store.dispatch(
696
- setLastViewedTimestamp({ chatJID: activeRoomJID, timestamp: Date.now() })
749
+ const readTs = getReadMarkerTimestamp(
750
+ rooms?.[activeRoomJID],
751
+ state.roomHeapSlice,
752
+ boundaryTs
697
753
  );
754
+ if (readTs > 0) {
755
+ store.dispatch(
756
+ setLastViewedTimestamp({ chatJID: activeRoomJID, timestamp: readTs })
757
+ );
758
+ }
698
759
  // Drop the "New messages" divider so it's gone when the user
699
760
  // returns to the chat (mirrors ChatRoom's unmount cleanup).
700
761
  store.dispatch(
@@ -705,7 +766,19 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
705
766
  store.dispatch(clearVisibleRoom());
706
767
  if (wasVisible && client?.flushLastViewedToPrivateStoreStanza) {
707
768
  client
708
- .flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID: activeRoomJID })
769
+ .flushLastViewedToPrivateStoreStanza(rooms, {
770
+ visibleRoomJID: activeRoomJID,
771
+ // Carry the same boundary to the SERVER marker - without it
772
+ // the flush defaults to "everything" for the visible room.
773
+ // Clamped exactly like the local stamp above.
774
+ visibleRoomTs: activeRoomJID
775
+ ? getFlushBoundaryTs(
776
+ rooms?.[activeRoomJID],
777
+ state.roomHeapSlice,
778
+ boundaryTs
779
+ )
780
+ : undefined,
781
+ })
709
782
  .catch(() => {});
710
783
  }
711
784
  }
@@ -726,26 +799,45 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
726
799
  if (room.messages === lastMessagesRef) {return;}
727
800
  lastMessagesRef = room.messages;
728
801
 
729
- let newest = 0;
730
- const list = room.messages || [];
731
- for (const m of list) {
732
- if (!m || m.id === 'delimiter-new') {continue;}
733
- const ms = msgSortableMs(m);
734
- if (ms > newest) {newest = ms;}
735
- }
736
- if (newest <= lastStampedMs) {return;}
802
+ // getReadMarkerTimestamp (not a hand-rolled scan + Date.now()):
803
+ // it excludes pending/optimistic messages, which carry the DEVICE
804
+ // send time via `date` until the server echoes them back - a
805
+ // manual scan without that exclusion could pick up a pending
806
+ // message's device timestamp here and write a future marker the
807
+ // same way a raw Date.now() would (bug #38). And when the user has
808
+ // scrolled up (a boundary is set for this room), it clamps to that
809
+ // boundary instead of the newest message - without this, every
810
+ // incoming message while scrolled up advanced the marker to
811
+ // "newest" regardless of scroll position, and the later, smaller
812
+ // boundary write on leave was rejected by the forward-only
813
+ // private-store merge, leaving 0 unread even after a restart
814
+ // (customer #42, a regression of #33 reintroduced by #38).
815
+ const boundaryTs = s.rooms?.readBoundaries?.[jid] ?? null;
816
+ const newest = getReadMarkerTimestamp(room, s.roomHeapSlice, boundaryTs);
817
+ if (!newest || newest <= lastStampedMs) {return;}
737
818
  lastStampedMs = newest;
738
819
 
739
820
  store.dispatch(
740
- setLastViewedTimestamp({ chatJID: jid, timestamp: Date.now() })
821
+ setLastViewedTimestamp({ chatJID: jid, timestamp: newest })
741
822
  );
742
823
 
743
824
  if (flushTimer) {clearTimeout(flushTimer);}
744
825
  if (client?.flushLastViewedToPrivateStoreStanza) {
745
826
  flushTimer = setTimeout(() => {
827
+ // Re-read fresh state rather than closing over `boundaryTs`:
828
+ // the user may have scrolled back to the bottom (clearing the
829
+ // boundary) in the 2s between the stamp and this flush, and
830
+ // the flush should reflect that.
831
+ const freshState = store.getState();
832
+ const freshBoundaryTs = freshState.rooms?.readBoundaries?.[jid] ?? null;
746
833
  client
747
- .flushLastViewedToPrivateStoreStanza(store.getState().rooms?.rooms, {
834
+ .flushLastViewedToPrivateStoreStanza(freshState.rooms?.rooms, {
748
835
  visibleRoomJID: jid,
836
+ visibleRoomTs: getFlushBoundaryTs(
837
+ freshState.rooms?.rooms?.[jid],
838
+ freshState.roomHeapSlice,
839
+ freshBoundaryTs
840
+ ),
749
841
  })
750
842
  .catch(() => {});
751
843
  }, 2000);
@@ -93,6 +93,25 @@ export const createMessageFromXml = async (
93
93
  const senderLocal = senderJID.includes('@')
94
94
  ? senderJID.split('@')[0]
95
95
  : senderJID;
96
+
97
+ // Bug #41 root cause: the resource of the MUC `from` (room@conference/
98
+ // occupant-id) is the SAME field getDataFromXml uses to derive
99
+ // `user.id` for live messages and for the wrapped MAM convention
100
+ // (getHistory.xmpp.ts) - it's stamped by the server itself when the
101
+ // sender joined the room, so it's always present and always correct.
102
+ // The positional convention (onMessageHistory's MAM catch-up path)
103
+ // never called getDataFromXml, so it fell back to splitting the
104
+ // sender-supplied `<data senderJID>` attr on '@' instead - usually the
105
+ // same value, but WRONG (picks up the room's own id, not the
106
+ // sender's) whenever senderJID is absent from the stanza, and simply a
107
+ // different derivation than the live path even when it happens to
108
+ // agree. Prefer the resource-of-`from` here too so every path that
109
+ // reaches this function resolves the same sender identity the same
110
+ // way, instead of MAM catch-up rows needing a later correction once
111
+ // the "official" fetch lands.
112
+ const xmppFrom = (merged.xmppFrom || '').toString();
113
+ const fromResource = xmppFrom.includes('/') ? xmppFrom.split('/')[1] : '';
114
+
96
115
  const composed = [firstName, lastName].filter(Boolean).join(' ').trim();
97
116
  const existingName = String(merged?.user?.name || '').trim();
98
117
  const resolvedName =
@@ -107,7 +126,7 @@ export const createMessageFromXml = async (
107
126
  ...merged,
108
127
  user: {
109
128
  ...(merged.user || {}),
110
- id: merged?.user?.id || senderLocal,
129
+ id: merged?.user?.id || fromResource || senderLocal,
111
130
  name: resolvedName,
112
131
  firstName: firstName || merged?.user?.firstName,
113
132
  lastName: lastName || merged?.user?.lastName,
@@ -0,0 +1,87 @@
1
+ import { ensureFilenameHasExtension, filenameFromUrl } from './mimeToExtension';
2
+
3
+ export interface DisplayFileNameOptions {
4
+ originalName?: string | null;
5
+ fileName?: string | null;
6
+ location?: string | null;
7
+ mimetype?: string | null;
8
+ }
9
+
10
+ /**
11
+ * Picks the best human-readable filename for a chat attachment - the
12
+ * single source of truth for every place a file name is rendered in the
13
+ * UI, saved to disk, shared, or downloaded.
14
+ *
15
+ * Priority: `originalName` (the sender's own file name, e.g. "test1.pdf")
16
+ * beats `fileName` (the server's stored/hashed name, e.g.
17
+ * "3f9a1c...c2.pdf") beats the last path segment of `location`. The
18
+ * stored hash name is only a fallback of last resort.
19
+ *
20
+ * Bug #40: display and save/share/download logic used to prefer the
21
+ * stored hash name over the original one, so an attachment sent as
22
+ * "test1.pdf" showed up (and got saved to the phone) as something like
23
+ * "3f9a1c...c2.pdf" - both directions (web -> RN and RN -> RN).
24
+ *
25
+ * Always returns a non-empty string with a valid extension: if the
26
+ * chosen candidate has no extension (a hash name can be `<hex>` with no
27
+ * extension at all, or `<hex>.<ext>`), one is appended from `mimetype`
28
+ * (or `.bin` as a last resort when the mime is unknown/missing too).
29
+ */
30
+ export function getDisplayFileName(options: DisplayFileNameOptions): string {
31
+ const candidate =
32
+ (options.originalName && options.originalName.trim()) ||
33
+ (options.fileName && options.fileName.trim()) ||
34
+ filenameFromUrl(options.location) ||
35
+ '';
36
+ return ensureFilenameHasExtension(candidate, options.mimetype);
37
+ }
38
+
39
+ /**
40
+ * Makes a display name safe to use as an actual path segment when
41
+ * saving/sharing a file to the device: strips path separators (so an
42
+ * odd/hostile server-provided name can't escape the target directory)
43
+ * and control characters. Keeps the rest of the name - including the
44
+ * extension - intact.
45
+ */
46
+ export function sanitizeFileNameForPath(name: string): string {
47
+ const cleaned = (name || '')
48
+ .replace(/[\/\\]/g, '_')
49
+ // eslint-disable-next-line no-control-regex
50
+ .replace(/[\x00-\x1f\x7f]/g, '')
51
+ .trim();
52
+ return cleaned || 'file';
53
+ }
54
+
55
+ /**
56
+ * Resolves a filename that is safe to write into a target directory:
57
+ * sanitizes it, then - if `exists` reports a collision - inserts
58
+ * " (1)", " (2)", ... before the extension until a free name is found,
59
+ * so saving two different attachments that share a display name never
60
+ * silently overwrites the first one.
61
+ *
62
+ * `exists` is injected so callers can back it with whatever existence
63
+ * check makes sense for their storage API (expo-file-system,
64
+ * StorageAccessFramework directory listing, ...) - this function stays
65
+ * pure and easy to unit test.
66
+ */
67
+ export async function getUniqueFileName(
68
+ name: string,
69
+ exists: (candidateName: string) => Promise<boolean>
70
+ ): Promise<string> {
71
+ const safeName = sanitizeFileNameForPath(name);
72
+ if (!(await exists(safeName))) {
73
+ return safeName;
74
+ }
75
+ const dotIndex = safeName.lastIndexOf('.');
76
+ const base = dotIndex > 0 ? safeName.slice(0, dotIndex) : safeName;
77
+ const ext = dotIndex > 0 ? safeName.slice(dotIndex) : '';
78
+ // 999 collisions is already a pathological case; fall back to a
79
+ // timestamp suffix rather than looping forever.
80
+ for (let i = 1; i < 1000; i += 1) {
81
+ const candidate = `${base} (${i})${ext}`;
82
+ if (!(await exists(candidate))) {
83
+ return candidate;
84
+ }
85
+ }
86
+ return `${base} (${Date.now()})${ext}`;
87
+ }
@@ -0,0 +1,197 @@
1
+ import type { IMessage, IRoom } from '../types/types';
2
+ import { msgSortableMs } from './msgSortableMs';
3
+
4
+ // A stored/stamped read marker more than this far past the newest
5
+ // message we actually know about for a room is not "the user is a
6
+ // little ahead" - it's a leftover from a device whose clock was wrong
7
+ // when it stamped the marker (bug #38). Generous on purpose: this value
8
+ // only controls when we're willing to treat an existing marker as
9
+ // corrupt and self-heal it, never how far off a legitimate new write can
10
+ // be, so a false positive here just means a bad value survives one extra
11
+ // cycle, not that a good value gets rejected.
12
+ export const FUTURE_READ_MARKER_TOLERANCE_MS = 5 * 60 * 1000;
13
+
14
+ export interface ReadTimestampHeapState {
15
+ messageHeap?: IMessage[] | null;
16
+ failedMessages?: Record<string, unknown> | null;
17
+ }
18
+
19
+ type RoomForReadTimestamp = Pick<IRoom, 'jid' | 'messages' | 'lastViewedTimestamp'>;
20
+
21
+ /**
22
+ * Resolve the "read up to here" marker for `room` using SERVER time,
23
+ * never the device clock.
24
+ *
25
+ * Bug #38: if the device clock is ahead when the user leaves a room, a
26
+ * `Date.now()` read marker lands in the future. The private-store merge
27
+ * (flushLastViewedToPrivateStore.ts) is forward-only ("if (prevNum >=
28
+ * ts) continue"), so once a future marker is written, no later
29
+ * *correct* write can ever replace it - the room's unread count is
30
+ * stuck wrong forever, on every device, from that point on. Anchoring
31
+ * to the newest SERVER-acknowledged message's own timestamp instead
32
+ * means the marker we write can never be ahead of what the server
33
+ * itself has recorded for the room.
34
+ *
35
+ * "Server-acknowledged" excludes:
36
+ * - a message still marked `pending` (the optimistic bubble shown
37
+ * while a send is in flight - it carries the DEVICE send time via
38
+ * `date`, not a server timestamp, until the server echoes it back
39
+ * and insertMessageWithDelimiter flips `pending` off);
40
+ * - a message id present in `heapState.messageHeap` (queued retry -
41
+ * used for offline media sends, see roomHeapSlice.ts) or
42
+ * `heapState.failedMessages` (a send that never reached the server
43
+ * at all).
44
+ *
45
+ * `msgSortableMs` (helpers/msgSortableMs.ts) reads the server-assigned microsecond
46
+ * timestamp encoded in the first 13 digits of the message id - the same
47
+ * source already used for message ordering and unread counting - so
48
+ * this stays consistent with the rest of the read/unread pipeline.
49
+ *
50
+ * Falls back to the room's existing `lastViewedTimestamp` (so leaving
51
+ * an empty or never-scrolled room doesn't regress the marker), then to
52
+ * `0` when there's truly nothing to anchor to yet (a brand new room
53
+ * with no history and no prior marker). Callers should treat `0` as
54
+ * "skip the write" rather than substituting a device-clock guess.
55
+ */
56
+ export function getServerReadTimestamp(
57
+ room: RoomForReadTimestamp | null | undefined,
58
+ heapState?: ReadTimestampHeapState | null
59
+ ): number {
60
+ if (!room) {return 0;}
61
+
62
+ const heapIds = new Set(
63
+ (heapState?.messageHeap || [])
64
+ .filter((m) => m && m.id != null)
65
+ .map((m) => String(m.id))
66
+ );
67
+ const failedIds = new Set(Object.keys(heapState?.failedMessages || {}));
68
+
69
+ let newest = 0;
70
+ const messages = room.messages || [];
71
+ for (const m of messages) {
72
+ if (!m || !m.id) {continue;}
73
+ const id = String(m.id);
74
+ if (id === 'delimiter-new' || id === 'delimiter-new-local') {continue;}
75
+ if ((m as any).pending) {continue;}
76
+ if (heapIds.has(id) || failedIds.has(id)) {continue;}
77
+
78
+ const ts = msgSortableMs(m);
79
+ if (ts > newest) {newest = ts;}
80
+ }
81
+
82
+ if (newest > 0) {return newest;}
83
+ if (room.lastViewedTimestamp && room.lastViewedTimestamp > 0) {
84
+ return room.lastViewedTimestamp;
85
+ }
86
+ return 0;
87
+ }
88
+
89
+ /**
90
+ * Is `marker` a leftover from a device whose clock was wrong when it
91
+ * stamped a read marker (bug #38)?
92
+ *
93
+ * TWO independent signals must agree before we're willing to throw a
94
+ * stored marker away, because "waive the forward-only guard" is a
95
+ * destructive operation:
96
+ *
97
+ * 1. it sits well past the newest message we know the room has, AND
98
+ * 2. it sits well past real time.
99
+ *
100
+ * Signal 1 alone is NOT enough, and that is the whole point of this
101
+ * function: a device whose local cache for a room is simply stale (very
102
+ * common - any room whose history hasn't been synced yet this session)
103
+ * sees a perfectly legitimate marker written by another device that HAS
104
+ * seen newer messages, and would happily "heal" it backwards, resurrecting
105
+ * already-read messages as unread on every device. A real timestamp can
106
+ * never be ahead of real time, so requiring signal 2 as well means a
107
+ * stale cache on its own can no longer trigger the self-heal.
108
+ *
109
+ * Signal 2 alone is not used either: it would let a device whose own
110
+ * clock is running SLOW mistake every correct marker for a corrupt one.
111
+ */
112
+ export function isCorruptFutureReadMarker(
113
+ marker: number,
114
+ newestAckedMs: number
115
+ ): boolean {
116
+ if (!Number.isFinite(marker) || marker <= 0) {return false;}
117
+ if (!(newestAckedMs > 0)) {return false;}
118
+ if (marker <= newestAckedMs + FUTURE_READ_MARKER_TOLERANCE_MS) {return false;}
119
+ return marker > Date.now() + FUTURE_READ_MARKER_TOLERANCE_MS;
120
+ }
121
+
122
+ /**
123
+ * Resolve the marker to STAMP (locally and to the server) for `room`,
124
+ * honouring an explicit read boundary when one is set.
125
+ *
126
+ * Bug #42 (a regression of #33, reintroduced by #38): #38 made every
127
+ * caller stamp the newest server-acknowledged message unconditionally.
128
+ * That is correct for a user who is at the bottom of the room, but wrong
129
+ * for a user who scrolled up, received messages, and left without
130
+ * scrolling back down - stamping "newest" there silently marks
131
+ * messages they never saw as read, both locally and (because the
132
+ * private-store merge is forward-only) permanently on the server.
133
+ *
134
+ * `boundaryTs` is the msgSortableMs of the newest message the user
135
+ * actually reached (see `readBoundaries` on `RoomMessagesState`,
136
+ * populated by MessageList's `onReadBoundaryChange`). When it's set and
137
+ * positive, it wins - but is still clamped to never exceed the newest
138
+ * server-acknowledged message, so a stale/corrupt boundary can't stamp a
139
+ * marker further ahead than the room's own history supports. When no
140
+ * boundary is set (the common case - the user is at the bottom), this is
141
+ * exactly `getServerReadTimestamp`.
142
+ *
143
+ * Never returns `Date.now()`, directly or indirectly - same rule as
144
+ * `getServerReadTimestamp` (bug #38).
145
+ */
146
+ export function getReadMarkerTimestamp(
147
+ room: RoomForReadTimestamp | null | undefined,
148
+ heapState?: ReadTimestampHeapState | null,
149
+ boundaryTs?: number | null
150
+ ): number {
151
+ const newestAcked = getServerReadTimestamp(room, heapState);
152
+ if (
153
+ typeof boundaryTs === 'number' &&
154
+ Number.isFinite(boundaryTs) &&
155
+ boundaryTs > 0
156
+ ) {
157
+ // `newestAcked === 0` means this room has NOTHING server-acked and no
158
+ // prior marker, so there is nothing to clamp the boundary against -
159
+ // and MessageList derives the boundary from the rendered list, which
160
+ // at that point can only contain pending/failed sends carrying the
161
+ // DEVICE clock via `date`. Returning it here would smuggle a
162
+ // device-clock marker back in through the boundary (bug #38). Return
163
+ // 0 = "nothing to anchor to, skip the write", same contract as
164
+ // getServerReadTimestamp.
165
+ return newestAcked > 0 ? Math.min(boundaryTs, newestAcked) : 0;
166
+ }
167
+ return newestAcked;
168
+ }
169
+
170
+ /**
171
+ * The value to hand `flushLastViewedToPrivateStore` as `visibleRoomTs`
172
+ * for a room being left, or `undefined` when no boundary applies (the
173
+ * flush then uses its own newest-acked default).
174
+ *
175
+ * Exists so the SERVER write goes through the SAME clamp as the local
176
+ * stamp. Passing the raw redux boundary straight through would bypass
177
+ * `getReadMarkerTimestamp`'s clamp on exactly the path where a bad value
178
+ * is permanent: the private-store merge is forward-only, so a boundary
179
+ * that (through a pending own message's device `date`, or a stale
180
+ * readBoundaries entry) sat ahead of the room's real history would be
181
+ * written verbatim and could never be corrected again (bug #38).
182
+ */
183
+ export function getFlushBoundaryTs(
184
+ room: RoomForReadTimestamp | null | undefined,
185
+ heapState?: ReadTimestampHeapState | null,
186
+ boundaryTs?: number | null
187
+ ): number | undefined {
188
+ if (
189
+ typeof boundaryTs !== 'number' ||
190
+ !Number.isFinite(boundaryTs) ||
191
+ boundaryTs <= 0
192
+ ) {
193
+ return undefined;
194
+ }
195
+ const clamped = getReadMarkerTimestamp(room, heapState, boundaryTs);
196
+ return clamped > 0 ? clamped : undefined;
197
+ }
@@ -1,5 +1,4 @@
1
1
  import { setCurrentRoom, setIsLoading } from '../roomStore/roomsSlice';
2
- import { updatedChatLastTimestamps } from './updatedChatLastTimestamps';
3
2
  import { initRoomsPresence } from './initRoomsPresence';
4
3
  import XmppClient from '../networking/xmppClient';
5
4
  import { IConfig, IRoom, User } from '../types/types';
@@ -48,15 +47,16 @@ const initXmppRooms = async (
48
47
  console.log(res);
49
48
  }
50
49
 
51
- //@ts-ignore
52
- const roomTimestampObject: [jid: string, timestamp: string] =
53
- await (xmmpClient as any as XmppClient).getChatsPrivateStoreRequestStanza();
54
- updatedChatLastTimestamps(roomTimestampObject, store.dispatch);
50
+ // getChatsPrivateStoreRequestStanza hydrates redux itself
51
+ // (dispatch(applyPrivateStoreMarkers(...)) internally, clamped +
52
+ // forward-only) - do not also apply the raw return value here. That
53
+ // used to double-dispatch via updatedChatLastTimestamps' unconditional
54
+ // setLastViewedTimestamp, which has neither the forward-only guard
55
+ // nor the future-marker clamp, so it could silently re-clobber a
56
+ // value the clamp had just corrected (bug #38).
57
+ await (xmmpClient as any as XmppClient).getChatsPrivateStoreRequestStanza();
55
58
  } else {
56
- //@ts-ignore
57
- const roomTimestampObject: [jid: string, timestamp: string] =
58
- await xmmpClient.getChatsPrivateStoreRequestStanza();
59
- updatedChatLastTimestamps(roomTimestampObject, store.dispatch);
59
+ await xmmpClient.getChatsPrivateStoreRequestStanza();
60
60
  }
61
61
 
62
62
  if (config?.refreshTokens?.enabled) {