@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.
- package/CHANGELOG.md +0 -9
- package/README.md +0 -22
- package/lib/commonjs/assets/icons.js +2 -2
- package/lib/commonjs/assets/icons.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatRoom.js +10 -10
- package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/commonjs/components/MainComponents/MessageList.js +22 -5
- package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js +8 -9
- package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
- package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +37 -110
- package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
- package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +14 -16
- package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
- package/lib/commonjs/components/Modals/NewChatModal/ModalContent.js +1 -2
- package/lib/commonjs/components/Modals/NewChatModal/ModalContent.js.map +1 -1
- package/lib/commonjs/components/Modals/NewChatModal/UpdNewChatModal.js +0 -8
- package/lib/commonjs/components/Modals/NewChatModal/UpdNewChatModal.js.map +1 -1
- package/lib/commonjs/components/Modals/UserProfileModal/EditUserModal.js +21 -7
- package/lib/commonjs/components/Modals/UserProfileModal/EditUserModal.js.map +1 -1
- package/lib/commonjs/components/styled/SendInput.js +10 -0
- package/lib/commonjs/components/styled/SendInput.js.map +1 -1
- package/lib/commonjs/context/xmppProvider.js +35 -6
- package/lib/commonjs/context/xmppProvider.js.map +1 -1
- package/lib/commonjs/helpers/updateMessagesTillLast.js +40 -12
- package/lib/commonjs/helpers/updateMessagesTillLast.js.map +1 -1
- package/lib/commonjs/hooks/useChatRoomFocus.js +9 -3
- package/lib/commonjs/hooks/useChatRoomFocus.js.map +1 -1
- package/lib/commonjs/hooks/useLogout.js +5 -0
- package/lib/commonjs/hooks/useLogout.js.map +1 -1
- package/lib/commonjs/hooks/useSendMessage.js +50 -23
- package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
- package/lib/commonjs/main.js +0 -6
- package/lib/commonjs/main.js.map +1 -1
- package/lib/commonjs/networking/outboundQueue.js +18 -0
- package/lib/commonjs/networking/outboundQueue.js.map +1 -1
- package/lib/commonjs/networking/stanzaHandlers.js +10 -1
- package/lib/commonjs/networking/stanzaHandlers.js.map +1 -1
- package/lib/commonjs/networking/xmppClient.js +107 -7
- package/lib/commonjs/networking/xmppClient.js.map +1 -1
- package/lib/commonjs/roomStore/roomsSlice.js +22 -3
- package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
- package/lib/module/assets/icons.js +1 -1
- package/lib/module/assets/icons.js.map +1 -1
- package/lib/module/components/MainComponents/ChatRoom.js +10 -10
- package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/module/components/MainComponents/MessageList.js +23 -6
- package/lib/module/components/MainComponents/MessageList.js.map +1 -1
- package/lib/module/components/MainComponents/ProfileImagePlaceholder.js +8 -9
- package/lib/module/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
- package/lib/module/components/Menu/HeaderRoomListMenu.js +38 -110
- package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
- package/lib/module/components/Modals/AttachSheet/AttachSheet.js +14 -16
- package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
- package/lib/module/components/Modals/NewChatModal/ModalContent.js +1 -2
- package/lib/module/components/Modals/NewChatModal/ModalContent.js.map +1 -1
- package/lib/module/components/Modals/NewChatModal/UpdNewChatModal.js +0 -8
- package/lib/module/components/Modals/NewChatModal/UpdNewChatModal.js.map +1 -1
- package/lib/module/components/Modals/UserProfileModal/EditUserModal.js +23 -9
- package/lib/module/components/Modals/UserProfileModal/EditUserModal.js.map +1 -1
- package/lib/module/components/styled/SendInput.js +11 -1
- package/lib/module/components/styled/SendInput.js.map +1 -1
- package/lib/module/context/xmppProvider.js +35 -6
- package/lib/module/context/xmppProvider.js.map +1 -1
- package/lib/module/helpers/updateMessagesTillLast.js +41 -13
- package/lib/module/helpers/updateMessagesTillLast.js.map +1 -1
- package/lib/module/hooks/useChatRoomFocus.js +9 -3
- package/lib/module/hooks/useChatRoomFocus.js.map +1 -1
- package/lib/module/hooks/useLogout.js +5 -0
- package/lib/module/hooks/useLogout.js.map +1 -1
- package/lib/module/hooks/useSendMessage.js +51 -24
- package/lib/module/hooks/useSendMessage.js.map +1 -1
- package/lib/module/main.js +1 -1
- package/lib/module/main.js.map +1 -1
- package/lib/module/networking/outboundQueue.js +17 -0
- package/lib/module/networking/outboundQueue.js.map +1 -1
- package/lib/module/networking/stanzaHandlers.js +10 -1
- package/lib/module/networking/stanzaHandlers.js.map +1 -1
- package/lib/module/networking/xmppClient.js +107 -7
- package/lib/module/networking/xmppClient.js.map +1 -1
- package/lib/module/roomStore/roomsSlice.js +20 -2
- package/lib/module/roomStore/roomsSlice.js.map +1 -1
- package/lib/typescript/assets/icons.d.ts +0 -4
- package/lib/typescript/assets/icons.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ProfileImagePlaceholder.d.ts.map +1 -1
- package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts +0 -3
- package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
- package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
- package/lib/typescript/components/Modals/NewChatModal/ModalContent.d.ts +2 -4
- package/lib/typescript/components/Modals/NewChatModal/ModalContent.d.ts.map +1 -1
- package/lib/typescript/components/Modals/NewChatModal/UpdNewChatModal.d.ts.map +1 -1
- package/lib/typescript/components/Modals/UserProfileModal/EditUserModal.d.ts.map +1 -1
- package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
- package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
- package/lib/typescript/helpers/updateMessagesTillLast.d.ts.map +1 -1
- package/lib/typescript/hooks/useChatRoomFocus.d.ts.map +1 -1
- package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
- package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
- package/lib/typescript/main.d.ts +1 -1
- package/lib/typescript/main.d.ts.map +1 -1
- package/lib/typescript/networking/outboundQueue.d.ts +16 -0
- package/lib/typescript/networking/outboundQueue.d.ts.map +1 -1
- package/lib/typescript/networking/stanzaHandlers.d.ts.map +1 -1
- package/lib/typescript/networking/xmppClient.d.ts +33 -1
- package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
- package/lib/typescript/roomStore/roomsSlice.d.ts +18 -1
- package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
- package/lib/typescript/types/models/room.model.d.ts +15 -0
- package/lib/typescript/types/models/room.model.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +15 -12
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/assets/icons.tsx +0 -19
- package/src/components/MainComponents/ChatRoom.tsx +13 -9
- package/src/components/MainComponents/MessageList.tsx +23 -6
- package/src/components/MainComponents/ProfileImagePlaceholder.tsx +9 -16
- package/src/components/Menu/HeaderRoomListMenu.tsx +11 -105
- package/src/components/Modals/AttachSheet/AttachSheet.tsx +14 -16
- package/src/components/Modals/NewChatModal/ModalContent.tsx +3 -6
- package/src/components/Modals/NewChatModal/UpdNewChatModal.tsx +1 -12
- package/src/components/Modals/UserProfileModal/EditUserModal.tsx +25 -9
- package/src/components/styled/SendInput.tsx +10 -0
- package/src/context/xmppProvider.tsx +41 -8
- package/src/helpers/updateMessagesTillLast.tsx +42 -17
- package/src/hooks/useChatRoomFocus.ts +11 -3
- package/src/hooks/useLogout.tsx +7 -0
- package/src/hooks/useSendMessage.tsx +60 -24
- package/src/main.ts +1 -1
- package/src/networking/outboundQueue.ts +29 -0
- package/src/networking/stanzaHandlers.ts +9 -1
- package/src/networking/xmppClient.ts +118 -7
- package/src/roomStore/roomsSlice.ts +20 -2
- package/src/types/models/room.model.ts +15 -0
- package/src/types/types.ts +15 -14
- package/lib/commonjs/helpers/pickImageAsset.js +0 -68
- package/lib/commonjs/helpers/pickImageAsset.js.map +0 -1
- package/lib/module/helpers/pickImageAsset.js +0 -63
- package/lib/module/helpers/pickImageAsset.js.map +0 -1
- package/lib/typescript/helpers/pickImageAsset.d.ts +0 -27
- package/lib/typescript/helpers/pickImageAsset.d.ts.map +0 -1
- 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 |
|
|
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 (
|
|
40
|
-
|
|
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 =
|
|
67
|
-
|
|
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
|
-
|
|
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({
|
|
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, {
|
|
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({
|
|
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, {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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, {
|
|
72
|
+
?.flushLastViewedToPrivateStoreStanza(rooms, {
|
|
73
|
+
visibleRoomJID: jid,
|
|
74
|
+
visibleRoomTs: boundaryTs,
|
|
75
|
+
})
|
|
68
76
|
.catch(() => {});
|
|
69
77
|
};
|
|
70
78
|
|
package/src/hooks/useLogout.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
if (
|
|
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(
|
|
791
|
+
devPushLog(
|
|
792
|
+
'xmpp',
|
|
793
|
+
`stream probe missed (${this.failedProbeStreak}/${this.streamProbeFailuresForReconnect}) — confirming before reconnect`
|
|
794
|
+
);
|
|
698
795
|
} catch {}
|
|
699
|
-
|
|
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() {
|