@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
@@ -1,22 +1,32 @@
1
1
  import { getChatsPrivateStoreRequest } from './getChatsPrivateStoreRequest.xmpp';
2
2
  import { setChatsPrivateStoreRequest } from './setChatsPrivateStoreRequest.xmpp';
3
+ import {
4
+ getServerReadTimestamp,
5
+ isCorruptFutureReadMarker,
6
+ type ReadTimestampHeapState,
7
+ } from '../../helpers/getServerReadTimestamp';
8
+ import type { IMessage } from '../../types/types';
3
9
 
4
10
  interface RoomLike {
5
11
  jid: string;
6
12
  lastViewedTimestamp?: number;
7
13
  unreadMessages?: number;
14
+ // Needed to resolve a SERVER-timestamp read marker via
15
+ // getServerReadTimestamp instead of the device clock - see bug #38.
16
+ messages?: IMessage[];
8
17
  }
9
18
 
10
19
  interface FlushOpts {
11
20
  // When visibleRoomJID is set, treat it as "user just viewed everything
12
- // up to now" and stamp it with Date.now() — even if its in-memory
13
- // lastViewedTimestamp has not been updated yet.
21
+ // up to now" and stamp it with the newest server-acknowledged message
22
+ // timestamp for that room - even if its in-memory lastViewedTimestamp
23
+ // has not been updated yet.
14
24
  visibleRoomJID?: string | null;
15
- // Explicit read boundary for the visible room, used INSTEAD of
16
- // Date.now(). ChatRoom passes the timestamp of the newest message the
17
- // user actually reached when they leave a room while scrolled up —
18
- // without it, the Date.now() default writes "read everything" to the
19
- // server and the messages they never scrolled down to come back as
25
+ // Explicit read boundary for the visible room, used INSTEAD of the
26
+ // newest-message fallback. ChatRoom passes the timestamp of the newest
27
+ // message the user actually reached when they leave a room while
28
+ // scrolled up - without it, the default writes "read everything" to
29
+ // the server and the messages they never scrolled down to come back as
20
30
  // read on the next login, even though the local count was correct.
21
31
  // Customer-reported #33 (server half).
22
32
  visibleRoomTs?: number | null;
@@ -24,6 +34,12 @@ interface FlushOpts {
24
34
  // (or the visible room). Used at logout time: rooms with outstanding
25
35
  // unread keep their old marker so the next login still surfaces them.
26
36
  onlyIfNoUnread?: boolean;
37
+ // Pending/failed-send state (store.getState().roomHeapSlice), combined
38
+ // with each room's `messages` to resolve the newest SERVER-acked
39
+ // message timestamp via getServerReadTimestamp. Passed in rather than
40
+ // read from the store here so this module stays a pure function of its
41
+ // arguments - existing jest coverage builds `rooms` by hand.
42
+ heapState?: ReadTimestampHeapState;
27
43
  }
28
44
 
29
45
  /**
@@ -33,8 +49,11 @@ interface FlushOpts {
33
49
  * each other's entries.
34
50
  *
35
51
  * Called on: tab change away from chat, AppState background/inactive,
36
- * and (with onlyIfNoUnread=true) logout. ChatRoom's own unmount path
37
- * still uses actionSetTimestampToPrivateStore for the single-room case.
52
+ * and (with onlyIfNoUnread=true) logout. ChatRoom's own unmount path also
53
+ * goes through this (via flushLastViewedToPrivateStoreStanza) for the
54
+ * single-room case - actionSetTimestampToPrivateStore is a separate,
55
+ * unconditional low-level write primitive kept for direct/external use
56
+ * and is not part of this merge path.
38
57
  */
39
58
  export async function flushLastViewedToPrivateStore(
40
59
  client: any,
@@ -42,7 +61,7 @@ export async function flushLastViewedToPrivateStore(
42
61
  opts: FlushOpts = {}
43
62
  ): Promise<boolean> {
44
63
  if (!client?.client) {return false;}
45
- const { visibleRoomJID, visibleRoomTs, onlyIfNoUnread } = opts;
64
+ const { visibleRoomJID, visibleRoomTs, onlyIfNoUnread, heapState } = opts;
46
65
  const roomList = Object.values(rooms || {});
47
66
  if (roomList.length === 0) {return false;}
48
67
 
@@ -54,7 +73,6 @@ export async function flushLastViewedToPrivateStore(
54
73
  }
55
74
  if (!storeObj || typeof storeObj !== 'object') {storeObj = {};}
56
75
 
57
- const nowMs = Date.now();
58
76
  let dirty = false;
59
77
 
60
78
  for (const room of roomList) {
@@ -62,27 +80,56 @@ export async function flushLastViewedToPrivateStore(
62
80
  const isVisible = !!visibleRoomJID && room.jid === visibleRoomJID;
63
81
  const hasUnread = Number(room.unreadMessages || 0) > 0;
64
82
 
65
- // For the visible room, persist "now" — read state is driven by
66
- // room visibility, not by a sentinel timestamp. The exception is an
67
- // explicit `visibleRoomTs`: the caller knows the user only read up
68
- // to a certain point (left the room while scrolled up), so stamping
69
- // "now" here would mark genuinely-unread messages as read.
83
+ // Newest message this room actually has server-side proof of. Used
84
+ // both as the "read up to now" fallback for the visible room and as
85
+ // the yardstick for detecting a corrupt (future) stored marker below
86
+ // - never as a substitute for the device clock (bug #38: a device
87
+ // with a fast clock previously stamped Date.now() here, and once
88
+ // that future value reached the server the forward-only check a few
89
+ // lines down rejected every later correct write, forever).
90
+ const newestAckedMs = getServerReadTimestamp(room as any, heapState);
91
+
92
+ // For the visible room, persist "read up to the newest message we
93
+ // know the server has" - read state is driven by room visibility,
94
+ // not by a sentinel timestamp. The exception is an explicit
95
+ // `visibleRoomTs`: the caller knows the user only read up to a
96
+ // certain point (left the room while scrolled up), so stamping
97
+ // "everything" here would mark genuinely-unread messages as read.
70
98
  let ts: number | undefined;
71
99
  if (isVisible) {
72
100
  ts =
73
101
  typeof visibleRoomTs === 'number' && visibleRoomTs > 0
74
102
  ? visibleRoomTs
75
- : nowMs;
103
+ : newestAckedMs || undefined;
76
104
  } else if (room.lastViewedTimestamp && room.lastViewedTimestamp > 0) {
77
105
  ts = room.lastViewedTimestamp;
78
106
  }
79
107
 
108
+ // Nothing to anchor a write to (no boundary, no existing marker, no
109
+ // known messages yet) - skip rather than write a device-clock guess.
80
110
  if (!ts) {continue;}
81
111
  if (onlyIfNoUnread && hasUnread && !isVisible) {continue;}
82
112
 
83
113
  const prev = storeObj[room.jid];
84
114
  const prevNum = prev != null ? Number(prev) : 0;
85
- if (Number.isFinite(prevNum) && prevNum >= ts) {continue;}
115
+ // A currently-stored value that sits well past the newest message we
116
+ // know this room has is not "fresher" - it's a leftover future
117
+ // marker (bug #38). Forward-only logic normally protects a fresher
118
+ // read from being clobbered by a stale fetch, but applied to a
119
+ // corrupt future value it does the opposite: it permanently blocks
120
+ // the correct (smaller) value from ever landing. Detect that case
121
+ // and waive the forward-only check for it specifically; sane values
122
+ // keep the normal forward-only protection.
123
+ //
124
+ // "Well past the newest message we know about" is deliberately NOT
125
+ // sufficient on its own - see isCorruptFutureReadMarker. THIS device's
126
+ // copy of the room can simply be stale (history not synced yet this
127
+ // session), in which case a legitimate marker written by another
128
+ // device that HAS seen newer messages looks exactly like a corrupt
129
+ // one, and "healing" it here would push the server marker BACKWARDS
130
+ // and resurrect already-read messages on every device.
131
+ const prevLooksCorrupt = isCorruptFutureReadMarker(prevNum, newestAckedMs);
132
+ if (!prevLooksCorrupt && Number.isFinite(prevNum) && prevNum >= ts) {continue;}
86
133
  storeObj[room.jid] = String(ts);
87
134
  dirty = true;
88
135
  }
@@ -32,6 +32,10 @@ import {
32
32
  enqueueOutboundSend,
33
33
  flushOutboundSends,
34
34
  } from './outboundQueue';
35
+ import {
36
+ getServerReadTimestamp,
37
+ isCorruptFutureReadMarker,
38
+ } from '../helpers/getServerReadTimestamp';
35
39
 
36
40
  // Canonical production XMPP WSS endpoint (standard wss/443, no port suffix).
37
41
  const DEFAULT_DEV_SERVER = 'xmpp.chat.ethora.com';
@@ -1060,9 +1064,37 @@ export class XmppClient {
1060
1064
  // `getChatsPrivateStoreRequest` directly and intentionally skip this.
1061
1065
  if (markers && typeof markers === 'object') {
1062
1066
  const normalized: Record<string, number> = {};
1067
+ const roomsState = store.getState().rooms?.rooms || {};
1068
+ const heapState = store.getState().roomHeapSlice;
1063
1069
  for (const jid of Object.keys(markers as Record<string, unknown>)) {
1064
1070
  const n = Number((markers as Record<string, unknown>)[jid]);
1065
- if (jid && Number.isFinite(n) && n > 0) {normalized[jid] = n;}
1071
+ if (!jid || !Number.isFinite(n) || n <= 0) {continue;}
1072
+ // Defensive clamp (bug #38): a marker written while some
1073
+ // device's clock was ahead can land in the future relative to
1074
+ // the newest message the server actually has for this room.
1075
+ // The redux merge (applyPrivateStoreMarkers) and the
1076
+ // server-side merge (flushLastViewedToPrivateStore) both treat
1077
+ // "newer" as "better", so an uncorrected future value would
1078
+ // block every later correct write forever - useUnread() would
1079
+ // report 0 for that room on every cold start. When we already
1080
+ // know this room's messages locally, clamp an implausible
1081
+ // marker down to the newest one we actually have instead of
1082
+ // trusting the server value verbatim.
1083
+ //
1084
+ // "Implausible" needs BOTH "past the newest message we know
1085
+ // about" AND "past real time" (isCorruptFutureReadMarker):
1086
+ // this device's cache for a room is routinely stale, and
1087
+ // clamping on staleness alone would drag a perfectly good
1088
+ // marker written by another device backwards.
1089
+ const room = roomsState[jid];
1090
+ const newestKnownMs = room
1091
+ ? getServerReadTimestamp(room, heapState)
1092
+ : 0;
1093
+ if (isCorruptFutureReadMarker(n, newestKnownMs)) {
1094
+ normalized[jid] = newestKnownMs;
1095
+ } else {
1096
+ normalized[jid] = n;
1097
+ }
1066
1098
  }
1067
1099
  if (Object.keys(normalized).length > 0) {
1068
1100
  store.dispatch(applyPrivateStoreMarkers(normalized));
@@ -1104,7 +1136,14 @@ export class XmppClient {
1104
1136
  ) {
1105
1137
  if (this.disableLastRead) {return false;}
1106
1138
  try {
1107
- return await flushLastViewedToPrivateStore(this, rooms, opts);
1139
+ // Auto-inject the pending/failed-send heap so every caller gets
1140
+ // getServerReadTimestamp's exclusion of optimistic messages for
1141
+ // free, without needing to reach into the redux store themselves.
1142
+ const heapState = store.getState().roomHeapSlice;
1143
+ return await flushLastViewedToPrivateStore(this, rooms, {
1144
+ ...opts,
1145
+ heapState,
1146
+ });
1108
1147
  } catch {
1109
1148
  return false;
1110
1149
  }
@@ -1,14 +1,18 @@
1
1
  import { configureStore, combineReducers } from '@reduxjs/toolkit';
2
2
  import chatSettingsReducer, { setUser } from './chatSettingsSlice';
3
3
  import roomsSlice, { addRoom } from './roomsSlice';
4
- import { roomHeapSlice } from './roomHeapSlice';
4
+ import { roomHeapSlice, markMessageFailed } from './roomHeapSlice';
5
5
  import callReducer from './callSlice';
6
6
  import { IRoom } from '../types/types';
7
7
  import { unreadMiddleware } from './Middleware/unreadMidlleware';
8
8
  import { logoutMiddleware } from './Middleware/logoutMiddleware';
9
9
  import { newMessageMidlleware } from './Middleware/newMessageMidlleware';
10
10
  import { reactionsMiddleware } from './Middleware/reactionsMiddleware';
11
- import { persistenceMiddleware, readPersistedState } from './persistence';
11
+ import {
12
+ persistenceMiddleware,
13
+ readPersistedState,
14
+ computeBootTimeFailures,
15
+ } from './persistence';
12
16
 
13
17
  const rootReducer = combineReducers({
14
18
  chatSettingStore: chatSettingsReducer,
@@ -68,11 +72,12 @@ export const getActiveRoom = (state: RootState): IRoom | null => {
68
72
  : null;
69
73
  };
70
74
 
71
- // Async rehydrate — read persisted slices and replay them as standard
75
+ // Async rehydrate: read persisted slices and replay them as standard
76
+ // actions.
72
77
  export const persistorReady =
73
78
  globalScope.__CHAT_PERSISTOR_READY__ ||
74
79
  (globalScope.__CHAT_PERSISTOR_READY__ = (async () => {
75
- const { chat, rooms } = await readPersistedState();
80
+ const { chat, rooms, heap } = await readPersistedState();
76
81
  if (chat?.user && chat.user.walletAddress) {
77
82
  store.dispatch(setUser(chat.user));
78
83
  }
@@ -82,4 +87,35 @@ export const persistorReady =
82
87
  store.dispatch(addRoom({ roomData: room as IRoom }));
83
88
  }
84
89
  }
90
+
91
+ // bug #39: restore failures that were already flagged (watchdog or
92
+ // an explicit send error) before the app was killed, so the bubble
93
+ // still shows "Failed / tap to retry" instead of quietly reverting
94
+ // to a normal sent bubble.
95
+ // Skip entries whose bubble no longer exists in the restored cache.
96
+ // A failure flag is only ever READ as `failedMessages[message.id]`,
97
+ // so one with no matching message is dead weight that would be
98
+ // replayed and re-persisted on every launch forever. This happens
99
+ // for real: a send that reached the server but whose echo was lost
100
+ // gets flagged failed at boot, then the history merge replaces the
101
+ // optimistic bubble with the archived copy under its server id (see
102
+ // mergeHistoryIntoCache), leaving the flag behind.
103
+ const restoredFailed = heap?.failedMessages || {};
104
+ for (const payload of Object.values(restoredFailed)) {
105
+ const stillRendered = (
106
+ rooms?.rooms?.[payload.roomJID]?.messages || []
107
+ ).some((m) => m?.id === payload.id);
108
+ if (!stillRendered) {continue;}
109
+ store.dispatch(markMessageFailed(payload));
110
+ }
111
+
112
+ // bug #39: a message that was still `pending: true` at kill time
113
+ // never got a server echo AND never got the chance to trip the
114
+ // in-session send watchdog (messageHeap is process-lifetime only,
115
+ // never persisted). Treat every such message as failed so it gets
116
+ // a working retry instead of silently rendering as delivered.
117
+ const bootFailures = computeBootTimeFailures(rooms?.rooms, restoredFailed);
118
+ for (const payload of bootFailures) {
119
+ store.dispatch(markMessageFailed(payload));
120
+ }
85
121
  })());
@@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
2
2
  import { Middleware } from '@reduxjs/toolkit';
3
3
  import { IMessage, IRoom, User } from '../types/types';
4
4
  import { encryptForPersist, decryptFromPersist } from '../helpers/persistCrypto';
5
+ import type { FailedMessagePayload } from './roomHeapSlice';
5
6
 
6
7
  // -------------------------------------------------------------------
7
8
  // Lightweight RN persistence layer. Mirrors what redux-persist gives
@@ -21,12 +22,24 @@ import { encryptForPersist, decryptFromPersist } from '../helpers/persistCrypto'
21
22
 
22
23
  const KEY_CHAT = '@ethora/persist:chatSettingStore';
23
24
  const KEY_ROOMS = '@ethora/persist:rooms';
25
+ // bug #39: a failed send needs to survive a relaunch as "failed / tap
26
+ // to retry", not silently render as delivered. `roomHeapStore.failedMessages`
27
+ // is the map the bubble reads (`isFailed = failedMessages[id]`, see
28
+ // Message.tsx / useMessageHeapState): it lives in a separate, previously
29
+ // non-persisted slice from `rooms`, so it gets its own key here.
30
+ const KEY_HEAP = '@ethora/persist:roomHeap';
24
31
 
25
32
  // Per-room message cap on disk. Mirror this value in `roomsSlice`'s
26
33
  // in-memory cap (see `enforceMessageCap`) so the runtime and persisted
27
34
  // shapes stay aligned.
28
35
  export const MESSAGE_LIMIT = 100;
29
36
 
37
+ // Defensive cap on the number of failed-message payloads written to
38
+ // disk. In practice this map only grows while the user is actively
39
+ // sending offline, but a runaway sender (or a bug that never clears an
40
+ // entry) must not turn this key into an unbounded write.
41
+ export const MAX_PERSISTED_FAILED_MESSAGES = 200;
42
+
30
43
  interface PersistedChatState {
31
44
  user: User;
32
45
  }
@@ -36,6 +49,14 @@ interface PersistedRoomsState {
36
49
  // intentionally NOT persisted: activeRoomJID, editAction, isLoading
37
50
  }
38
51
 
52
+ interface PersistedHeapState {
53
+ failedMessages: Record<string, FailedMessagePayload>;
54
+ // intentionally NOT persisted: messageHeap, a process-lifetime
55
+ // "in flight" buffer (see roomHeapSlice.ts); anything still in it when
56
+ // the app dies is handled by the boot-time pending->failed scan below,
57
+ // not by replaying the heap itself.
58
+ }
59
+
39
60
  const sanitizeUser = (user?: User): User | null => {
40
61
  if (!user) {return null;}
41
62
  // Drop secrets we don't want at rest — destructure to omit, then
@@ -81,6 +102,13 @@ const PERSISTED_MESSAGE_FIELDS: (keyof IMessage)[] = [
81
102
  'size',
82
103
  'langSource',
83
104
  'callLog',
105
+ // bug #39: without this, a message that was still in flight (no
106
+ // server echo yet) at kill time comes back from disk indistinguishable
107
+ // from a normal delivered message, so the bubble renders it as sent.
108
+ // Persisting `pending` lets the boot-time scan (see
109
+ // computeBootTimeFailures below) find exactly the messages that never
110
+ // got confirmed and flip them to failed instead.
111
+ 'pending',
84
112
  ];
85
113
 
86
114
  // The sender identity that rides along on every message over the wire
@@ -180,6 +208,154 @@ const sanitizeRooms = (
180
208
  return out;
181
209
  };
182
210
 
211
+ // Only the fields retryMessage actually needs to replay the send (see
212
+ // FailedMessagePayload / useSendMessage.retryMessage). In particular, the
213
+ // media `data` blob is trimmed to `uri/type/name/size` (the picker never
214
+ // hands us auth material, but this stays defensive against a future
215
+ // picker change smuggling extra fields, or a huge inline payload, into
216
+ // `data`).
217
+ const sanitizeMediaData = (
218
+ data: any
219
+ ): { uri?: string; type?: string; name?: string; size?: number } => {
220
+ if (!data || typeof data !== 'object') {return {};}
221
+ const out: { uri?: string; type?: string; name?: string; size?: number } = {};
222
+ if (typeof data.uri === 'string') {out.uri = data.uri;}
223
+ if (typeof data.type === 'string') {out.type = data.type;}
224
+ if (typeof data.name === 'string') {out.name = data.name;}
225
+ if (typeof data.size === 'number') {out.size = data.size;}
226
+ return out;
227
+ };
228
+
229
+ const sanitizeFailedMessagePayload = (
230
+ payload: FailedMessagePayload
231
+ ): FailedMessagePayload | null => {
232
+ if (!payload || typeof payload !== 'object') {return null;}
233
+ if (!payload.id || typeof payload.id !== 'string') {return null;}
234
+ if (!payload.roomJID || typeof payload.roomJID !== 'string') {return null;}
235
+ if (payload.kind === 'media') {
236
+ return {
237
+ kind: 'media',
238
+ id: payload.id,
239
+ roomJID: payload.roomJID,
240
+ data: sanitizeMediaData(payload.data),
241
+ type: typeof payload.type === 'string' ? payload.type : '',
242
+ isReply: payload.isReply,
243
+ isChecked: payload.isChecked,
244
+ mainMessage: payload.mainMessage,
245
+ };
246
+ }
247
+ if (payload.kind === 'text') {
248
+ return {
249
+ kind: 'text',
250
+ id: payload.id,
251
+ roomJID: payload.roomJID,
252
+ body: typeof payload.body === 'string' ? payload.body : '',
253
+ isReply: payload.isReply,
254
+ isChecked: payload.isChecked,
255
+ mainMessage: payload.mainMessage,
256
+ };
257
+ }
258
+ return null;
259
+ };
260
+
261
+ const sanitizeFailedMessages = (
262
+ failedMessages: Record<string, FailedMessagePayload> | undefined
263
+ ): Record<string, FailedMessagePayload> => {
264
+ if (!failedMessages || typeof failedMessages !== 'object') {return {};}
265
+ const out: Record<string, FailedMessagePayload> = {};
266
+ let count = 0;
267
+ for (const [id, payload] of Object.entries(failedMessages)) {
268
+ if (count >= MAX_PERSISTED_FAILED_MESSAGES) {break;}
269
+ const clean = sanitizeFailedMessagePayload(payload);
270
+ if (clean) {
271
+ out[id] = clean;
272
+ count++;
273
+ }
274
+ }
275
+ return out;
276
+ };
277
+
278
+ // -------------------------------------------------------------------
279
+ // Boot-time pending -> failed conversion (bug #39).
280
+ //
281
+ // `messageHeap` (the "still sending" buffer) is intentionally NOT
282
+ // persisted: it's a process-lifetime thing. That means on a cold start
283
+ // there is no such thing as "still sending": a persisted message that
284
+ // carries `pending: true` never got a server echo NOR got the chance to
285
+ // trip the in-session watchdog (see PENDING_WATCHDOG_MS in
286
+ // useSendMessage.tsx) before the process died. It must render as failed
287
+ // with a working retry, not as delivered and not as stuck "sending...".
288
+ //
289
+ // Reconstructs the same FailedMessagePayload shape the live send path
290
+ // builds in useSendMessage (markMessageFailed calls), from the fields we
291
+ // persist per message.
292
+ // -------------------------------------------------------------------
293
+
294
+ export function reconstructFailedMessagePayload(
295
+ message: IMessage,
296
+ roomJID: string
297
+ ): FailedMessagePayload | null {
298
+ if (!message?.id || !roomJID) {return null;}
299
+ const isReply = !!message.isReply;
300
+ const isChecked = message.showInChannel === 'true';
301
+ const mainMessage = message.mainMessage;
302
+ if (message.isMediafile === 'true') {
303
+ return {
304
+ kind: 'media',
305
+ id: message.id,
306
+ roomJID,
307
+ data: sanitizeMediaData({
308
+ uri: message.location,
309
+ name: message.fileName,
310
+ type: message.mimetype,
311
+ size:
312
+ message.size !== undefined && message.size !== null
313
+ ? Number(message.size)
314
+ : undefined,
315
+ }),
316
+ type: message.mimetype || '',
317
+ isReply,
318
+ isChecked,
319
+ mainMessage,
320
+ };
321
+ }
322
+ return {
323
+ kind: 'text',
324
+ id: message.id,
325
+ roomJID,
326
+ body: message.body || '',
327
+ isReply,
328
+ isChecked,
329
+ mainMessage,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Scan every persisted room for messages still `pending: true` that
335
+ * DON'T already have a restored failure entry (those are handled by the
336
+ * caller separately, from the persisted `failedMessages` map), and
337
+ * synthesize a retry-able FailedMessagePayload for each. Pure function:
338
+ * the caller (roomStore/index.ts) is responsible for dispatching
339
+ * `markMessageFailed` for the results.
340
+ */
341
+ export function computeBootTimeFailures(
342
+ rooms: Record<string, IRoom> | null | undefined,
343
+ alreadyFailed: Record<string, FailedMessagePayload> | null | undefined
344
+ ): FailedMessagePayload[] {
345
+ if (!rooms || typeof rooms !== 'object') {return [];}
346
+ const out: FailedMessagePayload[] = [];
347
+ for (const [jid, room] of Object.entries(rooms)) {
348
+ const messages = Array.isArray(room?.messages) ? room.messages : [];
349
+ for (const message of messages) {
350
+ if (!message || !message.pending) {continue;}
351
+ if (alreadyFailed && alreadyFailed[message.id]) {continue;}
352
+ const payload = reconstructFailedMessagePayload(message, jid);
353
+ if (payload) {out.push(payload);}
354
+ }
355
+ }
356
+ return out;
357
+ }
358
+
183
359
  let writeTimer: ReturnType<typeof setTimeout> | null = null;
184
360
 
185
361
  /**
@@ -194,6 +370,7 @@ export const persistenceMiddleware: Middleware = (storeAPI) => (next) => (
194
370
  const type: string = action?.type || '';
195
371
  if (
196
372
  !type.startsWith('roomMessages/') &&
373
+ !type.startsWith('roomHeapStore/') &&
197
374
  !type.startsWith('chat/setUser') &&
198
375
  !type.startsWith('chat/updateUser') &&
199
376
  !type.startsWith('chat/refreshTokens') &&
@@ -213,13 +390,20 @@ export const persistenceMiddleware: Middleware = (storeAPI) => (next) => (
213
390
  const roomsPayload: PersistedRoomsState = {
214
391
  rooms: sanitizeRooms(state.rooms?.rooms || {}),
215
392
  };
216
- const [chatCipher, roomsCipher] = await Promise.all([
393
+ const heapPayload: PersistedHeapState = {
394
+ failedMessages: sanitizeFailedMessages(
395
+ state.roomHeapSlice?.failedMessages
396
+ ),
397
+ };
398
+ const [chatCipher, roomsCipher, heapCipher] = await Promise.all([
217
399
  encryptForPersist(JSON.stringify(chatPayload)),
218
400
  encryptForPersist(JSON.stringify(roomsPayload)),
401
+ encryptForPersist(JSON.stringify(heapPayload)),
219
402
  ]);
220
403
  await AsyncStorage.multiSet([
221
404
  [KEY_CHAT, chatCipher],
222
405
  [KEY_ROOMS, roomsCipher],
406
+ [KEY_HEAP, heapCipher],
223
407
  ]);
224
408
  } catch (e) {
225
409
  console.warn('persist write failed', e);
@@ -238,15 +422,18 @@ export const persistenceMiddleware: Middleware = (storeAPI) => (next) => (
238
422
  export async function readPersistedState(): Promise<{
239
423
  chat: PersistedChatState | null;
240
424
  rooms: PersistedRoomsState | null;
425
+ heap: PersistedHeapState | null;
241
426
  }> {
242
427
  try {
243
- const [chatRaw, roomsRaw] = await AsyncStorage.multiGet([
428
+ const [chatRaw, roomsRaw, heapRaw] = await AsyncStorage.multiGet([
244
429
  KEY_CHAT,
245
430
  KEY_ROOMS,
431
+ KEY_HEAP,
246
432
  ]);
247
- const [chatPlain, roomsPlain] = await Promise.all([
433
+ const [chatPlain, roomsPlain, heapPlain] = await Promise.all([
248
434
  chatRaw[1] ? decryptFromPersist(chatRaw[1]) : Promise.resolve(null),
249
435
  roomsRaw[1] ? decryptFromPersist(roomsRaw[1]) : Promise.resolve(null),
436
+ heapRaw[1] ? decryptFromPersist(heapRaw[1]) : Promise.resolve(null),
250
437
  ]);
251
438
  // `decryptFromPersist` returns null both for "nothing stored" and
252
439
  // for "stored value isn't a valid envelope for the current key" —
@@ -257,19 +444,22 @@ export async function readPersistedState(): Promise<{
257
444
  const rooms = roomsPlain
258
445
  ? (JSON.parse(roomsPlain) as PersistedRoomsState)
259
446
  : null;
260
- return { chat, rooms };
447
+ const heap = heapPlain
448
+ ? (JSON.parse(heapPlain) as PersistedHeapState)
449
+ : null;
450
+ return { chat, rooms, heap };
261
451
  } catch (e) {
262
452
  console.warn('persist read failed', e);
263
- return { chat: null, rooms: null };
453
+ return { chat: null, rooms: null, heap: null };
264
454
  }
265
455
  }
266
456
 
267
457
  export async function clearPersistedState(): Promise<void> {
268
458
  try {
269
- await AsyncStorage.multiRemove([KEY_CHAT, KEY_ROOMS]);
459
+ await AsyncStorage.multiRemove([KEY_CHAT, KEY_ROOMS, KEY_HEAP]);
270
460
  } catch (e) {
271
461
  console.warn('persist clear failed', e);
272
462
  }
273
463
  }
274
464
 
275
- export const PERSIST_KEYS = { KEY_CHAT, KEY_ROOMS };
465
+ export const PERSIST_KEYS = { KEY_CHAT, KEY_ROOMS, KEY_HEAP };