@ethora/chat-component-rn 26.7.7 → 26.7.9

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 (170) hide show
  1. package/lib/commonjs/components/MainComponents/ChatRoom.js +17 -8
  2. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  3. package/lib/commonjs/components/MainComponents/MediaMessage.js +8 -5
  4. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/MessageContainer.js +5 -1
  6. package/lib/commonjs/components/MainComponents/MessageContainer.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/MessageList.js +2 -1
  8. package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
  9. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +51 -12
  10. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  11. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js +12 -1
  12. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  13. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js +13 -2
  14. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  15. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js +13 -2
  16. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  17. package/lib/commonjs/components/styled/MessageImage.js +3 -1
  18. package/lib/commonjs/components/styled/MessageImage.js.map +1 -1
  19. package/lib/commonjs/components/styled/UnsupportedType.js +14 -4
  20. package/lib/commonjs/components/styled/UnsupportedType.js.map +1 -1
  21. package/lib/commonjs/components/styled/VideoMessage.js +3 -1
  22. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  23. package/lib/commonjs/context/xmppProvider.js +46 -31
  24. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  25. package/lib/commonjs/helpers/createMessageFromXml.js +19 -1
  26. package/lib/commonjs/helpers/createMessageFromXml.js.map +1 -1
  27. package/lib/commonjs/helpers/getDisplayFileName.js +79 -0
  28. package/lib/commonjs/helpers/getDisplayFileName.js.map +1 -0
  29. package/lib/commonjs/helpers/getServerReadTimestamp.js +125 -0
  30. package/lib/commonjs/helpers/getServerReadTimestamp.js.map +1 -0
  31. package/lib/commonjs/helpers/initXmppRooms.js +9 -7
  32. package/lib/commonjs/helpers/initXmppRooms.js.map +1 -1
  33. package/lib/commonjs/helpers/isOwnMessage.js +75 -0
  34. package/lib/commonjs/helpers/isOwnMessage.js.map +1 -0
  35. package/lib/commonjs/helpers/mimeToExtension.js +6 -14
  36. package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
  37. package/lib/commonjs/hooks/useChatRoomFocus.js +17 -8
  38. package/lib/commonjs/hooks/useChatRoomFocus.js.map +1 -1
  39. package/lib/commonjs/hooks/useChatWrapperInit.js +12 -7
  40. package/lib/commonjs/hooks/useChatWrapperInit.js.map +1 -1
  41. package/lib/commonjs/networking/stanzaHandlers.js +20 -2
  42. package/lib/commonjs/networking/stanzaHandlers.js.map +1 -1
  43. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js +45 -11
  44. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  45. package/lib/commonjs/networking/xmppClient.js +36 -2
  46. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  47. package/lib/commonjs/roomStore/index.js +35 -2
  48. package/lib/commonjs/roomStore/index.js.map +1 -1
  49. package/lib/commonjs/roomStore/persistence.js +200 -11
  50. package/lib/commonjs/roomStore/persistence.js.map +1 -1
  51. package/lib/commonjs/roomStore/roomsSlice.js +83 -6
  52. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  53. package/lib/module/components/MainComponents/ChatRoom.js +17 -8
  54. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  55. package/lib/module/components/MainComponents/MediaMessage.js +9 -6
  56. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  57. package/lib/module/components/MainComponents/MessageContainer.js +5 -1
  58. package/lib/module/components/MainComponents/MessageContainer.js.map +1 -1
  59. package/lib/module/components/MainComponents/MessageList.js +2 -1
  60. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  61. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +52 -13
  62. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  63. package/lib/module/components/RoomComponents/styled/LastMessageFile.js +12 -1
  64. package/lib/module/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  65. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js +13 -2
  66. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  67. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js +13 -2
  68. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  69. package/lib/module/components/styled/MessageImage.js +3 -1
  70. package/lib/module/components/styled/MessageImage.js.map +1 -1
  71. package/lib/module/components/styled/UnsupportedType.js +14 -4
  72. package/lib/module/components/styled/UnsupportedType.js.map +1 -1
  73. package/lib/module/components/styled/VideoMessage.js +3 -1
  74. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  75. package/lib/module/context/xmppProvider.js +47 -32
  76. package/lib/module/context/xmppProvider.js.map +1 -1
  77. package/lib/module/helpers/createMessageFromXml.js +19 -1
  78. package/lib/module/helpers/createMessageFromXml.js.map +1 -1
  79. package/lib/module/helpers/getDisplayFileName.js +73 -0
  80. package/lib/module/helpers/getDisplayFileName.js.map +1 -0
  81. package/lib/module/helpers/getServerReadTimestamp.js +120 -0
  82. package/lib/module/helpers/getServerReadTimestamp.js.map +1 -0
  83. package/lib/module/helpers/initXmppRooms.js +9 -7
  84. package/lib/module/helpers/initXmppRooms.js.map +1 -1
  85. package/lib/module/helpers/isOwnMessage.js +70 -0
  86. package/lib/module/helpers/isOwnMessage.js.map +1 -0
  87. package/lib/module/helpers/mimeToExtension.js +6 -13
  88. package/lib/module/helpers/mimeToExtension.js.map +1 -1
  89. package/lib/module/hooks/useChatRoomFocus.js +17 -8
  90. package/lib/module/hooks/useChatRoomFocus.js.map +1 -1
  91. package/lib/module/hooks/useChatWrapperInit.js +12 -7
  92. package/lib/module/hooks/useChatWrapperInit.js.map +1 -1
  93. package/lib/module/networking/stanzaHandlers.js +20 -2
  94. package/lib/module/networking/stanzaHandlers.js.map +1 -1
  95. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js +45 -11
  96. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  97. package/lib/module/networking/xmppClient.js +36 -2
  98. package/lib/module/networking/xmppClient.js.map +1 -1
  99. package/lib/module/roomStore/index.js +37 -4
  100. package/lib/module/roomStore/index.js.map +1 -1
  101. package/lib/module/roomStore/persistence.js +197 -11
  102. package/lib/module/roomStore/persistence.js.map +1 -1
  103. package/lib/module/roomStore/roomsSlice.js +83 -6
  104. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  105. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  106. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  107. package/lib/typescript/components/MainComponents/MessageContainer.d.ts.map +1 -1
  108. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  109. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  110. package/lib/typescript/components/RoomComponents/styled/LastMessageFile.d.ts +1 -1
  111. package/lib/typescript/components/RoomComponents/styled/LastMessageFile.d.ts.map +1 -1
  112. package/lib/typescript/components/RoomComponents/styled/LastMessagePhoto.d.ts +1 -1
  113. package/lib/typescript/components/RoomComponents/styled/LastMessagePhoto.d.ts.map +1 -1
  114. package/lib/typescript/components/RoomComponents/styled/LastMessageVideo.d.ts +1 -1
  115. package/lib/typescript/components/RoomComponents/styled/LastMessageVideo.d.ts.map +1 -1
  116. package/lib/typescript/components/styled/MessageImage.d.ts +6 -0
  117. package/lib/typescript/components/styled/MessageImage.d.ts.map +1 -1
  118. package/lib/typescript/components/styled/UnsupportedType.d.ts.map +1 -1
  119. package/lib/typescript/components/styled/VideoMessage.d.ts +6 -0
  120. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  121. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  122. package/lib/typescript/helpers/createMessageFromXml.d.ts.map +1 -1
  123. package/lib/typescript/helpers/getDisplayFileName.d.ts +49 -0
  124. package/lib/typescript/helpers/getDisplayFileName.d.ts.map +1 -0
  125. package/lib/typescript/helpers/getServerReadTimestamp.d.ts +69 -0
  126. package/lib/typescript/helpers/getServerReadTimestamp.d.ts.map +1 -0
  127. package/lib/typescript/helpers/initXmppRooms.d.ts.map +1 -1
  128. package/lib/typescript/helpers/isOwnMessage.d.ts +36 -0
  129. package/lib/typescript/helpers/isOwnMessage.d.ts.map +1 -0
  130. package/lib/typescript/helpers/mimeToExtension.d.ts +0 -15
  131. package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
  132. package/lib/typescript/hooks/useChatRoomFocus.d.ts +4 -1
  133. package/lib/typescript/hooks/useChatRoomFocus.d.ts.map +1 -1
  134. package/lib/typescript/hooks/useChatWrapperInit.d.ts.map +1 -1
  135. package/lib/typescript/networking/stanzaHandlers.d.ts.map +1 -1
  136. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts +9 -2
  137. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts.map +1 -1
  138. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  139. package/lib/typescript/roomStore/index.d.ts.map +1 -1
  140. package/lib/typescript/roomStore/persistence.d.ts +18 -1
  141. package/lib/typescript/roomStore/persistence.d.ts.map +1 -1
  142. package/lib/typescript/roomStore/roomsSlice.d.ts +13 -1
  143. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  144. package/package.json +1 -1
  145. package/src/components/MainComponents/ChatRoom.tsx +21 -10
  146. package/src/components/MainComponents/MediaMessage.tsx +8 -5
  147. package/src/components/MainComponents/MessageContainer.tsx +5 -1
  148. package/src/components/MainComponents/MessageList.tsx +2 -1
  149. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +63 -26
  150. package/src/components/RoomComponents/styled/LastMessageFile.tsx +14 -2
  151. package/src/components/RoomComponents/styled/LastMessagePhoto.tsx +14 -2
  152. package/src/components/RoomComponents/styled/LastMessageVideo.tsx +14 -2
  153. package/src/components/styled/MessageImage.tsx +8 -1
  154. package/src/components/styled/UnsupportedType.tsx +14 -4
  155. package/src/components/styled/VideoMessage.tsx +8 -1
  156. package/src/context/xmppProvider.tsx +47 -24
  157. package/src/helpers/createMessageFromXml.ts +20 -1
  158. package/src/helpers/getDisplayFileName.ts +87 -0
  159. package/src/helpers/getServerReadTimestamp.ts +120 -0
  160. package/src/helpers/initXmppRooms.tsx +9 -9
  161. package/src/helpers/isOwnMessage.ts +90 -0
  162. package/src/helpers/mimeToExtension.ts +6 -22
  163. package/src/hooks/useChatRoomFocus.ts +14 -5
  164. package/src/hooks/useChatWrapperInit.ts +12 -7
  165. package/src/networking/stanzaHandlers.ts +20 -6
  166. package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +65 -18
  167. package/src/networking/xmppClient.ts +41 -2
  168. package/src/roomStore/index.ts +40 -4
  169. package/src/roomStore/persistence.ts +197 -7
  170. package/src/roomStore/roomsSlice.ts +84 -6
@@ -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
 
@@ -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,9 @@ import {
46
46
  clearVisibleRoom,
47
47
  setLastViewedTimestamp,
48
48
  deleteRoomMessage,
49
- msgSortableMs,
50
49
  setUnreadSyncing,
51
50
  } from '../roomStore/roomsSlice';
51
+ import { getServerReadTimestamp } from '../helpers/getServerReadTimestamp';
52
52
  import { runHistoryPreloadScheduler } from '../helpers/historyPreloadScheduler';
53
53
  import { updateMessagesTillLast } from '../helpers/updateMessagesTillLast';
54
54
  import { secureUserStorage } from '../helpers/secureUserStorage';
@@ -638,16 +638,30 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
638
638
  // Mark the open room "not visible" while backgrounded so messages
639
639
  // that arrive (or MAM-replay on reconnect) count as unread instead
640
640
  // 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.
641
+ // read" gap. Stamp lastViewed=<newest server-acked message>, not
642
+ // Date.now(), as the read baseline: a device clock running ahead
643
+ // would otherwise write a future marker that the forward-only
644
+ // private-store merge can never correct again (bug #38). Then
645
+ // clear visibility and flush that marker to the server private
646
+ // store. Restored on the next 'active' transition above. Handled
647
+ // here in the provider so consumers using the component as a
648
+ // package get correct unread without reaching into the chat store
649
+ // themselves.
646
650
  visibleBeforeBackground = visibleRoomJID;
647
651
  if (visibleRoomJID) {
648
- store.dispatch(
649
- setLastViewedTimestamp({ chatJID: visibleRoomJID, timestamp: Date.now() })
652
+ const readTs = getServerReadTimestamp(
653
+ rooms?.[visibleRoomJID],
654
+ state.roomHeapSlice
650
655
  );
656
+ // Nothing to anchor to yet (no known messages, no prior marker)
657
+ // - skip the local stamp rather than fall back to the device
658
+ // clock. The flush below still runs so the visibility clear
659
+ // isn't blocked on this.
660
+ if (readTs > 0) {
661
+ store.dispatch(
662
+ setLastViewedTimestamp({ chatJID: visibleRoomJID, timestamp: readTs })
663
+ );
664
+ }
651
665
  // Drop the "New messages" divider for the room we're leaving so it
652
666
  // doesn't linger when the user comes back (mirrors ChatRoom's
653
667
  // unmount cleanup, which never runs while the pane stays mounted).
@@ -672,11 +686,11 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
672
686
  // "shown" from "hidden", so the unread middleware would keep the open
673
687
  // room's badge at 0 and messages arriving while you're elsewhere look
674
688
  // 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.)
689
+ // background/foreground handling: hidden → stamp lastViewed=<newest
690
+ // server-acked message> + clear room visibility (so later messages
691
+ // count) + flush; shown → restore visibility for the open room. Doing
692
+ // it here means consumers never touch the chat store themselves.
693
+ // (Undefined = host unmounts on hide, nothing to do.)
680
694
  // -----------------------------------------------------------
681
695
  const chatWasVisibleRef = useRef(false);
682
696
  useEffect(() => {
@@ -692,9 +706,18 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
692
706
  }
693
707
  } else {
694
708
  if (wasVisible && activeRoomJID) {
695
- store.dispatch(
696
- setLastViewedTimestamp({ chatJID: activeRoomJID, timestamp: Date.now() })
709
+ // Never Date.now(): a fast device clock would write a future
710
+ // marker that the forward-only private-store merge could then
711
+ // never correct (bug #38).
712
+ const readTs = getServerReadTimestamp(
713
+ rooms?.[activeRoomJID],
714
+ state.roomHeapSlice
697
715
  );
716
+ if (readTs > 0) {
717
+ store.dispatch(
718
+ setLastViewedTimestamp({ chatJID: activeRoomJID, timestamp: readTs })
719
+ );
720
+ }
698
721
  // Drop the "New messages" divider so it's gone when the user
699
722
  // returns to the chat (mirrors ChatRoom's unmount cleanup).
700
723
  store.dispatch(
@@ -726,18 +749,18 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
726
749
  if (room.messages === lastMessagesRef) {return;}
727
750
  lastMessagesRef = room.messages;
728
751
 
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;}
752
+ // getServerReadTimestamp (not a hand-rolled scan + Date.now()):
753
+ // it excludes pending/optimistic messages, which carry the DEVICE
754
+ // send time via `date` until the server echoes them back - a
755
+ // manual scan without that exclusion could pick up a pending
756
+ // message's device timestamp here and write a future marker the
757
+ // same way a raw Date.now() would (bug #38).
758
+ const newest = getServerReadTimestamp(room, s.roomHeapSlice);
759
+ if (!newest || newest <= lastStampedMs) {return;}
737
760
  lastStampedMs = newest;
738
761
 
739
762
  store.dispatch(
740
- setLastViewedTimestamp({ chatJID: jid, timestamp: Date.now() })
763
+ setLastViewedTimestamp({ chatJID: jid, timestamp: newest })
741
764
  );
742
765
 
743
766
  if (flushTimer) {clearTimeout(flushTimer);}
@@ -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,120 @@
1
+ import type { IMessage, IRoom } from '../types/types';
2
+ import { msgSortableMs } from '../roomStore/roomsSlice';
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` (roomsSlice.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
+ }
@@ -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) {
@@ -0,0 +1,90 @@
1
+ import { IMessage } from '../types/types';
2
+
3
+ /**
4
+ * Bug #41: during reconnect catch-up (app was backgrounded/offline, then
5
+ * comes back online), messages replayed from MAM could transiently render
6
+ * on the RIGHT (as if sent by the current user) before flipping to the
7
+ * LEFT a moment later. Root cause: `message.user.id === walletAddress`
8
+ * treats `undefined === undefined` (or `'' === ''`) as a match, and the
9
+ * two sides could independently be empty for one frame -- the message's
10
+ * sender id before the MAM-derived value settled, and/or the current
11
+ * user's id before it rehydrated. Two blank ids must never compare equal.
12
+ *
13
+ * A second, related issue: the same logical sender id can show up in
14
+ * different shapes depending on which code path produced it -- a bare
15
+ * id ("wallet123") or a bare JID with an incidental session resource
16
+ * ("wallet123@host/sessionResource", as `senderJID` attrs carry on the
17
+ * wire -- see src/assets/test.xml). In every shape used in this
18
+ * codebase the identifying segment is the part BEFORE the first '@' (or
19
+ * before '/' when there's no '@'); anything after that -- domain,
20
+ * resource -- is noise. Comparing raw strings with `===` fails whenever
21
+ * one side still carries that noise and the other doesn't. Normalizing
22
+ * both sides (strip resource, strip domain, lowercase) before comparing
23
+ * makes the check tolerant of that drift.
24
+ */
25
+
26
+ export interface OwnMessageUserLike {
27
+ id?: string | null;
28
+ xmppUsername?: string | null;
29
+ walletAddress?: string | null;
30
+ }
31
+
32
+ export type OwnMessageCurrentUser =
33
+ | OwnMessageUserLike
34
+ | string
35
+ | null
36
+ | undefined;
37
+
38
+ /**
39
+ * Strip a MUC resource (`room@host/resource` -> `room@host`) and then a
40
+ * JID domain (`local@host` -> `local`), lowercasing the result. Applies
41
+ * to both plain ids and full/partial JIDs so either shape normalizes to
42
+ * the same comparable value.
43
+ */
44
+ const normalizeId = (raw: unknown): string => {
45
+ const value = String(raw ?? '').trim();
46
+ if (!value) {
47
+ return '';
48
+ }
49
+ const withoutResource = value.split('/')[0];
50
+ const withoutDomain = withoutResource.includes('@')
51
+ ? withoutResource.split('@')[0]
52
+ : withoutResource;
53
+ return withoutDomain.toLowerCase();
54
+ };
55
+
56
+ // Same precedence the rest of the app uses when it needs "who am I" as a
57
+ // single id: xmppUsername first, walletAddress as the fallback.
58
+ const resolveCurrentUserId = (currentUser: OwnMessageCurrentUser): string => {
59
+ if (typeof currentUser === 'string') {
60
+ return currentUser;
61
+ }
62
+ if (!currentUser) {
63
+ return '';
64
+ }
65
+ return (
66
+ currentUser.xmppUsername || currentUser.walletAddress || currentUser.id || ''
67
+ );
68
+ };
69
+
70
+ /**
71
+ * Robustly determine whether `message` was sent by `currentUser`. Never
72
+ * returns true from two blank/undefined ids, and tolerates bare-id vs.
73
+ * full-JID mismatches between the sender id and the current user id.
74
+ */
75
+ export const isOwnMessage = (
76
+ message: Pick<IMessage, 'user'> | null | undefined,
77
+ currentUser: OwnMessageCurrentUser
78
+ ): boolean => {
79
+ const messageUserId = normalizeId(message?.user?.id);
80
+ if (!messageUserId) {
81
+ return false;
82
+ }
83
+
84
+ const currentUserId = normalizeId(resolveCurrentUserId(currentUser));
85
+ if (!currentUserId) {
86
+ return false;
87
+ }
88
+
89
+ return messageUserId === currentUserId;
90
+ };
@@ -289,25 +289,9 @@ export function ensureFilenameHasExtension(
289
289
  return `${base}${getExtensionForMime(mime)}`;
290
290
  }
291
291
 
292
- /**
293
- * Best-effort filename derivation for a message bubble:
294
- * - server-provided `fileName`
295
- * - server-provided `originalName`
296
- * - URL pathname
297
- * - generic `media_<timestamp>` so we never render an empty label
298
- *
299
- * Always returns a string with a valid extension.
300
- */
301
- export function deriveDisplayFilename(opts: {
302
- fileName?: string | null;
303
- originalName?: string | null;
304
- url?: string | null;
305
- mime?: string | null;
306
- }): string {
307
- const candidate =
308
- (opts.fileName && opts.fileName.trim()) ||
309
- (opts.originalName && opts.originalName.trim()) ||
310
- filenameFromUrl(opts.url) ||
311
- '';
312
- return ensureFilenameHasExtension(candidate, opts.mime);
313
- }
292
+ // NOTE: the "which name wins" decision (originalName vs the stored
293
+ // fileName vs the URL) lives in helpers/getDisplayFileName.ts - that is
294
+ // the single source of truth used across display, save, share and
295
+ // download call sites (bug #40: this used to be duplicated here with
296
+ // the priority backwards, so attachments showed/saved under the
297
+ // server's hash name instead of the sender's original one).