@ethora/chat-component-rn 26.5.17 → 26.5.18

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 (181) hide show
  1. package/lib/commonjs/assets/EmptyChatIllustration.js +106 -0
  2. package/lib/commonjs/assets/EmptyChatIllustration.js.map +1 -0
  3. package/lib/commonjs/assets/icons.js +13 -2
  4. package/lib/commonjs/assets/icons.js.map +1 -1
  5. package/lib/commonjs/components/DropdownMenu/DropdownMenu.js +2 -1
  6. package/lib/commonjs/components/DropdownMenu/DropdownMenu.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/ChatHeader.js +2 -1
  8. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/ChatRoom.js +5 -2
  10. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  11. package/lib/commonjs/components/MainComponents/ChooseChatMessage.js +9 -2
  12. package/lib/commonjs/components/MainComponents/ChooseChatMessage.js.map +1 -1
  13. package/lib/commonjs/components/MainComponents/EditWrapper.js +12 -4
  14. package/lib/commonjs/components/MainComponents/EditWrapper.js.map +1 -1
  15. package/lib/commonjs/components/MainComponents/MessageList.js +2 -1
  16. package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
  17. package/lib/commonjs/components/MainComponents/NoMessagesPlaceholder.js +11 -3
  18. package/lib/commonjs/components/MainComponents/NoMessagesPlaceholder.js.map +1 -1
  19. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js +12 -7
  20. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  21. package/lib/commonjs/components/MainComponents/RoomList.js +2 -1
  22. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  23. package/lib/commonjs/components/MessageBubble/Avatar.js +16 -7
  24. package/lib/commonjs/components/MessageBubble/Avatar.js.map +1 -1
  25. package/lib/commonjs/components/MessageBubble/Message.js +16 -1
  26. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  27. package/lib/commonjs/components/Modals/ModalWrapper/ModalWrapper.js +9 -0
  28. package/lib/commonjs/components/Modals/ModalWrapper/ModalWrapper.js.map +1 -1
  29. package/lib/commonjs/components/styled/Button.js +4 -2
  30. package/lib/commonjs/components/styled/Button.js.map +1 -1
  31. package/lib/commonjs/components/styled/DateLabel.js +12 -4
  32. package/lib/commonjs/components/styled/DateLabel.js.map +1 -1
  33. package/lib/commonjs/components/styled/SendInput.js +4 -3
  34. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  35. package/lib/commonjs/context/xmppProvider.js +8 -1
  36. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  37. package/lib/commonjs/helpers/getAvatarColor.js +33 -0
  38. package/lib/commonjs/helpers/getAvatarColor.js.map +1 -0
  39. package/lib/commonjs/helpers/getDateLabelColor.js +9 -0
  40. package/lib/commonjs/helpers/getDateLabelColor.js.map +1 -0
  41. package/lib/commonjs/helpers/getIconColor.js +9 -0
  42. package/lib/commonjs/helpers/getIconColor.js.map +1 -0
  43. package/lib/commonjs/helpers/getSenderNameColor.js +9 -0
  44. package/lib/commonjs/helpers/getSenderNameColor.js.map +1 -0
  45. package/lib/commonjs/hooks/useSendMessage.js +72 -22
  46. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  47. package/lib/commonjs/networking/outboundQueue.js +22 -4
  48. package/lib/commonjs/networking/outboundQueue.js.map +1 -1
  49. package/lib/commonjs/networking/xmppClient.js +50 -0
  50. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  51. package/lib/commonjs/roomStore/Middleware/newMessageMidlleware.js +22 -0
  52. package/lib/commonjs/roomStore/Middleware/newMessageMidlleware.js.map +1 -1
  53. package/lib/commonjs/roomStore/roomsSlice.js +13 -1
  54. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  55. package/lib/module/assets/EmptyChatIllustration.js +99 -0
  56. package/lib/module/assets/EmptyChatIllustration.js.map +1 -0
  57. package/lib/module/assets/icons.js +13 -2
  58. package/lib/module/assets/icons.js.map +1 -1
  59. package/lib/module/components/DropdownMenu/DropdownMenu.js +2 -1
  60. package/lib/module/components/DropdownMenu/DropdownMenu.js.map +1 -1
  61. package/lib/module/components/MainComponents/ChatHeader.js +2 -1
  62. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  63. package/lib/module/components/MainComponents/ChatRoom.js +5 -2
  64. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  65. package/lib/module/components/MainComponents/ChooseChatMessage.js +9 -2
  66. package/lib/module/components/MainComponents/ChooseChatMessage.js.map +1 -1
  67. package/lib/module/components/MainComponents/EditWrapper.js +12 -4
  68. package/lib/module/components/MainComponents/EditWrapper.js.map +1 -1
  69. package/lib/module/components/MainComponents/MessageList.js +2 -1
  70. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  71. package/lib/module/components/MainComponents/NoMessagesPlaceholder.js +11 -3
  72. package/lib/module/components/MainComponents/NoMessagesPlaceholder.js.map +1 -1
  73. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js +12 -7
  74. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  75. package/lib/module/components/MainComponents/RoomList.js +2 -1
  76. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  77. package/lib/module/components/MessageBubble/Avatar.js +16 -7
  78. package/lib/module/components/MessageBubble/Avatar.js.map +1 -1
  79. package/lib/module/components/MessageBubble/Message.js +16 -1
  80. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  81. package/lib/module/components/Modals/ModalWrapper/ModalWrapper.js +9 -0
  82. package/lib/module/components/Modals/ModalWrapper/ModalWrapper.js.map +1 -1
  83. package/lib/module/components/styled/Button.js +4 -2
  84. package/lib/module/components/styled/Button.js.map +1 -1
  85. package/lib/module/components/styled/DateLabel.js +12 -4
  86. package/lib/module/components/styled/DateLabel.js.map +1 -1
  87. package/lib/module/components/styled/SendInput.js +4 -3
  88. package/lib/module/components/styled/SendInput.js.map +1 -1
  89. package/lib/module/context/xmppProvider.js +8 -1
  90. package/lib/module/context/xmppProvider.js.map +1 -1
  91. package/lib/module/helpers/getAvatarColor.js +28 -0
  92. package/lib/module/helpers/getAvatarColor.js.map +1 -0
  93. package/lib/module/helpers/getDateLabelColor.js +4 -0
  94. package/lib/module/helpers/getDateLabelColor.js.map +1 -0
  95. package/lib/module/helpers/getIconColor.js +4 -0
  96. package/lib/module/helpers/getIconColor.js.map +1 -0
  97. package/lib/module/helpers/getSenderNameColor.js +4 -0
  98. package/lib/module/helpers/getSenderNameColor.js.map +1 -0
  99. package/lib/module/hooks/useSendMessage.js +72 -22
  100. package/lib/module/hooks/useSendMessage.js.map +1 -1
  101. package/lib/module/networking/outboundQueue.js +21 -4
  102. package/lib/module/networking/outboundQueue.js.map +1 -1
  103. package/lib/module/networking/xmppClient.js +50 -0
  104. package/lib/module/networking/xmppClient.js.map +1 -1
  105. package/lib/module/roomStore/Middleware/newMessageMidlleware.js +22 -0
  106. package/lib/module/roomStore/Middleware/newMessageMidlleware.js.map +1 -1
  107. package/lib/module/roomStore/roomsSlice.js +13 -1
  108. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  109. package/lib/typescript/assets/EmptyChatIllustration.d.ts +10 -0
  110. package/lib/typescript/assets/EmptyChatIllustration.d.ts.map +1 -0
  111. package/lib/typescript/assets/icons.d.ts +3 -12
  112. package/lib/typescript/assets/icons.d.ts.map +1 -1
  113. package/lib/typescript/components/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  114. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  115. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  116. package/lib/typescript/components/MainComponents/ChooseChatMessage.d.ts.map +1 -1
  117. package/lib/typescript/components/MainComponents/EditWrapper.d.ts +5 -1
  118. package/lib/typescript/components/MainComponents/EditWrapper.d.ts.map +1 -1
  119. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  120. package/lib/typescript/components/MainComponents/NoMessagesPlaceholder.d.ts.map +1 -1
  121. package/lib/typescript/components/MainComponents/ProfileImagePlaceholder.d.ts.map +1 -1
  122. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  123. package/lib/typescript/components/MessageBubble/Avatar.d.ts.map +1 -1
  124. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  125. package/lib/typescript/components/Modals/ModalWrapper/ModalWrapper.d.ts.map +1 -1
  126. package/lib/typescript/components/styled/Button.d.ts +1 -0
  127. package/lib/typescript/components/styled/Button.d.ts.map +1 -1
  128. package/lib/typescript/components/styled/DateLabel.d.ts +9 -8
  129. package/lib/typescript/components/styled/DateLabel.d.ts.map +1 -1
  130. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  131. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  132. package/lib/typescript/helpers/getAvatarColor.d.ts +7 -0
  133. package/lib/typescript/helpers/getAvatarColor.d.ts.map +1 -0
  134. package/lib/typescript/helpers/getDateLabelColor.d.ts +8 -0
  135. package/lib/typescript/helpers/getDateLabelColor.d.ts.map +1 -0
  136. package/lib/typescript/helpers/getIconColor.d.ts +8 -0
  137. package/lib/typescript/helpers/getIconColor.d.ts.map +1 -0
  138. package/lib/typescript/helpers/getSenderNameColor.d.ts +8 -0
  139. package/lib/typescript/helpers/getSenderNameColor.d.ts.map +1 -0
  140. package/lib/typescript/hooks/useSendMessage.d.ts +1 -1
  141. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  142. package/lib/typescript/networking/outboundQueue.d.ts +16 -1
  143. package/lib/typescript/networking/outboundQueue.d.ts.map +1 -1
  144. package/lib/typescript/networking/xmppClient.d.ts +3 -0
  145. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  146. package/lib/typescript/roomStore/Middleware/newMessageMidlleware.d.ts.map +1 -1
  147. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  148. package/lib/typescript/types/models/message.model.d.ts +1 -0
  149. package/lib/typescript/types/models/message.model.d.ts.map +1 -1
  150. package/lib/typescript/types/types.d.ts +14 -0
  151. package/lib/typescript/types/types.d.ts.map +1 -1
  152. package/package.json +1 -1
  153. package/src/assets/EmptyChatIllustration.tsx +49 -0
  154. package/src/assets/icons.tsx +64 -81
  155. package/src/components/DropdownMenu/DropdownMenu.tsx +2 -1
  156. package/src/components/MainComponents/ChatHeader.tsx +2 -1
  157. package/src/components/MainComponents/ChatRoom.tsx +5 -2
  158. package/src/components/MainComponents/ChooseChatMessage.tsx +5 -2
  159. package/src/components/MainComponents/EditWrapper.tsx +9 -6
  160. package/src/components/MainComponents/MessageList.tsx +2 -1
  161. package/src/components/MainComponents/NoMessagesPlaceholder.tsx +4 -1
  162. package/src/components/MainComponents/ProfileImagePlaceholder.tsx +14 -7
  163. package/src/components/MainComponents/RoomList.tsx +2 -1
  164. package/src/components/MessageBubble/Avatar.tsx +18 -9
  165. package/src/components/MessageBubble/Message.tsx +16 -1
  166. package/src/components/Modals/ModalWrapper/ModalWrapper.tsx +6 -0
  167. package/src/components/styled/Button.tsx +5 -1
  168. package/src/components/styled/DateLabel.tsx +10 -7
  169. package/src/components/styled/SendInput.tsx +4 -3
  170. package/src/context/xmppProvider.tsx +6 -1
  171. package/src/helpers/getAvatarColor.ts +31 -0
  172. package/src/helpers/getDateLabelColor.ts +3 -0
  173. package/src/helpers/getIconColor.ts +3 -0
  174. package/src/helpers/getSenderNameColor.ts +3 -0
  175. package/src/hooks/useSendMessage.tsx +101 -58
  176. package/src/networking/outboundQueue.ts +25 -4
  177. package/src/networking/xmppClient.ts +47 -0
  178. package/src/roomStore/Middleware/newMessageMidlleware.tsx +25 -0
  179. package/src/roomStore/roomsSlice.ts +15 -1
  180. package/src/types/models/message.model.ts +3 -0
  181. package/src/types/types.ts +18 -1
@@ -20,11 +20,25 @@ import type { IConfig, IMessage } from '../types/types';
20
20
  // long after the optimistic dispatch, mark it failed so the bubble
21
21
  // flips out of the "sending..." state. Covers the silent-failure
22
22
  // case (e.g. device offline before the message ever made it to the
23
- // XMPP socket) — bug #18. Tuned generously so a brief network blip
24
- // followed by reconnect within 30s still resolves naturally via the
25
- // server echo.
23
+ // XMPP socket) — bug #18.
24
+ //
25
+ // 5s window: a brief network blip that reconnects inside this window
26
+ // still resolves on its own — the offline send is buffered in the
27
+ // outbound queue (OUTBOUND_QUEUE_TTL_MS, kept in lockstep) and replayed
28
+ // the moment the stream comes back, so the server echo flips the bubble
29
+ // to delivered. Only a drop that outlasts 5s flips it to "Failed → tap
30
+ // to retry". If a slow send completes just after the window, the
31
+ // delivery clears the failed flag (see newMessageMidlleware) so it
32
+ // doesn't get stuck showing "Failed".
26
33
  const PENDING_WATCHDOG_MS = 30_000;
27
34
 
35
+ // Media gets a longer window than text: the optimistic bubble appears before
36
+ // the file upload even starts, and a large file (up to the 50MB cap) over a
37
+ // slow link can legitimately take far longer than 5s to upload + send. Failing
38
+ // it at 5s mid-upload would flash a wrong "Failed" and let the user kick off a
39
+ // duplicate upload. The text watchdog stays at 5s per product spec.
40
+ const MEDIA_PENDING_WATCHDOG_MS = 30_000;
41
+
28
42
  // Monotonic counter for stanza ids. Bumped on each send within a single
29
43
  // process. Two rapid sends in the same millisecond used to collide on
30
44
  // `send-text-message-${Date.now()}`; that collision made
@@ -76,7 +90,14 @@ export const useSendMessage = (_configOverride?: IConfig) => {
76
90
  activeRoomJID: string,
77
91
  isReply?: boolean,
78
92
  isChecked?: boolean,
79
- mainMessage?: string
93
+ mainMessage?: string,
94
+ // Set by retryMessage to the failed message's own id. When present we
95
+ // re-use that id (and its existing optimistic bubble) instead of minting
96
+ // a fresh one — mirroring sendMedia's `existingId`. Without this, a text
97
+ // retry created a second bubble while the original stayed pending
98
+ // forever, so a failed message that the user retried showed as two
99
+ // bubbles, one stuck on "sending...".
100
+ existingId?: string
80
101
  ) => {
81
102
  if (editAction?.isEdit) {
82
103
  try {
@@ -113,7 +134,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
113
134
  // dedupes by id and flips `pending: false` → DoubleTick renders.
114
135
  // Without this, the user taps send and sees nothing for ~200ms
115
136
  // until the echo lands, which feels broken.
116
- const optimisticId = nextStanzaId('send-text-message');
137
+ const optimisticId = existingId || nextStanzaId('send-text-message');
117
138
  const optimisticDate = new Date().toISOString();
118
139
  const selfId =
119
140
  (user as any)?.xmppUsername || (user as any)?.walletAddress || '';
@@ -135,7 +156,11 @@ export const useSendMessage = (_configOverride?: IConfig) => {
135
156
  name: `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || selfId,
136
157
  } as any,
137
158
  } as IMessage;
138
- if (!config?.disableSentLogic) {
159
+ // On retry (`existingId`) the original optimistic bubble is still in
160
+ // the room (pending:true) — clearMessageFailure just flipped it back out
161
+ // of the "failed" state — so re-adding would duplicate it. Only push a
162
+ // fresh bubble for a brand-new send.
163
+ if (!config?.disableSentLogic && !existingId) {
139
164
  dispatch(
140
165
  addRoomMessage({ roomJID: activeRoomJID, message: optimisticMessage })
141
166
  );
@@ -177,15 +202,54 @@ export const useSendMessage = (_configOverride?: IConfig) => {
177
202
  }, PENDING_WATCHDOG_MS);
178
203
 
179
204
  try {
180
-
181
- const effectiveClient: any =
182
- client ||
183
- [
184
- getGlobalXmppClient(),
185
- store.getState()?.chatSettingStore?.client,
186
- ].find((c: any) => c && c.status === 'online');
205
+ // The single send closure, used both for the immediate send and for
206
+ // the buffered replay. Passing the optimistic id as the stanza id
207
+ // means the server echoes it back with the same value, so the
208
+ // reducer's dedupe matches and the bubble flips pending → delivered
209
+ // in-place (rather than rendering a second copy).
210
+ const replaySend = (c: any) =>
211
+ c.sendMessage(
212
+ activeRoomJID,
213
+ user.firstName,
214
+ user.lastName,
215
+ '',
216
+ user.walletAddress,
217
+ message,
218
+ '',
219
+ isReply || false,
220
+ isChecked || false,
221
+ mainMessage || '',
222
+ optimisticId
223
+ );
224
+
225
+ // Always buffer a copy BEFORE attempting the live send. The WebSocket
226
+ // can still read `status: 'online'` for a beat after the network
227
+ // actually drops (the WS error lands a moment later), so a stanza sent
228
+ // "while online" can vanish into a dying socket and never get echoed.
229
+ // Buffering every send means the onOnline reconnect flush replays
230
+ // anything that wasn't confirmed within the window. The server echo
231
+ // removes the entry (see newMessageMidlleware), so a healthy online
232
+ // send never actually replays — only genuinely-lost ones do.
233
+ enqueueOutboundSend({
234
+ optimisticId,
235
+ roomJID: activeRoomJID,
236
+ enqueuedAt: Date.now(),
237
+ send: replaySend,
238
+ });
239
+
240
+ // Only treat a client as usable if its underlying stream is actually
241
+ // `online` — the instance survives a network drop with a stale
242
+ // 'connecting'/'offline' status, and sending through it would just
243
+ // lose the stanza.
244
+ const effectiveClient: any = [
245
+ client,
246
+ getGlobalXmppClient(),
247
+ store.getState()?.chatSettingStore?.client,
248
+ ].find((c: any) => c && c.status === 'online');
187
249
 
188
250
  if (!effectiveClient) {
251
+ // No live stream — kick a reconnect; the buffered copy above is
252
+ // replayed by the onOnline flush once the stream is back.
189
253
  const anyClient: any =
190
254
  client ||
191
255
  getGlobalXmppClient() ||
@@ -193,45 +257,13 @@ export const useSendMessage = (_configOverride?: IConfig) => {
193
257
  try {
194
258
  anyClient?.forceReconnect?.();
195
259
  } catch {}
196
- enqueueOutboundSend({
197
- optimisticId,
198
- roomJID: activeRoomJID,
199
- enqueuedAt: Date.now(),
200
- send: (c) =>
201
- c.sendMessage(
202
- activeRoomJID,
203
- user.firstName,
204
- user.lastName,
205
- '',
206
- user.walletAddress,
207
- message,
208
- '',
209
- isReply || false,
210
- isChecked || false,
211
- mainMessage || '',
212
- optimisticId
213
- ),
214
- });
215
260
  return;
216
261
  }
217
- // Pass the optimistic id as the stanza id so the server echoes
218
- // it back with the same value — the reducer's dedupe lookup
219
- // matches and the bubble flips from pending → delivered in-place
220
- // (instead of rendering two copies, which is what happens when
221
- // ids don't match).
222
- effectiveClient.sendMessage(
223
- activeRoomJID,
224
- user.firstName,
225
- user.lastName,
226
- '',
227
- user.walletAddress,
228
- message,
229
- '',
230
- isReply || false,
231
- isChecked || false,
232
- mainMessage || '',
233
- optimisticId
234
- );
262
+
263
+ replaySend(effectiveClient);
264
+ try {
265
+ effectiveClient.ensureStreamAlive?.();
266
+ } catch {}
235
267
  await handleMessageSent({
236
268
  message,
237
269
  roomJID: activeRoomJID,
@@ -405,7 +437,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
405
437
  messageType: 'media',
406
438
  });
407
439
  }
408
- }, PENDING_WATCHDOG_MS);
440
+ }, MEDIA_PENDING_WATCHDOG_MS);
409
441
 
410
442
  // Help RN's FormData polyfill build a proper multipart/file part
411
443
  // (instead of serialising the JS object as JSON). On iOS the
@@ -487,12 +519,14 @@ export const useSendMessage = (_configOverride?: IConfig) => {
487
519
  // Stanza id == placeholder id so the MUC echo's outer
488
520
  // <message id="..."> matches xmppId on the placeholder and
489
521
  // insertMessageWithDelimiter merges in place + flips pending.
490
- const effectiveMediaClient: any =
491
- client ||
492
- [
493
- getGlobalXmppClient(),
494
- store.getState()?.chatSettingStore?.client,
495
- ].find((c: any) => c && c.status === 'online');
522
+ // Same online-only guard as the text path: a surviving-but-offline
523
+ // client must NOT be used (the stanza would vanish into a dead
524
+ // socket) — fall through to enqueue so the reconnect flush replays.
525
+ const effectiveMediaClient: any = [
526
+ client,
527
+ getGlobalXmppClient(),
528
+ store.getState()?.chatSettingStore?.client,
529
+ ].find((c: any) => c && c.status === 'online');
496
530
  if (effectiveMediaClient) {
497
531
  effectiveMediaClient.setActiveRoomJid?.(activeRoomJID);
498
532
  await Promise.resolve(
@@ -503,6 +537,9 @@ export const useSendMessage = (_configOverride?: IConfig) => {
503
537
  messagePayload,
504
538
  id
505
539
  );
540
+ try {
541
+ effectiveMediaClient.ensureStreamAlive?.();
542
+ } catch {}
506
543
  } else {
507
544
  const anyMediaClient: any =
508
545
  client ||
@@ -515,6 +552,9 @@ export const useSendMessage = (_configOverride?: IConfig) => {
515
552
  optimisticId: id,
516
553
  roomJID: activeRoomJID,
517
554
  enqueuedAt: Date.now(),
555
+ // Match the media watchdog so a reconnect within the (longer)
556
+ // media window still auto-replays the buffered stanza.
557
+ ttlMs: MEDIA_PENDING_WATCHDOG_MS,
518
558
  send: (c) =>
519
559
  c.sendMediaMessageStanza(activeRoomJID, messagePayload, id),
520
560
  });
@@ -605,7 +645,10 @@ export const useSendMessage = (_configOverride?: IConfig) => {
605
645
  payload.roomJID,
606
646
  payload.isReply,
607
647
  payload.isChecked,
608
- payload.mainMessage
648
+ payload.mainMessage,
649
+ // Re-use the failed message's id so the retry resends in place
650
+ // instead of spawning a duplicate bubble.
651
+ failedId
609
652
  );
610
653
  } else {
611
654
  await sendMedia(
@@ -19,7 +19,7 @@
19
19
  * Each queued item carries a `send(client)` closure that captures the
20
20
  * original args (text or media), so flush is type-agnostic.
21
21
  *
22
- * TTL is deliberately aligned with the 30s pending-send watchdog in
22
+ * TTL is deliberately aligned with the pending-send watchdog in
23
23
  * useSendMessage (PENDING_WATCHDOG_MS): a queued item older than that has
24
24
  * already been — or is about to be — flipped to "Failed → tap to retry" by
25
25
  * the watchdog, so replaying it on a late reconnect would duplicate the
@@ -37,11 +37,20 @@ export interface QueuedSend {
37
37
  roomJID: string;
38
38
  /** Wall-clock ms when first queued; drives the TTL drop on flush. */
39
39
  enqueuedAt: number;
40
+ /**
41
+ * Per-item replay window. Kept in lockstep with the matching send
42
+ * watchdog so a reconnect inside the window replays the buffered send,
43
+ * while a longer drop lets the watchdog own the failure (no duplicate).
44
+ * Text uses 5s, media 30s (large uploads need longer). Falls back to
45
+ * OUTBOUND_QUEUE_TTL_MS when omitted.
46
+ */
47
+ ttlMs?: number;
40
48
  /** Replays the original send against a live, online client. */
41
49
  send: (client: OutboundQueueClient) => void;
42
50
  }
43
51
 
44
- // Kept in lockstep with PENDING_WATCHDOG_MS in useSendMessage.
52
+ // Default replay window (text). Mirrors PENDING_WATCHDOG_MS in useSendMessage.
53
+ // Media enqueues a longer per-item ttlMs (see QueuedSend.ttlMs).
45
54
  export const OUTBOUND_QUEUE_TTL_MS = 30_000;
46
55
 
47
56
  let queue: QueuedSend[] = [];
@@ -74,8 +83,8 @@ export function flushOutboundSends(
74
83
  const pending = queue;
75
84
  queue = [];
76
85
  for (const item of pending) {
77
- if (now - item.enqueuedAt > OUTBOUND_QUEUE_TTL_MS) {
78
- // Stale — the 30s watchdog owns the failure; don't duplicate.
86
+ if (now - item.enqueuedAt > (item.ttlMs ?? OUTBOUND_QUEUE_TTL_MS)) {
87
+ // Stale — the send watchdog owns the failure; don't duplicate.
79
88
  continue;
80
89
  }
81
90
  try {
@@ -87,6 +96,18 @@ export function flushOutboundSends(
87
96
  }
88
97
  }
89
98
 
99
+ /**
100
+ * Drop a single buffered send by its optimistic/stanza id. Called when a
101
+ * server-confirmed copy of the message arrives (see newMessageMidlleware):
102
+ * the send went through, so the buffered replay is no longer needed and must
103
+ * not fire on a later reconnect. No-op if the id isn't queued.
104
+ */
105
+ export function removeOutboundSend(optimisticId: string): void {
106
+ if (!optimisticId) {return;}
107
+ const idx = queue.findIndex((q) => q.optimisticId === optimisticId);
108
+ if (idx >= 0) {queue.splice(idx, 1);}
109
+ }
110
+
90
111
  /** Drop everything — called on permanent teardown (logout / close). */
91
112
  export function clearOutboundSends(): void {
92
113
  queue = [];
@@ -7,6 +7,7 @@ import { getChatsPrivateStoreRequest } from './xmpp/getChatsPrivateStoreRequest.
7
7
  import { actionSetTimestampToPrivateStore } from './xmpp/actionSetTimestampToPrivateStore.xmpp';
8
8
  import { flushLastViewedToPrivateStore } from './xmpp/flushLastViewedToPrivateStore';
9
9
  import { sendTypingRequest } from './xmpp/sendTypingRequest.xmpp';
10
+ import { sendPing } from './xmpp/sendPing.xmpp';
10
11
  import { getHistory } from './xmpp/getHistory.xmpp';
11
12
  import { sendTextMessage } from './xmpp/sendTextMessage.xmpp';
12
13
  import { deleteMessage } from './xmpp/deleteMessage.xmpp';
@@ -614,6 +615,52 @@ export class XmppClient {
614
615
  this.reconnect();
615
616
  }
616
617
 
618
+ private streamAliveProbe: Promise<boolean> | null = null;
619
+
620
+ verifyStreamAlive(timeoutMs: number = 4000): Promise<boolean> {
621
+ if (this.status !== 'online' || !this.client) {
622
+ return Promise.resolve(false);
623
+ }
624
+ if (this.streamAliveProbe) {return this.streamAliveProbe;}
625
+ const underlying: any = this.client;
626
+ this.streamAliveProbe = new Promise<boolean>((resolve) => {
627
+ let settled = false;
628
+ const done = (alive: boolean) => {
629
+ if (settled) {return;}
630
+ settled = true;
631
+ clearTimeout(timer);
632
+ try {
633
+ underlying.removeListener?.('stanza', onStanza);
634
+ } catch {}
635
+ resolve(alive);
636
+ };
637
+ const onStanza = () => done(true);
638
+ const timer = setTimeout(() => done(false), timeoutMs);
639
+ try {
640
+ underlying.on('stanza', onStanza);
641
+ sendPing(underlying, this.host, `alive-${Date.now()}`);
642
+ } catch {
643
+ // Couldn't even write the probe — treat as dead.
644
+ done(false);
645
+ }
646
+ }).finally(() => {
647
+ this.streamAliveProbe = null;
648
+ }) as Promise<boolean>;
649
+ return this.streamAliveProbe;
650
+ }
651
+
652
+ async ensureStreamAlive(timeoutMs: number = 4000): Promise<void> {
653
+ if (this.suppressReconnect || this.status !== 'online') {return;}
654
+ const alive = await this.verifyStreamAlive(timeoutMs);
655
+ // Re-check state: a reconnect may have started meanwhile.
656
+ if (!alive && this.status === 'online' && !this.suppressReconnect) {
657
+ try {
658
+ devPushLog('xmpp', 'stream is zombie (ping unanswered) → forceReconnect');
659
+ } catch {}
660
+ this.forceReconnect();
661
+ }
662
+ }
663
+
617
664
  async reconnect() {
618
665
  if (this.suppressReconnect) {return;}
619
666
  // Single-flight: ignore overlapping triggers (AppState + NetInfo +
@@ -1,5 +1,7 @@
1
1
  import { Middleware } from '@reduxjs/toolkit';
2
2
  import { updateRoom } from '../roomsSlice';
3
+ import { clearMessageFailure } from '../roomHeapSlice';
4
+ import { removeOutboundSend } from '../../networking/outboundQueue';
3
5
  import { IRoom } from '../../types/types';
4
6
 
5
7
  export const newMessageMidlleware: Middleware =
@@ -24,6 +26,29 @@ export const newMessageMidlleware: Middleware =
24
26
 
25
27
  const { roomJID, message } = action.payload;
26
28
 
29
+ // Un-fail on confirmed delivery. A server-confirmed copy (echo or MAM
30
+ // history, never `pending`) arriving for a message we'd flagged failed
31
+ // means the send actually went through — e.g. a slow send that completed
32
+ // just after the 5s watchdog window, or a queued auto-resend landing on
33
+ // reconnect. The failed flag is keyed by our optimistic/stanza id, which
34
+ // the echo carries back as `id`/`xmppId`, so clear it here and let the
35
+ // bubble flip to delivered instead of staying stuck on "Failed".
36
+ if (message && !message.pending) {
37
+ // The confirmed copy carries our optimistic/stanza id back as id/xmppId.
38
+ // Drop any still-buffered replay for it so a later reconnect flush can't
39
+ // re-send an already-delivered message.
40
+ removeOutboundSend(message.id);
41
+ removeOutboundSend(message.xmppId);
42
+
43
+ const failedMap = state.roomHeapSlice?.failedMessages || {};
44
+ const failedKey = [message.id, message.xmppId].find(
45
+ (k: string | undefined) => k && failedMap[k]
46
+ );
47
+ if (failedKey) {
48
+ storeAPI.dispatch(clearMessageFailure(failedKey as string));
49
+ }
50
+ }
51
+
27
52
  if ((rooms[roomJID]?.lastMessageTimestamp ?? 0) <= Number(message.id)) {
28
53
  storeAPI.dispatch(
29
54
  updateRoom({
@@ -76,7 +76,17 @@ function mergeHistoryIntoCache(
76
76
  // the freshest server state (reactions / edits / deletions).
77
77
  const byId = new Map<string, IMessage>();
78
78
  for (const m of realExisting) {byId.set(String(m.id), m);}
79
- for (const m of realFetched) {byId.set(String(m.id), m);}
79
+ for (const m of realFetched) {
80
+ // Fetched wins on body/reactions/etc., but MAM doesn't carry our
81
+ // client-side "edited" flag (the archived stanza just has the corrected
82
+ // body). Preserve `isEdited` from the cached copy so a message edited
83
+ // before reload keeps its marker instead of losing it on history sync.
84
+ const prev = byId.get(String(m.id));
85
+ byId.set(
86
+ String(m.id),
87
+ prev?.isEdited && !m.isEdited ? { ...m, isEdited: true } : m
88
+ );
89
+ }
80
90
  const merged = Array.from(byId.values()).sort(byMs);
81
91
  return [...capTail(merged), ...pending];
82
92
  }
@@ -307,6 +317,10 @@ const reducers = {
307
317
  state.rooms[roomJID].messages.map((message) => {
308
318
  if (message.id === messageId) {
309
319
  message.body = text;
320
+ // Flag the correction so the bubble can render an "edited" marker.
321
+ // Covers both the author's own edit and edits from other users —
322
+ // every <replace> echo flows through here.
323
+ message.isEdited = true;
310
324
  }
311
325
  });
312
326
  }
@@ -33,6 +33,9 @@ export interface IMessage {
33
33
  xmppId?: string;
34
34
  xmppFrom?: string;
35
35
  deleted?: boolean;
36
+ // Set once a message has been corrected via XEP-0308 (<replace>), so the
37
+ // bubble can show a small "edited" marker (Slack/WhatsApp style).
38
+ isEdited?: boolean;
36
39
  }
37
40
 
38
41
  export interface ReactionMessage {
@@ -270,7 +270,24 @@ export interface IConfig {
270
270
  projectName?: string;
271
271
 
272
272
  // ----- theming -----
273
- colors?: { primary: string; secondary: string };
273
+ colors?: {
274
+ primary: string;
275
+ secondary: string;
276
+ /** Tint for chrome icons (attach, mic/send, burger menu, edit banner,
277
+ * scroll-to-bottom FAB). Falls back to `primary`, then `#0052CD`. */
278
+ icon?: string;
279
+ /** Color of the sender's name above incoming message bubbles.
280
+ * Falls back to `primary`, then `#0052CD`. */
281
+ senderName?: string;
282
+ /** Background of initials avatars (message bubbles, chat header,
283
+ * profile modals). When omitted, each user keeps their per-name
284
+ * pastel color from the hash palette (current default). */
285
+ avatar?: string;
286
+ /** Text color of the day-separator pill ("Today", "June 8") in the
287
+ * message list; the pill background is a light tint of it. Falls
288
+ * back to `primary`, then `#0052CD`. */
289
+ dateLabel?: string;
290
+ };
274
291
  /** Configurable font family / weights for the chat UI. See TypographyConfig. */
275
292
  typography?: TypographyConfig;
276
293
  messageColor?: {