@ethora/chat-component-rn 26.7.3 → 26.7.5

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 (143) hide show
  1. package/CHANGELOG.md +0 -9
  2. package/README.md +0 -22
  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 +10 -10
  6. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/MessageList.js +22 -5
  8. package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js +8 -9
  10. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  11. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +37 -110
  12. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
  13. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +14 -16
  14. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  15. package/lib/commonjs/components/Modals/NewChatModal/ModalContent.js +1 -2
  16. package/lib/commonjs/components/Modals/NewChatModal/ModalContent.js.map +1 -1
  17. package/lib/commonjs/components/Modals/NewChatModal/UpdNewChatModal.js +0 -8
  18. package/lib/commonjs/components/Modals/NewChatModal/UpdNewChatModal.js.map +1 -1
  19. package/lib/commonjs/components/Modals/UserProfileModal/EditUserModal.js +21 -7
  20. package/lib/commonjs/components/Modals/UserProfileModal/EditUserModal.js.map +1 -1
  21. package/lib/commonjs/components/styled/SendInput.js +10 -0
  22. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  23. package/lib/commonjs/context/xmppProvider.js +35 -6
  24. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  25. package/lib/commonjs/helpers/updateMessagesTillLast.js +40 -12
  26. package/lib/commonjs/helpers/updateMessagesTillLast.js.map +1 -1
  27. package/lib/commonjs/hooks/useChatRoomFocus.js +9 -3
  28. package/lib/commonjs/hooks/useChatRoomFocus.js.map +1 -1
  29. package/lib/commonjs/hooks/useLogout.js +5 -0
  30. package/lib/commonjs/hooks/useLogout.js.map +1 -1
  31. package/lib/commonjs/hooks/useSendMessage.js +50 -23
  32. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  33. package/lib/commonjs/main.js +0 -6
  34. package/lib/commonjs/main.js.map +1 -1
  35. package/lib/commonjs/networking/outboundQueue.js +18 -0
  36. package/lib/commonjs/networking/outboundQueue.js.map +1 -1
  37. package/lib/commonjs/networking/stanzaHandlers.js +10 -1
  38. package/lib/commonjs/networking/stanzaHandlers.js.map +1 -1
  39. package/lib/commonjs/networking/xmppClient.js +107 -7
  40. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  41. package/lib/commonjs/roomStore/roomsSlice.js +22 -3
  42. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  43. package/lib/module/assets/icons.js +1 -1
  44. package/lib/module/assets/icons.js.map +1 -1
  45. package/lib/module/components/MainComponents/ChatRoom.js +10 -10
  46. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  47. package/lib/module/components/MainComponents/MessageList.js +23 -6
  48. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  49. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js +8 -9
  50. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  51. package/lib/module/components/Menu/HeaderRoomListMenu.js +38 -110
  52. package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
  53. package/lib/module/components/Modals/AttachSheet/AttachSheet.js +14 -16
  54. package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  55. package/lib/module/components/Modals/NewChatModal/ModalContent.js +1 -2
  56. package/lib/module/components/Modals/NewChatModal/ModalContent.js.map +1 -1
  57. package/lib/module/components/Modals/NewChatModal/UpdNewChatModal.js +0 -8
  58. package/lib/module/components/Modals/NewChatModal/UpdNewChatModal.js.map +1 -1
  59. package/lib/module/components/Modals/UserProfileModal/EditUserModal.js +23 -9
  60. package/lib/module/components/Modals/UserProfileModal/EditUserModal.js.map +1 -1
  61. package/lib/module/components/styled/SendInput.js +11 -1
  62. package/lib/module/components/styled/SendInput.js.map +1 -1
  63. package/lib/module/context/xmppProvider.js +35 -6
  64. package/lib/module/context/xmppProvider.js.map +1 -1
  65. package/lib/module/helpers/updateMessagesTillLast.js +41 -13
  66. package/lib/module/helpers/updateMessagesTillLast.js.map +1 -1
  67. package/lib/module/hooks/useChatRoomFocus.js +9 -3
  68. package/lib/module/hooks/useChatRoomFocus.js.map +1 -1
  69. package/lib/module/hooks/useLogout.js +5 -0
  70. package/lib/module/hooks/useLogout.js.map +1 -1
  71. package/lib/module/hooks/useSendMessage.js +51 -24
  72. package/lib/module/hooks/useSendMessage.js.map +1 -1
  73. package/lib/module/main.js +1 -1
  74. package/lib/module/main.js.map +1 -1
  75. package/lib/module/networking/outboundQueue.js +17 -0
  76. package/lib/module/networking/outboundQueue.js.map +1 -1
  77. package/lib/module/networking/stanzaHandlers.js +10 -1
  78. package/lib/module/networking/stanzaHandlers.js.map +1 -1
  79. package/lib/module/networking/xmppClient.js +107 -7
  80. package/lib/module/networking/xmppClient.js.map +1 -1
  81. package/lib/module/roomStore/roomsSlice.js +20 -2
  82. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  83. package/lib/typescript/assets/icons.d.ts +0 -4
  84. package/lib/typescript/assets/icons.d.ts.map +1 -1
  85. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  86. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  87. package/lib/typescript/components/MainComponents/ProfileImagePlaceholder.d.ts.map +1 -1
  88. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts +0 -3
  89. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
  90. package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
  91. package/lib/typescript/components/Modals/NewChatModal/ModalContent.d.ts +2 -4
  92. package/lib/typescript/components/Modals/NewChatModal/ModalContent.d.ts.map +1 -1
  93. package/lib/typescript/components/Modals/NewChatModal/UpdNewChatModal.d.ts.map +1 -1
  94. package/lib/typescript/components/Modals/UserProfileModal/EditUserModal.d.ts.map +1 -1
  95. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  96. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  97. package/lib/typescript/helpers/updateMessagesTillLast.d.ts.map +1 -1
  98. package/lib/typescript/hooks/useChatRoomFocus.d.ts.map +1 -1
  99. package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
  100. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  101. package/lib/typescript/main.d.ts +1 -1
  102. package/lib/typescript/main.d.ts.map +1 -1
  103. package/lib/typescript/networking/outboundQueue.d.ts +16 -0
  104. package/lib/typescript/networking/outboundQueue.d.ts.map +1 -1
  105. package/lib/typescript/networking/stanzaHandlers.d.ts.map +1 -1
  106. package/lib/typescript/networking/xmppClient.d.ts +33 -1
  107. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  108. package/lib/typescript/roomStore/roomsSlice.d.ts +18 -1
  109. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  110. package/lib/typescript/types/models/room.model.d.ts +15 -0
  111. package/lib/typescript/types/models/room.model.d.ts.map +1 -1
  112. package/lib/typescript/types/types.d.ts +15 -12
  113. package/lib/typescript/types/types.d.ts.map +1 -1
  114. package/package.json +1 -1
  115. package/src/assets/icons.tsx +0 -19
  116. package/src/components/MainComponents/ChatRoom.tsx +13 -9
  117. package/src/components/MainComponents/MessageList.tsx +23 -6
  118. package/src/components/MainComponents/ProfileImagePlaceholder.tsx +9 -16
  119. package/src/components/Menu/HeaderRoomListMenu.tsx +11 -105
  120. package/src/components/Modals/AttachSheet/AttachSheet.tsx +14 -16
  121. package/src/components/Modals/NewChatModal/ModalContent.tsx +3 -6
  122. package/src/components/Modals/NewChatModal/UpdNewChatModal.tsx +1 -12
  123. package/src/components/Modals/UserProfileModal/EditUserModal.tsx +25 -9
  124. package/src/components/styled/SendInput.tsx +10 -0
  125. package/src/context/xmppProvider.tsx +41 -8
  126. package/src/helpers/updateMessagesTillLast.tsx +42 -17
  127. package/src/hooks/useChatRoomFocus.ts +11 -3
  128. package/src/hooks/useLogout.tsx +7 -0
  129. package/src/hooks/useSendMessage.tsx +60 -24
  130. package/src/main.ts +1 -1
  131. package/src/networking/outboundQueue.ts +29 -0
  132. package/src/networking/stanzaHandlers.ts +9 -1
  133. package/src/networking/xmppClient.ts +118 -7
  134. package/src/roomStore/roomsSlice.ts +20 -2
  135. package/src/types/models/room.model.ts +15 -0
  136. package/src/types/types.ts +15 -14
  137. package/lib/commonjs/helpers/pickImageAsset.js +0 -68
  138. package/lib/commonjs/helpers/pickImageAsset.js.map +0 -1
  139. package/lib/module/helpers/pickImageAsset.js +0 -63
  140. package/lib/module/helpers/pickImageAsset.js.map +0 -1
  141. package/lib/typescript/helpers/pickImageAsset.d.ts +0 -27
  142. package/lib/typescript/helpers/pickImageAsset.d.ts.map +0 -1
  143. package/src/helpers/pickImageAsset.ts +0 -68
@@ -8,10 +8,23 @@ import { updateProfile } from '../../../networking/api-requests/user.api';
8
8
  import { useDispatch } from 'react-redux';
9
9
  import { updateUser } from '../../../roomStore/chatSettingsSlice';
10
10
  import { View } from 'react-native';
11
- import { pickImageAsset, PickedImage } from '../../../helpers/pickImageAsset';
12
11
  import { AddPhotoIcon } from '../../../assets/icons';
13
12
  // import { actionUpdateUser } from '../actions';
14
13
 
14
+ const base64ToFile = (base64String: string, fileName: string) => {
15
+ const byteString = atob(base64String.split(',')[1]);
16
+ const arrayBuffer = new ArrayBuffer(byteString.length);
17
+ const uintArray = new Uint8Array(arrayBuffer);
18
+ for (let i = 0; i < byteString.length; i++) {
19
+ uintArray[i] = byteString.charCodeAt(i);
20
+ }
21
+ const blob = new Blob([uintArray], { type: 'image/jpeg' });
22
+ return new File([blob], fileName, {
23
+ type: 'image/jpeg',
24
+ lastModified: Date.now(),
25
+ });
26
+ };
27
+
15
28
  interface EditUserModalProps {
16
29
  setIsEditing: React.Dispatch<React.SetStateAction<boolean>>;
17
30
  modalUser: any;
@@ -28,7 +41,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
28
41
  const [firstName, setFirstName] = useState(modalUser?.firstName || '');
29
42
  const [lastName, setLastName] = useState(modalUser?.lastName || '');
30
43
  const [description, setDescription] = useState(modalUser?.description || '');
31
- const [profileImage, setProfileImage] = useState<string | PickedImage>(
44
+ const [profileImage, setProfileImage] = useState<string | File>(
32
45
  modalUser?.profileImage
33
46
  );
34
47
 
@@ -36,8 +49,14 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
36
49
  try {
37
50
  let fd = new FormData();
38
51
 
39
- if (profileImage && typeof profileImage !== 'string') {
40
- fd.append('file', profileImage as any);
52
+ if (
53
+ typeof profileImage === 'string' &&
54
+ profileImage.startsWith('data:image/')
55
+ ) {
56
+ const file = base64ToFile(profileImage, 'profileImage.jpg');
57
+ fd.append('file', file);
58
+ } else if (profileImage instanceof File) {
59
+ fd.append('file', profileImage);
41
60
  }
42
61
 
43
62
  fd.append('firstName', firstName);
@@ -63,11 +82,8 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
63
82
  }
64
83
  };
65
84
 
66
- const handleProfileImageChange = async () => {
67
- const picked = await pickImageAsset();
68
- if (picked) {
69
- setProfileImage(picked);
70
- }
85
+ const handleProfileImageChange = (image: File) => {
86
+ setProfileImage(image);
71
87
  };
72
88
 
73
89
  return (
@@ -265,6 +265,16 @@ const SendInput: React.FC<SendInputProps> = ({
265
265
  if (pickingDocRef.current) {return;}
266
266
  pickingDocRef.current = true;
267
267
  try {
268
+ // Let the attach-sheet's modal finish its NATIVE dismissal before we
269
+ // present the picker. The sheet fires this handler right after its
270
+ // slide-out animation, but the underlying RN Modal view-controller
271
+ // dismissal is async — presenting the document picker over a still-
272
+ // dismissing modal makes iOS tear it down without a cancel callback,
273
+ // which strands expo-document-picker's pickingContext and makes every
274
+ // subsequent open throw "Different document picking in progress".
275
+ if (Platform.OS === 'ios') {
276
+ await new Promise((resolve) => setTimeout(resolve, 350));
277
+ }
268
278
  const result = await DocumentPicker.getDocumentAsync({
269
279
  type: '*/*',
270
280
  multiple: false,
@@ -557,7 +557,9 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
557
557
  devPushLog('rn', `watchdog: client ${client.status} → forceReconnect`);
558
558
  client.forceReconnect();
559
559
  } else {
560
- client.ensureStreamAlive?.();
560
+ // The periodic health check is the probe's primary owner, so it
561
+ // bypasses the throttle that keeps send-path probes rare.
562
+ client.ensureStreamAlive?.({ force: true, reason: 'watchdog' });
561
563
  }
562
564
  }, 30000);
563
565
  return () => clearInterval(id);
@@ -622,9 +624,19 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
622
624
  // the provider so consumers using the component as a package get
623
625
  // correct unread without reaching into the chat store themselves.
624
626
  visibleBeforeBackground = visibleRoomJID;
627
+ // Prefer the read boundary the user actually reached. Stamping
628
+ // `Date.now()` while they are scrolled up marks unseen messages as
629
+ // read, and because we flush to the server private store below the
630
+ // loss survives an app restart. Customer-reported #33.
631
+ const bgBoundaryTs = visibleRoomJID
632
+ ? rooms?.[visibleRoomJID]?.readBoundaryTs ?? null
633
+ : null;
625
634
  if (visibleRoomJID) {
626
635
  store.dispatch(
627
- setLastViewedTimestamp({ chatJID: visibleRoomJID, timestamp: Date.now() })
636
+ setLastViewedTimestamp({
637
+ chatJID: visibleRoomJID,
638
+ timestamp: bgBoundaryTs ?? Date.now(),
639
+ })
628
640
  );
629
641
  // Drop the "New messages" divider for the room we're leaving so it
630
642
  // doesn't linger when the user comes back (mirrors ChatRoom's
@@ -636,9 +648,10 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
636
648
  }
637
649
  // Fire-and-forget — we're going to the background and don't
638
650
  // care about the resolution path.
639
- c.flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID }).catch(
640
- () => {}
641
- );
651
+ c.flushLastViewedToPrivateStoreStanza(rooms, {
652
+ visibleRoomJID,
653
+ visibleRoomTs: bgBoundaryTs,
654
+ }).catch(() => {});
642
655
  });
643
656
  return () => sub.remove();
644
657
  }, [client]);
@@ -669,9 +682,16 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
669
682
  store.dispatch(setVisibleRoom({ roomJID: activeRoomJID }));
670
683
  }
671
684
  } else {
685
+ // Same read-boundary rule as the AppState path above (#33).
686
+ const hideBoundaryTs = activeRoomJID
687
+ ? rooms?.[activeRoomJID]?.readBoundaryTs ?? null
688
+ : null;
672
689
  if (wasVisible && activeRoomJID) {
673
690
  store.dispatch(
674
- setLastViewedTimestamp({ chatJID: activeRoomJID, timestamp: Date.now() })
691
+ setLastViewedTimestamp({
692
+ chatJID: activeRoomJID,
693
+ timestamp: hideBoundaryTs ?? Date.now(),
694
+ })
675
695
  );
676
696
  // Drop the "New messages" divider so it's gone when the user
677
697
  // returns to the chat (mirrors ChatRoom's unmount cleanup).
@@ -683,7 +703,10 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
683
703
  store.dispatch(clearVisibleRoom());
684
704
  if (wasVisible && client?.flushLastViewedToPrivateStoreStanza) {
685
705
  client
686
- .flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID: activeRoomJID })
706
+ .flushLastViewedToPrivateStoreStanza(rooms, {
707
+ visibleRoomJID: activeRoomJID,
708
+ visibleRoomTs: hideBoundaryTs,
709
+ })
687
710
  .catch(() => {});
688
711
  }
689
712
  }
@@ -704,6 +727,15 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
704
727
  if (room.messages === lastMessagesRef) {return;}
705
728
  lastMessagesRef = room.messages;
706
729
 
730
+ // "Chat is on screen" does NOT mean "user is looking at the newest
731
+ // message". While they are scrolled up, MessageList publishes a read
732
+ // boundary; advancing the marker to `now` here would clear the very
733
+ // unread messages the scroll-up state exists to preserve — and the
734
+ // 2s flush below would push that loss to the server, making it
735
+ // survive an app restart. Leave the marker alone until they come
736
+ // back to the bottom (boundary back to null). Customer-reported #33.
737
+ if (room.readBoundaryTs != null) {return;}
738
+
707
739
  let newest = 0;
708
740
  const list = room.messages || [];
709
741
  for (const m of list) {
@@ -762,7 +794,8 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
762
794
  devPushLog('rn', `NetInfo: reachable & client ${client.status} → forceReconnect`);
763
795
  client.forceReconnect();
764
796
  } else if (client.status === 'online') {
765
- client.ensureStreamAlive?.();
797
+ // Connectivity just came back — worth an immediate check.
798
+ client.ensureStreamAlive?.({ force: true, reason: 'netinfo' });
766
799
  }
767
800
  });
768
801
  return () => unsub();
@@ -1,6 +1,6 @@
1
1
  import XmppClient from '../networking/xmppClient';
2
2
  import { store } from '../roomStore';
3
- import { setRoomMessages } from '../roomStore/roomsSlice';
3
+ import { applyRoomsPreloadBatch } from '../roomStore/roomsSlice';
4
4
  import { IMessage, IRoom } from '../types/types';
5
5
  import { checkUniqueUsers } from './checkUniqueUsers';
6
6
 
@@ -76,22 +76,47 @@ export const updateMessagesTillLast = async (
76
76
  Number(message.id) === Number(lastCachedMessagesTimeStamp)
77
77
  );
78
78
 
79
- if (!isMessageFound && !(counter <= maxFetchAttempts - 1)) {
80
- // Fire-and-forget — checkUniqueUsers resolves to an
81
- // updated RoomMember list, but there's no slice
82
- // action wired to receive it on RN yet (the web SDK
83
- // has one; the port left a TODO). Keep the call so
84
- // the API request goes out (it warms the cache for
85
- // later renders) but stop short of dispatching.
86
- await checkUniqueUsers(currentJidNewMessages);
87
-
88
- store.dispatch(
89
- setRoomMessages({
90
- roomJID: jid,
91
- messages: currentJidNewMessages,
92
- })
93
- );
94
- }
79
+ }
80
+
81
+ // Publish whatever the catch-up actually retrieved.
82
+ //
83
+ // This used to sit INSIDE the loop behind
84
+ // `!isMessageFound && !(counter <= maxFetchAttempts - 1)`,
85
+ // i.e. it only ran when the walk-back gave up. In the common
86
+ // case — the gap is small and the anchor is found on the first
87
+ // fetch — `isMessageFound` flips true immediately and NOTHING
88
+ // was ever dispatched, so the reconnect catch-up silently
89
+ // dropped its own result. The messages still appeared, but only
90
+ // as a side effect of the always-on `onMessageHistory` stanza
91
+ // handler, which parses the same stanza a different way
92
+ // (positional form, no explicit `user` object) and inserts via
93
+ // addRoomMessage. That copy could carry a wrong sender identity
94
+ // and, being treated as an own-message, was excluded from the
95
+ // unread count — the "wrong sender + no unread after reconnect,
96
+ // both correct after restart" symptom, because only a fresh
97
+ // start re-runs the preload scheduler that overwrites it.
98
+ //
99
+ // Dispatching through `applyRoomsPreloadBatch` (rather than the
100
+ // previous `setRoomMessages` array-replace) reuses
101
+ // mergeHistoryIntoCache: it dedupes by id with "fetched wins",
102
+ // preserves locally-pending sends and older cached history, and
103
+ // is already a trigger for the unread middleware — exactly what
104
+ // the startup path does, so reconnect and cold start now agree.
105
+ // Customer-reported #32.
106
+ if (currentJidNewMessages.length > 0) {
107
+ // Fire-and-forget — checkUniqueUsers resolves to an
108
+ // updated RoomMember list, but there's no slice
109
+ // action wired to receive it on RN yet (the web SDK
110
+ // has one; the port left a TODO). Keep the call so
111
+ // the API request goes out (it warms the cache for
112
+ // later renders) but stop short of dispatching.
113
+ await checkUniqueUsers(currentJidNewMessages);
114
+
115
+ store.dispatch(
116
+ applyRoomsPreloadBatch({
117
+ rooms: [{ jid, messages: currentJidNewMessages }],
118
+ })
119
+ );
95
120
  }
96
121
  } catch (error) {
97
122
  console.error(`Error processing room ${jid}:`, error);
@@ -59,12 +59,20 @@ export const useChatRoomFocus = ({
59
59
  });
60
60
 
61
61
  const leaveRoom = (jid: string) => {
62
- const timestamp = Date.now();
62
+ const rooms = store.getState().rooms?.rooms;
63
+ // Only mark read as far as the user actually scrolled. Blindly
64
+ // stamping `Date.now()` here clears messages they never reached, and
65
+ // the server flush below makes that permanent across a restart.
66
+ // Customer-reported #33.
67
+ const boundaryTs = rooms?.[jid]?.readBoundaryTs ?? null;
68
+ const timestamp = boundaryTs ?? Date.now();
63
69
  dispatch(setLastViewedTimestamp({ chatJID: jid, timestamp }));
64
70
  dispatch(clearVisibleRoom());
65
- const rooms = store.getState().rooms?.rooms;
66
71
  (store.getState().chatSettingStore as any)?.client
67
- ?.flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID: jid })
72
+ ?.flushLastViewedToPrivateStoreStanza(rooms, {
73
+ visibleRoomJID: jid,
74
+ visibleRoomTs: boundaryTs,
75
+ })
68
76
  .catch(() => {});
69
77
  };
70
78
 
@@ -63,6 +63,13 @@ const logoutService = {
63
63
  await Promise.race([
64
64
  client.flushLastViewedToPrivateStoreStanza(rooms, {
65
65
  visibleRoomJID,
66
+ // `onlyIfNoUnread` deliberately exempts the visible room, so
67
+ // without this the open-but-scrolled-up room would still be
68
+ // stamped "read up to now" on logout and come back read on
69
+ // the next login. Customer-reported #33.
70
+ visibleRoomTs: visibleRoomJID
71
+ ? rooms?.[visibleRoomJID]?.readBoundaryTs ?? null
72
+ : null,
66
73
  onlyIfNoUnread: true,
67
74
  }),
68
75
  new Promise((res) => setTimeout(res, 2000)),
@@ -10,7 +10,11 @@ import {
10
10
  markMessageFailed,
11
11
  } from '../roomStore/roomHeapSlice';
12
12
  import { uploadFileV2 } from '../networking/api-requests/auth.api';
13
- import { enqueueOutboundSend, removeOutboundSend } from '../networking/outboundQueue';
13
+ import {
14
+ enqueueOutboundSend,
15
+ markOutboundSentLive,
16
+ removeOutboundSend,
17
+ } from '../networking/outboundQueue';
14
18
  import { RootState, store } from '../roomStore';
15
19
  import { getGlobalXmppClient } from '../utils/clientRegistry';
16
20
  import { useEventHandlers } from './useEventHandlers';
@@ -304,15 +308,36 @@ export const useSendMessage = (_configOverride?: IConfig) => {
304
308
  }
305
309
 
306
310
  replaySend(effectiveClient);
311
+ // The stanza is on the wire from here on. Mark the buffered copy
312
+ // so a later reconnect flush won't blind-replay it — see
313
+ // markOutboundSentLive / flushOutboundSends. Customer-reported #31.
314
+ markOutboundSentLive(optimisticId);
307
315
  try {
308
- effectiveClient.ensureStreamAlive?.();
316
+ // Throttled + config-gated: a burst of sends starts at most one
317
+ // probe, and a single missed probe no longer tears the stream
318
+ // down. See ensureStreamAliveAfterSend / ensureStreamAlive.
319
+ effectiveClient.ensureStreamAliveAfterSend?.();
309
320
  } catch {}
310
- await handleMessageSent({
311
- message,
312
- roomJID: activeRoomJID,
313
- user,
314
- messageType: 'text',
315
- });
321
+ // Post-send notification ONLY. Deliberately outside the try below:
322
+ // `handleMessageSent` re-throws whatever the host app's
323
+ // `eventHandlers.onMessageSent` callback throws, and this runs
324
+ // AFTER the stanza was already delivered — letting that reach the
325
+ // catch marked a delivered message "Failed → tap to retry" purely
326
+ // because a consumer's analytics/webhook hook was slow or errored.
327
+ // Customer-reported #31 ("delivered but shows as failed").
328
+ try {
329
+ await handleMessageSent({
330
+ message,
331
+ roomJID: activeRoomJID,
332
+ user,
333
+ messageType: 'text',
334
+ });
335
+ } catch (notifyError) {
336
+ console.warn(
337
+ 'onMessageSent handler threw after a successful send; not failing the message',
338
+ notifyError
339
+ );
340
+ }
316
341
  } catch (error) {
317
342
  clearTimeout(watchdogTimer);
318
343
  // Same reasoning as the watchdog: once the bubble shows failed,
@@ -580,7 +605,7 @@ export const useSendMessage = (_configOverride?: IConfig) => {
580
605
  id
581
606
  );
582
607
  try {
583
- effectiveMediaClient.ensureStreamAlive?.();
608
+ effectiveMediaClient.ensureStreamAliveAfterSend?.();
584
609
  } catch {}
585
610
  } else {
586
611
  const anyMediaClient: any =
@@ -603,21 +628,32 @@ export const useSendMessage = (_configOverride?: IConfig) => {
603
628
  }
604
629
  }
605
630
 
606
- await handleMessageSent({
607
- message: 'media',
608
- roomJID: activeRoomJID,
609
- user,
610
- messageType: 'media',
611
- metadata: {
612
- isReply,
613
- isChecked,
614
- mainMessage,
615
- fileData: data,
616
- fileType: type,
617
- messageId: id,
618
- uploadResults: response.data.results,
619
- },
620
- });
631
+ // Post-send notification ONLY — see the text path: the upload and
632
+ // stanza send have already succeeded here, so a throw from the
633
+ // host's `onMessageSent` callback must not flip this to "Failed".
634
+ // Customer-reported #31.
635
+ try {
636
+ await handleMessageSent({
637
+ message: 'media',
638
+ roomJID: activeRoomJID,
639
+ user,
640
+ messageType: 'media',
641
+ metadata: {
642
+ isReply,
643
+ isChecked,
644
+ mainMessage,
645
+ fileData: data,
646
+ fileType: type,
647
+ messageId: id,
648
+ uploadResults: response.data.results,
649
+ },
650
+ });
651
+ } catch (notifyError) {
652
+ console.warn(
653
+ 'onMessageSent handler threw after a successful media send; not failing the message',
654
+ notifyError
655
+ );
656
+ }
621
657
  } catch (error: any) {
622
658
  // Upload (or stanza send) threw an explicit error. Clear the
623
659
  // watchdog so it doesn't double-fire `markMessageFailed`.
package/src/main.ts CHANGED
@@ -32,7 +32,7 @@ export { XmppProvider } from './context/xmppProvider';
32
32
  // Hooks that consumers usually need (badge counts, logout, push, etc.).
33
33
  export { useUnread } from './hooks/useUnreadMessagesCounter';
34
34
  export { useChatRoomFocus } from './hooks/useChatRoomFocus';
35
- export { logoutService, useLogout } from './hooks/useLogout';
35
+ export { logoutService } from './hooks/useLogout';
36
36
  export { useQRCodeChat, handleQRChatId } from './hooks/useQRCodeChatHandler';
37
37
  export { resendMessage } from './utils/resendMessage';
38
38
  // Font loader, exposed so hosts can also load the font outside <Chat> if needed.
@@ -46,6 +46,21 @@ export interface QueuedSend {
46
46
  * OUTBOUND_QUEUE_TTL_MS when omitted.
47
47
  */
48
48
  ttlMs?: number;
49
+ /**
50
+ * True once the stanza was actually written to a live, online stream.
51
+ *
52
+ * Such a send has most likely reached the server already — only its
53
+ * echo may still be in flight. Blind-replaying it on the next 'online'
54
+ * event is what produced real duplicates on the recipient side: a send
55
+ * is followed by a liveness probe which, at the time, force-reconnected
56
+ * a perfectly healthy but quiet stream on a single missed 4s round trip
57
+ * — and the resulting reconnect flushed the queue. (That probe has since
58
+ * been throttled and confirmation-gated, but this guard is the one that
59
+ * actually makes a duplicate impossible.) The queue exists for
60
+ * sends that never made it onto the wire, so those are the only ones
61
+ * safe to replay unattended. Customer-reported #31.
62
+ */
63
+ sentLive?: boolean;
49
64
  /** Replays the original send against a live, online client. */
50
65
  send: (client: OutboundQueueClient) => void;
51
66
  }
@@ -88,6 +103,14 @@ export function flushOutboundSends(
88
103
  // Stale — the send watchdog owns the failure; don't duplicate.
89
104
  continue;
90
105
  }
106
+ if (item.sentLive) {
107
+ // Already written to a live stream: the server very likely has it
108
+ // and we'd be sending a second copy. Let the pending watchdog own
109
+ // this one — if no echo ever arrives it surfaces as "Failed → tap
110
+ // to retry" and the USER decides, which is what the customer asked
111
+ // for ("a message the server accepted is never sent again").
112
+ continue;
113
+ }
91
114
  try {
92
115
  item.send(client);
93
116
  } catch (err) {
@@ -103,6 +126,12 @@ export function flushOutboundSends(
103
126
  * the send went through, so the buffered replay is no longer needed and must
104
127
  * not fire on a later reconnect. No-op if the id isn't queued.
105
128
  */
129
+ export function markOutboundSentLive(optimisticId: string): void {
130
+ if (!optimisticId) {return;}
131
+ const item = queue.find((q) => q.optimisticId === optimisticId);
132
+ if (item) {item.sentLive = true;}
133
+ }
134
+
106
135
  export function removeOutboundSend(optimisticId: string): void {
107
136
  if (!optimisticId) {return;}
108
137
  const idx = queue.findIndex((q) => q.optimisticId === optimisticId);
@@ -246,7 +246,15 @@ const onMessageHistory = async (stanza: any) => {
246
246
  mergedAttrs,
247
247
  body,
248
248
  id,
249
- stanza.attrs.from,
249
+ // `innerFrom` (the forwarded message's own `room@conference/nick`),
250
+ // NOT the outer MAM envelope's `from` — for an archive query the
251
+ // envelope is the BARE room jid with no resource, so passing it here
252
+ // stripped the sender out of `xmppFrom` and left `roomJid` derived
253
+ // from a value that carries no occupant identity. The correct
254
+ // occupant jid is already computed above for the senderJID fallback;
255
+ // use the same one so the archived copy carries the same sender
256
+ // identity the realtime path produces. Customer-reported #32.
257
+ innerFrom,
250
258
  !!deleted
251
259
  );
252
260
 
@@ -211,6 +211,13 @@ export class XmppClient {
211
211
  this.devServer = xmppSettings?.devServer;
212
212
  }
213
213
  this.disableLastRead = this.xmppSettings?.disableLastRead === true;
214
+ // `xmppPingOnSendEnabled` was declared in the public settings type but
215
+ // never read. It now controls the SEND-path liveness probe only; the
216
+ // periodic watchdog and the NetInfo check always run, so turning this
217
+ // off cannot leave a zombie socket undetected — it only removes the
218
+ // extra probe a send would otherwise trigger. Defaults to on
219
+ // (previous behaviour), just throttled and confirmation-gated now.
220
+ this.pingOnSendEnabled = this.xmppSettings?.xmppPingOnSendEnabled !== false;
214
221
 
215
222
  const qos = this.xmppSettings?.historyQoS;
216
223
  if (qos) {
@@ -494,6 +501,10 @@ export class XmppClient {
494
501
  this.status = 'online';
495
502
  this.presencesReady = true;
496
503
  this.reconnectAttempts = 0;
504
+ // Fresh stream: forget any probe failures from the dead one, and
505
+ // don't let the throttle suppress the first probe on it.
506
+ this.failedProbeStreak = 0;
507
+ this.lastStreamProbeAt = 0;
497
508
  try {
498
509
  devPushLog(
499
510
  'xmpp',
@@ -655,8 +666,54 @@ export class XmppClient {
655
666
  }
656
667
 
657
668
  private streamAliveProbe: Promise<boolean> | null = null;
669
+ /** Wall-clock of the last probe, for throttling. */
670
+ private lastStreamProbeAt = 0;
671
+ /** Consecutive failed probes; a reconnect needs confirmation, not one miss. */
672
+ private failedProbeStreak = 0;
673
+
674
+ // --- liveness-probe tuning -------------------------------------------
675
+ // The probe exists to catch a ZOMBIE socket: the WebSocket still reads
676
+ // `online` but nothing traverses it (iOS suspending a backgrounded
677
+ // socket, the JS debugger pausing the runtime). Without it the client
678
+ // sits "online" forever and every send silently vanishes.
679
+ //
680
+ // The original settings were far too trigger-happy: a 4s budget for a
681
+ // ping round-trip, fired after EVERY send, with a single timeout
682
+ // immediately calling `forceReconnect()` — a full teardown, rebuild,
683
+ // MUC re-join and private-store refetch. On a slow or congested mobile
684
+ // link a perfectly healthy but quiet stream would miss that 4s window
685
+ // and get torn down mid-conversation, repeatedly. (That reconnect is
686
+ // also what used to replay the outbound queue and duplicate a message
687
+ // that had already been delivered — customer-reported #31.)
688
+ //
689
+ // Tuned to: a realistic timeout, a throttle so bursts of sends can't
690
+ // each start a probe, and a required second failure before we tear a
691
+ // connection down. Zombie sockets fail every probe, so they are still
692
+ // caught — just after confirmation rather than on one unlucky RTT.
693
+ /** Per-probe budget. 4s was under a slow-network ping RTT. */
694
+ private streamProbeTimeoutMs = 10_000;
695
+ /** Minimum gap between probes, so a burst of sends probes at most once. */
696
+ private minStreamProbeIntervalMs = 15_000;
697
+ /** Delay before the confirmation probe that a teardown requires. */
698
+ private streamProbeConfirmDelayMs = 2_000;
699
+ /** Failures needed before `forceReconnect()`. */
700
+ private streamProbeFailuresForReconnect = 2;
701
+ /** Host opt-out for the send-path probe (`xmppPingOnSendEnabled`). */
702
+ private pingOnSendEnabled = true;
658
703
 
659
- verifyStreamAlive(timeoutMs: number = 4000): Promise<boolean> {
704
+ /**
705
+ * Send-path liveness check. Throttled and skippable by config — unlike
706
+ * the watchdog, this fires from user activity and must not turn a chatty
707
+ * room into a probe storm.
708
+ */
709
+ ensureStreamAliveAfterSend(): void {
710
+ if (!this.pingOnSendEnabled) {return;}
711
+ this.ensureStreamAlive({ reason: 'send' }).catch(() => {});
712
+ }
713
+
714
+ verifyStreamAlive(
715
+ timeoutMs: number = this.streamProbeTimeoutMs
716
+ ): Promise<boolean> {
660
717
  if (this.status !== 'online' || !this.client) {
661
718
  return Promise.resolve(false);
662
719
  }
@@ -688,16 +745,70 @@ export class XmppClient {
688
745
  return this.streamAliveProbe;
689
746
  }
690
747
 
691
- async ensureStreamAlive(timeoutMs: number = 4000): Promise<void> {
748
+ /**
749
+ * Check that the stream is really carrying traffic, and tear it down only
750
+ * if that is confirmed false.
751
+ *
752
+ * `force` skips the throttle — used by the periodic watchdog and the
753
+ * NetInfo "we're reachable again" signal, which are already rare and
754
+ * genuinely want to know right now. Send-path callers leave it off so a
755
+ * burst of messages triggers at most one probe.
756
+ */
757
+ async ensureStreamAlive(opts?: {
758
+ force?: boolean;
759
+ reason?: string;
760
+ }): Promise<void> {
692
761
  if (this.suppressReconnect || this.status !== 'online') {return;}
693
- const alive = await this.verifyStreamAlive(timeoutMs);
694
- // Re-check state: a reconnect may have started meanwhile.
695
- if (!alive && this.status === 'online' && !this.suppressReconnect) {
762
+
763
+ const now = Date.now();
764
+ if (
765
+ !opts?.force &&
766
+ now - this.lastStreamProbeAt < this.minStreamProbeIntervalMs
767
+ ) {
768
+ // Probed recently — a healthy answer is still fresh, and a dead
769
+ // socket will be caught by the next watchdog tick anyway.
770
+ return;
771
+ }
772
+ this.lastStreamProbeAt = now;
773
+
774
+ const alive = await this.verifyStreamAlive();
775
+
776
+ // Re-check state: a reconnect may have started while we waited.
777
+ if (this.status !== 'online' || this.suppressReconnect) {return;}
778
+
779
+ if (alive) {
780
+ this.failedProbeStreak = 0;
781
+ return;
782
+ }
783
+
784
+ this.failedProbeStreak += 1;
785
+ if (this.failedProbeStreak < this.streamProbeFailuresForReconnect) {
786
+ // One missed probe is not proof of a dead socket — a congested link
787
+ // or a busy server can blow the budget on a stream that is fine.
788
+ // Confirm with a second probe shortly, instead of tearing down a
789
+ // working connection on a single unlucky round trip.
696
790
  try {
697
- devPushLog('xmpp', 'stream is zombie (ping unanswered) → forceReconnect');
791
+ devPushLog(
792
+ 'xmpp',
793
+ `stream probe missed (${this.failedProbeStreak}/${this.streamProbeFailuresForReconnect}) — confirming before reconnect`
794
+ );
698
795
  } catch {}
699
- this.forceReconnect();
796
+ setTimeout(() => {
797
+ this.ensureStreamAlive({ force: true, reason: 'probe-confirm' }).catch(
798
+ () => {}
799
+ );
800
+ }, this.streamProbeConfirmDelayMs);
801
+ return;
700
802
  }
803
+
804
+ this.failedProbeStreak = 0;
805
+ try {
806
+ devPushLog(
807
+ 'xmpp',
808
+ 'stream is zombie (probe unanswered twice) → forceReconnect'
809
+ );
810
+ } catch {}
811
+ this.forceReconnect();
701
812
  }
702
813
 
703
814
  async reconnect() {