@ethora/chat-component-rn 26.7.8 → 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 (204) hide show
  1. package/CHANGELOG.md +0 -2
  2. package/lib/commonjs/assets/icons.js +2 -2
  3. package/lib/commonjs/assets/icons.js.map +1 -1
  4. package/lib/commonjs/components/MainComponents/ChatRoom.js +17 -8
  5. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  6. package/lib/commonjs/components/MainComponents/MediaMessage.js +8 -5
  7. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  8. package/lib/commonjs/components/MainComponents/MessageContainer.js +5 -1
  9. package/lib/commonjs/components/MainComponents/MessageContainer.js.map +1 -1
  10. package/lib/commonjs/components/MainComponents/MessageList.js +2 -1
  11. package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
  12. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +1 -51
  13. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  14. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +51 -12
  15. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  16. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js +12 -1
  17. package/lib/commonjs/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  18. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js +13 -2
  19. package/lib/commonjs/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  20. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js +13 -2
  21. package/lib/commonjs/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  22. package/lib/commonjs/components/styled/MessageImage.js +3 -1
  23. package/lib/commonjs/components/styled/MessageImage.js.map +1 -1
  24. package/lib/commonjs/components/styled/UnsupportedType.js +14 -4
  25. package/lib/commonjs/components/styled/UnsupportedType.js.map +1 -1
  26. package/lib/commonjs/components/styled/VideoMessage.js +3 -1
  27. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  28. package/lib/commonjs/context/xmppProvider.js +46 -31
  29. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  30. package/lib/commonjs/helpers/createMessageFromXml.js +19 -1
  31. package/lib/commonjs/helpers/createMessageFromXml.js.map +1 -1
  32. package/lib/commonjs/helpers/getDisplayFileName.js +79 -0
  33. package/lib/commonjs/helpers/getDisplayFileName.js.map +1 -0
  34. package/lib/commonjs/helpers/getServerReadTimestamp.js +125 -0
  35. package/lib/commonjs/helpers/getServerReadTimestamp.js.map +1 -0
  36. package/lib/commonjs/helpers/initXmppRooms.js +9 -7
  37. package/lib/commonjs/helpers/initXmppRooms.js.map +1 -1
  38. package/lib/commonjs/helpers/isOwnMessage.js +75 -0
  39. package/lib/commonjs/helpers/isOwnMessage.js.map +1 -0
  40. package/lib/commonjs/helpers/mimeToExtension.js +6 -14
  41. package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
  42. package/lib/commonjs/hooks/useChatRoomFocus.js +17 -8
  43. package/lib/commonjs/hooks/useChatRoomFocus.js.map +1 -1
  44. package/lib/commonjs/hooks/useChatWrapperInit.js +12 -7
  45. package/lib/commonjs/hooks/useChatWrapperInit.js.map +1 -1
  46. package/lib/commonjs/i18n/strings.js +0 -18
  47. package/lib/commonjs/i18n/strings.js.map +1 -1
  48. package/lib/commonjs/networking/api-requests/rooms.api.js +0 -13
  49. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  50. package/lib/commonjs/networking/stanzaHandlers.js +20 -2
  51. package/lib/commonjs/networking/stanzaHandlers.js.map +1 -1
  52. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js +45 -11
  53. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  54. package/lib/commonjs/networking/xmppClient.js +36 -2
  55. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  56. package/lib/commonjs/roomStore/index.js +35 -2
  57. package/lib/commonjs/roomStore/index.js.map +1 -1
  58. package/lib/commonjs/roomStore/persistence.js +200 -11
  59. package/lib/commonjs/roomStore/persistence.js.map +1 -1
  60. package/lib/commonjs/roomStore/roomsSlice.js +83 -6
  61. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  62. package/lib/module/assets/icons.js +1 -1
  63. package/lib/module/assets/icons.js.map +1 -1
  64. package/lib/module/components/MainComponents/ChatRoom.js +17 -8
  65. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  66. package/lib/module/components/MainComponents/MediaMessage.js +9 -6
  67. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  68. package/lib/module/components/MainComponents/MessageContainer.js +5 -1
  69. package/lib/module/components/MainComponents/MessageContainer.js.map +1 -1
  70. package/lib/module/components/MainComponents/MessageList.js +2 -1
  71. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  72. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +3 -53
  73. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  74. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +52 -13
  75. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  76. package/lib/module/components/RoomComponents/styled/LastMessageFile.js +12 -1
  77. package/lib/module/components/RoomComponents/styled/LastMessageFile.js.map +1 -1
  78. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js +13 -2
  79. package/lib/module/components/RoomComponents/styled/LastMessagePhoto.js.map +1 -1
  80. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js +13 -2
  81. package/lib/module/components/RoomComponents/styled/LastMessageVideo.js.map +1 -1
  82. package/lib/module/components/styled/MessageImage.js +3 -1
  83. package/lib/module/components/styled/MessageImage.js.map +1 -1
  84. package/lib/module/components/styled/UnsupportedType.js +14 -4
  85. package/lib/module/components/styled/UnsupportedType.js.map +1 -1
  86. package/lib/module/components/styled/VideoMessage.js +3 -1
  87. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  88. package/lib/module/context/xmppProvider.js +47 -32
  89. package/lib/module/context/xmppProvider.js.map +1 -1
  90. package/lib/module/helpers/createMessageFromXml.js +19 -1
  91. package/lib/module/helpers/createMessageFromXml.js.map +1 -1
  92. package/lib/module/helpers/getDisplayFileName.js +73 -0
  93. package/lib/module/helpers/getDisplayFileName.js.map +1 -0
  94. package/lib/module/helpers/getServerReadTimestamp.js +120 -0
  95. package/lib/module/helpers/getServerReadTimestamp.js.map +1 -0
  96. package/lib/module/helpers/initXmppRooms.js +9 -7
  97. package/lib/module/helpers/initXmppRooms.js.map +1 -1
  98. package/lib/module/helpers/isOwnMessage.js +70 -0
  99. package/lib/module/helpers/isOwnMessage.js.map +1 -0
  100. package/lib/module/helpers/mimeToExtension.js +6 -13
  101. package/lib/module/helpers/mimeToExtension.js.map +1 -1
  102. package/lib/module/hooks/useChatRoomFocus.js +17 -8
  103. package/lib/module/hooks/useChatRoomFocus.js.map +1 -1
  104. package/lib/module/hooks/useChatWrapperInit.js +12 -7
  105. package/lib/module/hooks/useChatWrapperInit.js.map +1 -1
  106. package/lib/module/i18n/strings.js +0 -18
  107. package/lib/module/i18n/strings.js.map +1 -1
  108. package/lib/module/networking/api-requests/rooms.api.js +0 -12
  109. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  110. package/lib/module/networking/stanzaHandlers.js +20 -2
  111. package/lib/module/networking/stanzaHandlers.js.map +1 -1
  112. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js +45 -11
  113. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  114. package/lib/module/networking/xmppClient.js +36 -2
  115. package/lib/module/networking/xmppClient.js.map +1 -1
  116. package/lib/module/roomStore/index.js +37 -4
  117. package/lib/module/roomStore/index.js.map +1 -1
  118. package/lib/module/roomStore/persistence.js +197 -11
  119. package/lib/module/roomStore/persistence.js.map +1 -1
  120. package/lib/module/roomStore/roomsSlice.js +83 -6
  121. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  122. package/lib/typescript/assets/icons.d.ts +0 -8
  123. package/lib/typescript/assets/icons.d.ts.map +1 -1
  124. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  125. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  126. package/lib/typescript/components/MainComponents/MessageContainer.d.ts.map +1 -1
  127. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  128. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  129. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  130. package/lib/typescript/components/RoomComponents/styled/LastMessageFile.d.ts +1 -1
  131. package/lib/typescript/components/RoomComponents/styled/LastMessageFile.d.ts.map +1 -1
  132. package/lib/typescript/components/RoomComponents/styled/LastMessagePhoto.d.ts +1 -1
  133. package/lib/typescript/components/RoomComponents/styled/LastMessagePhoto.d.ts.map +1 -1
  134. package/lib/typescript/components/RoomComponents/styled/LastMessageVideo.d.ts +1 -1
  135. package/lib/typescript/components/RoomComponents/styled/LastMessageVideo.d.ts.map +1 -1
  136. package/lib/typescript/components/styled/MessageImage.d.ts +6 -0
  137. package/lib/typescript/components/styled/MessageImage.d.ts.map +1 -1
  138. package/lib/typescript/components/styled/UnsupportedType.d.ts.map +1 -1
  139. package/lib/typescript/components/styled/VideoMessage.d.ts +6 -0
  140. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  141. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  142. package/lib/typescript/helpers/createMessageFromXml.d.ts.map +1 -1
  143. package/lib/typescript/helpers/getAvatarColor.d.ts +1 -1
  144. package/lib/typescript/helpers/getDisplayFileName.d.ts +49 -0
  145. package/lib/typescript/helpers/getDisplayFileName.d.ts.map +1 -0
  146. package/lib/typescript/helpers/getServerReadTimestamp.d.ts +69 -0
  147. package/lib/typescript/helpers/getServerReadTimestamp.d.ts.map +1 -0
  148. package/lib/typescript/helpers/initXmppRooms.d.ts.map +1 -1
  149. package/lib/typescript/helpers/isOwnMessage.d.ts +36 -0
  150. package/lib/typescript/helpers/isOwnMessage.d.ts.map +1 -0
  151. package/lib/typescript/helpers/mimeToExtension.d.ts +0 -15
  152. package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
  153. package/lib/typescript/hooks/useChatRoomFocus.d.ts +4 -1
  154. package/lib/typescript/hooks/useChatRoomFocus.d.ts.map +1 -1
  155. package/lib/typescript/hooks/useChatWrapperInit.d.ts.map +1 -1
  156. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  157. package/lib/typescript/networking/api-requests/rooms.api.d.ts +0 -1
  158. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  159. package/lib/typescript/networking/stanzaHandlers.d.ts.map +1 -1
  160. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts +9 -2
  161. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts.map +1 -1
  162. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  163. package/lib/typescript/roomStore/index.d.ts.map +1 -1
  164. package/lib/typescript/roomStore/persistence.d.ts +18 -1
  165. package/lib/typescript/roomStore/persistence.d.ts.map +1 -1
  166. package/lib/typescript/roomStore/roomsSlice.d.ts +13 -1
  167. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  168. package/lib/typescript/types/models/room.model.d.ts +0 -1
  169. package/lib/typescript/types/models/room.model.d.ts.map +1 -1
  170. package/lib/typescript/types/types.d.ts +0 -1
  171. package/lib/typescript/types/types.d.ts.map +1 -1
  172. package/package.json +1 -1
  173. package/src/assets/icons.tsx +0 -24
  174. package/src/components/MainComponents/ChatRoom.tsx +21 -10
  175. package/src/components/MainComponents/MediaMessage.tsx +8 -5
  176. package/src/components/MainComponents/MessageContainer.tsx +5 -1
  177. package/src/components/MainComponents/MessageList.tsx +2 -1
  178. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +2 -36
  179. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +63 -26
  180. package/src/components/RoomComponents/styled/LastMessageFile.tsx +14 -2
  181. package/src/components/RoomComponents/styled/LastMessagePhoto.tsx +14 -2
  182. package/src/components/RoomComponents/styled/LastMessageVideo.tsx +14 -2
  183. package/src/components/styled/MessageImage.tsx +8 -1
  184. package/src/components/styled/UnsupportedType.tsx +14 -4
  185. package/src/components/styled/VideoMessage.tsx +8 -1
  186. package/src/context/xmppProvider.tsx +47 -24
  187. package/src/helpers/createMessageFromXml.ts +20 -1
  188. package/src/helpers/getDisplayFileName.ts +87 -0
  189. package/src/helpers/getServerReadTimestamp.ts +120 -0
  190. package/src/helpers/initXmppRooms.tsx +9 -9
  191. package/src/helpers/isOwnMessage.ts +90 -0
  192. package/src/helpers/mimeToExtension.ts +6 -22
  193. package/src/hooks/useChatRoomFocus.ts +14 -5
  194. package/src/hooks/useChatWrapperInit.ts +12 -7
  195. package/src/i18n/strings.ts +0 -18
  196. package/src/networking/api-requests/rooms.api.ts +0 -9
  197. package/src/networking/stanzaHandlers.ts +20 -6
  198. package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +65 -18
  199. package/src/networking/xmppClient.ts +41 -2
  200. package/src/roomStore/index.ts +40 -4
  201. package/src/roomStore/persistence.ts +197 -7
  202. package/src/roomStore/roomsSlice.ts +84 -6
  203. package/src/types/models/room.model.ts +0 -1
  204. package/src/types/types.ts +0 -1
@@ -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 };
@@ -142,7 +142,6 @@ function mergeHistoryIntoCache(
142
142
  const ex = stripCallSignals(existing);
143
143
  const fe = stripCallSignals(fetched);
144
144
 
145
- const pending = ex.filter((m) => m?.pending);
146
145
  const realExisting = ex.filter(
147
146
  (m) => m && !m.pending && m.id !== 'delimiter-new'
148
147
  );
@@ -151,6 +150,29 @@ function mergeHistoryIntoCache(
151
150
  // Nothing usable came back → keep the cache exactly as it was.
152
151
  if (realFetched.length === 0) {return ex;}
153
152
 
153
+ // Optimistic sends are preserved across a history merge - EXCEPT the
154
+ // ones the fetched page proves the server already has. That happens
155
+ // whenever a send reached the server but its echo did not reach us
156
+ // before the process died: the optimistic bubble is restored from disk
157
+ // still `pending: true` (and, since bug #39, flagged failed at boot),
158
+ // while MAM hands back the very same message under its ARCHIVE id.
159
+ // Keying only on `id` never matched those two, so the room ended up
160
+ // showing the message twice - once for real and once as a bubble stuck
161
+ // on "sending…"/"Failed" that nothing could ever clear. The archived
162
+ // copy carries our original stanza id as `xmppId` (getDataFromXml), so
163
+ // match on that too and drop the local ghost.
164
+ const fetchedKeys = new Set<string>();
165
+ for (const m of realFetched) {
166
+ if (m?.id != null) {fetchedKeys.add(String(m.id));}
167
+ if ((m as any)?.xmppId) {fetchedKeys.add(String((m as any).xmppId));}
168
+ }
169
+ const pending = ex.filter(
170
+ (m) =>
171
+ m?.pending &&
172
+ !fetchedKeys.has(String(m.id)) &&
173
+ !(m.xmppId && fetchedKeys.has(String(m.xmppId)))
174
+ );
175
+
154
176
  const byMs = (a: IMessage, b: IMessage) =>
155
177
  msgSortableMs(a) - msgSortableMs(b);
156
178
  const capTail = (arr: IMessage[]) =>
@@ -580,7 +602,19 @@ const reducers = {
580
602
  *
581
603
  * Monotonic: a marker only ever moves a baseline FORWARD, so a stale
582
604
  * server value can't resurrect already-read messages and a more-recent
583
- * local read (tab blur stamping Date.now()) always wins.
605
+ * local read (tab blur stamping the server read timestamp) always wins.
606
+ *
607
+ * Bug #38 self-heal: forward-only is correct for a SANE existing
608
+ * value, but a device whose clock was ahead may have already stamped
609
+ * a marker in the future (either persisted locally from a previous
610
+ * session, or fetched from the server before this fix existed). To
611
+ * "only ever move forward" from a corrupt future value is to never
612
+ * move at all - the room would stay stuck at `unreadMessages: 0`
613
+ * forever. When the room is loaded, detect a marker that sits well
614
+ * past the newest message we actually know it has and waive the
615
+ * forward-only check for that one comparison, so the incoming
616
+ * (already-clamped, see xmppClient.getChatsPrivateStoreRequestStanza)
617
+ * value can correct it even though it's numerically smaller.
584
618
  */
585
619
  applyPrivateStoreMarkers: (
586
620
  state: WritableDraft<RoomMessagesState>,
@@ -592,15 +626,34 @@ const reducers = {
592
626
  for (const jid of Object.keys(markers)) {
593
627
  const ts = Number(markers[jid]);
594
628
  if (!jid || !Number.isFinite(ts) || ts <= 0) {continue;}
629
+
630
+ const room = state.rooms[jid];
631
+ const newestKnownMs = room ? newestAckedMessageMs(room.messages) : 0;
632
+ // Mirrors isCorruptFutureReadMarker in helpers/getServerReadTimestamp.ts
633
+ // (duplicated to avoid a roomStore <-> helpers import cycle):
634
+ // BOTH signals are required. "Further ahead than the newest
635
+ // message this room has locally" happens all the time on a room
636
+ // whose history hasn't synced yet, and healing on that alone
637
+ // would drop a legitimate marker another device wrote after
638
+ // reading newer messages. A real read marker can never be ahead
639
+ // of real time, so that second signal is what distinguishes a
640
+ // stale cache from a wrong clock.
641
+ const nowMs = Date.now();
642
+ const looksCorrupt = (existing: number) =>
643
+ newestKnownMs > 0 &&
644
+ existing > newestKnownMs + FUTURE_MARKER_TOLERANCE_MS &&
645
+ existing > nowMs + FUTURE_MARKER_TOLERANCE_MS;
646
+
595
647
  // Remember the marker so rooms that load LATER (via addRoom)
596
648
  // inherit it even though they don't exist in the store yet.
597
- if (ts > (state.privateStoreMarkers[jid] || 0)) {
649
+ const cachedMarker = state.privateStoreMarkers[jid] || 0;
650
+ if (ts > cachedMarker || looksCorrupt(cachedMarker)) {
598
651
  state.privateStoreMarkers[jid] = ts;
599
652
  }
600
653
  // Upgrade an already-loaded room's baseline + recompute its badge.
601
- // Only ever forward, so a later local read isn't clobbered.
602
- const room = state.rooms[jid];
603
- if (room && ts > (room.lastViewedTimestamp || 0)) {
654
+ // Forward-only, unless the room's CURRENT baseline is itself the
655
+ // corrupt value being corrected.
656
+ if (room && (ts > (room.lastViewedTimestamp || 0) || looksCorrupt(room.lastViewedTimestamp || 0))) {
604
657
  room.lastViewedTimestamp = ts;
605
658
  room.unreadMessages = countNewerMessages(room.messages, ts);
606
659
  }
@@ -872,6 +925,31 @@ const isOwn = (
872
925
  return false;
873
926
  };
874
927
 
928
+ // Bug #38 tolerance: a stored read marker more than this far past the
929
+ // newest message a room actually has is not "the user is a little
930
+ // ahead" - it's a leftover from a device whose clock was wrong when it
931
+ // stamped the marker. Kept in sync with (but duplicated from, to avoid
932
+ // a roomStore ↔ helpers import cycle) FUTURE_READ_MARKER_TOLERANCE_MS
933
+ // in helpers/getServerReadTimestamp.ts.
934
+ const FUTURE_MARKER_TOLERANCE_MS = 5 * 60 * 1000;
935
+
936
+ // Newest message in `messages` this room actually has proof the server
937
+ // accepted - used only to sanity-check an already-stored read marker
938
+ // against reality (see applyPrivateStoreMarkers's self-heal). Excludes
939
+ // pending/optimistic sends via the `pending` flag alone: unlike
940
+ // getServerReadTimestamp, this runs inside a reducer and can't reach
941
+ // across to the roomHeapSlice slice, but `pending` already identifies
942
+ // every optimistic send that matters for this comparison.
943
+ const newestAckedMessageMs = (messages: IMessage[] | undefined): number => {
944
+ let newest = 0;
945
+ for (const m of messages || []) {
946
+ if (!m || m.id === 'delimiter-new' || m.pending) {continue;}
947
+ const ms = msgSortableMs(m);
948
+ if (ms > newest) {newest = ms;}
949
+ }
950
+ return newest;
951
+ };
952
+
875
953
  const countNewerMessages = (
876
954
  messages: IMessage[],
877
955
  timestamp: number,
@@ -79,7 +79,6 @@ export interface ApiRoom {
79
79
  jid?: string;
80
80
  participants?: number;
81
81
  icon?: string;
82
- muted?: boolean;
83
82
  }
84
83
 
85
84
  export interface PostRoom {
@@ -34,7 +34,6 @@ export interface IRoom {
34
34
  lastViewedTimestamp?: number;
35
35
  unreadMessages?: number;
36
36
  noMessages?: boolean;
37
- muted?: boolean;
38
37
  role?: string;
39
38
  createdAt?: string | number | Date;
40
39
  updatedAt?: string | number | Date;