@ethora/chat-component-rn 26.7.9 → 26.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -0
- package/README.md +10 -0
- package/lib/commonjs/components/MainComponents/ChatRoom.js +63 -15
- package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/commonjs/context/xmppProvider.js +58 -14
- package/lib/commonjs/context/xmppProvider.js.map +1 -1
- package/lib/commonjs/helpers/getServerReadTimestamp.js +66 -3
- package/lib/commonjs/helpers/getServerReadTimestamp.js.map +1 -1
- package/lib/commonjs/helpers/insertMessageWithDelimiter.js +51 -9
- package/lib/commonjs/helpers/insertMessageWithDelimiter.js.map +1 -1
- package/lib/commonjs/helpers/keyboardLayout.js +12 -2
- package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
- package/lib/commonjs/helpers/msgSortableMs.js +37 -0
- package/lib/commonjs/helpers/msgSortableMs.js.map +1 -0
- package/lib/commonjs/hooks/useChatRoomFocus.js +31 -7
- package/lib/commonjs/hooks/useChatRoomFocus.js.map +1 -1
- package/lib/commonjs/hooks/useLogout.js +8 -0
- package/lib/commonjs/hooks/useLogout.js.map +1 -1
- package/lib/commonjs/roomStore/roomsSlice.js +66 -32
- package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
- package/lib/module/components/MainComponents/ChatRoom.js +65 -17
- package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/module/context/xmppProvider.js +59 -15
- package/lib/module/context/xmppProvider.js.map +1 -1
- package/lib/module/helpers/getServerReadTimestamp.js +63 -2
- package/lib/module/helpers/getServerReadTimestamp.js.map +1 -1
- package/lib/module/helpers/insertMessageWithDelimiter.js +52 -9
- package/lib/module/helpers/insertMessageWithDelimiter.js.map +1 -1
- package/lib/module/helpers/keyboardLayout.js +12 -2
- package/lib/module/helpers/keyboardLayout.js.map +1 -1
- package/lib/module/helpers/msgSortableMs.js +32 -0
- package/lib/module/helpers/msgSortableMs.js.map +1 -0
- package/lib/module/hooks/useChatRoomFocus.js +33 -9
- package/lib/module/hooks/useChatRoomFocus.js.map +1 -1
- package/lib/module/hooks/useLogout.js +8 -0
- package/lib/module/hooks/useLogout.js.map +1 -1
- package/lib/module/roomStore/roomsSlice.js +53 -27
- package/lib/module/roomStore/roomsSlice.js.map +1 -1
- package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
- package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
- package/lib/typescript/helpers/getServerReadTimestamp.d.ts +40 -1
- package/lib/typescript/helpers/getServerReadTimestamp.d.ts.map +1 -1
- package/lib/typescript/helpers/insertMessageWithDelimiter.d.ts +1 -1
- package/lib/typescript/helpers/insertMessageWithDelimiter.d.ts.map +1 -1
- package/lib/typescript/helpers/keyboardLayout.d.ts +5 -1
- package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
- package/lib/typescript/helpers/msgSortableMs.d.ts +2 -0
- package/lib/typescript/helpers/msgSortableMs.d.ts.map +1 -0
- package/lib/typescript/hooks/useChatRoomFocus.d.ts.map +1 -1
- package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
- package/lib/typescript/roomStore/roomsSlice.d.ts +29 -2
- package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +1 -0
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/MainComponents/ChatRoom.tsx +68 -17
- package/src/context/xmppProvider.tsx +87 -18
- package/src/helpers/getServerReadTimestamp.ts +79 -2
- package/src/helpers/insertMessageWithDelimiter.ts +55 -19
- package/src/helpers/keyboardLayout.ts +23 -2
- package/src/helpers/msgSortableMs.ts +25 -0
- package/src/hooks/useChatRoomFocus.ts +39 -8
- package/src/hooks/useLogout.tsx +14 -0
- package/src/roomStore/roomsSlice.ts +74 -22
- package/src/types/types.ts +14 -0
|
@@ -6,10 +6,12 @@ import { useDispatch } from 'react-redux';
|
|
|
6
6
|
import MessageList from './MessageList';
|
|
7
7
|
import SendInput from '../styled/SendInput';
|
|
8
8
|
import {
|
|
9
|
+
clearReadBoundary,
|
|
9
10
|
clearVisibleRoom,
|
|
10
11
|
deleteRoomMessage,
|
|
11
12
|
setEditAction,
|
|
12
13
|
setLastViewedTimestamp,
|
|
14
|
+
setReadBoundary,
|
|
13
15
|
setVisibleRoom,
|
|
14
16
|
} from '../../roomStore/roomsSlice';
|
|
15
17
|
import Loader from '../styled/Loader';
|
|
@@ -47,7 +49,10 @@ import {
|
|
|
47
49
|
} from 'react-native-keyboard-controller';
|
|
48
50
|
import useComposing from '../../hooks/useComposing';
|
|
49
51
|
import { store } from '../../roomStore';
|
|
50
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
getFlushBoundaryTs,
|
|
54
|
+
getReadMarkerTimestamp,
|
|
55
|
+
} from '../../helpers/getServerReadTimestamp';
|
|
51
56
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
52
57
|
import {
|
|
53
58
|
getInputDockPaddingBottom,
|
|
@@ -218,32 +223,62 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
|
|
|
218
223
|
// Tracks what the user actually saw: `null` while they're at the
|
|
219
224
|
// bottom (safe to mark everything read), or the timestamp of the
|
|
220
225
|
// newest message visible when they scrolled away from it. Reported
|
|
221
|
-
// by MessageList via onReadBoundaryChange
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
+
// by MessageList via onReadBoundaryChange, and mirrored into redux
|
|
227
|
+
// (`rooms.readBoundaries`) rather than kept only in a local ref - it
|
|
228
|
+
// is the single source of truth every "leaving this room" path
|
|
229
|
+
// consults (xmppProvider's AppState background handler, its
|
|
230
|
+
// `isVisible=false` handler, the live `advance()` effect, and
|
|
231
|
+
// `useChatRoomFocus`'s `leaveRoom`), not just this component's own
|
|
232
|
+
// unmount cleanup below. Without a boundary, leaving a room (or
|
|
233
|
+
// backgrounding) while scrolled up stamped `now()` (bug #33) or,
|
|
234
|
+
// after the #38 fix, the newest acked message regardless of scroll
|
|
235
|
+
// position (bug #42) - both silently discard genuinely-unread
|
|
236
|
+
// messages.
|
|
237
|
+
//
|
|
238
|
+
// MessageList reports the boundary from onScroll, which fires on
|
|
239
|
+
// every frame of a drag (the "back at the bottom" branch re-reports
|
|
240
|
+
// `null` each time). Mirror only actual CHANGES into redux: an
|
|
241
|
+
// unconditional dispatch per scroll frame would run the whole
|
|
242
|
+
// middleware chain and, worse, reset persistenceMiddleware's 200 ms
|
|
243
|
+
// debounce on every frame, starving the persisted write for as long
|
|
244
|
+
// as the user keeps scrolling.
|
|
245
|
+
const lastBoundarySentRef = useRef<number | null>(null);
|
|
226
246
|
const handleReadBoundaryChange = useCallback((boundaryTs: number | null) => {
|
|
227
|
-
|
|
228
|
-
|
|
247
|
+
if (!activeRoomJID) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const next = boundaryTs && boundaryTs > 0 ? boundaryTs : null;
|
|
251
|
+
if (lastBoundarySentRef.current === next) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
lastBoundarySentRef.current = next;
|
|
255
|
+
dispatch(setReadBoundary({ jid: activeRoomJID, ts: next }));
|
|
256
|
+
}, [activeRoomJID, dispatch]);
|
|
229
257
|
|
|
230
258
|
useEffect(() => {
|
|
231
259
|
if (!activeRoomJID) {
|
|
232
260
|
return;
|
|
233
261
|
}
|
|
234
262
|
|
|
235
|
-
|
|
263
|
+
lastBoundarySentRef.current = null;
|
|
264
|
+
dispatch(setReadBoundary({ jid: activeRoomJID, ts: null }));
|
|
236
265
|
dispatch(setVisibleRoom({ roomJID: activeRoomJID }));
|
|
237
266
|
setIsLoadingMore(false);
|
|
238
267
|
return () => {
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
268
|
+
const state = store.getState();
|
|
269
|
+
const rooms = state.rooms?.rooms;
|
|
270
|
+
const heapState = state.roomHeapSlice;
|
|
271
|
+
const boundaryTs = state.rooms?.readBoundaries?.[activeRoomJID] ?? null;
|
|
272
|
+
// getReadMarkerTimestamp honours the boundary (the newest message
|
|
273
|
+
// the user actually reached) when one is set, and otherwise falls
|
|
274
|
+
// back to the newest server-acked message - never the device
|
|
242
275
|
// clock: a fast device clock would write a future marker that the
|
|
243
276
|
// forward-only private-store merge could never correct (bug #38).
|
|
244
|
-
const timestamp =
|
|
245
|
-
|
|
246
|
-
|
|
277
|
+
const timestamp = getReadMarkerTimestamp(
|
|
278
|
+
rooms?.[activeRoomJID],
|
|
279
|
+
heapState,
|
|
280
|
+
boundaryTs,
|
|
281
|
+
);
|
|
247
282
|
if (timestamp > 0) {
|
|
248
283
|
dispatch(
|
|
249
284
|
setLastViewedTimestamp({
|
|
@@ -261,13 +296,27 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
|
|
|
261
296
|
// Carry the same boundary to the SERVER marker. Without
|
|
262
297
|
// this the flush defaults to "everything" for the visible
|
|
263
298
|
// room, so messages the user never scrolled down to come
|
|
264
|
-
// back as read on the next login
|
|
299
|
+
// back as read on the next login - the local count was
|
|
265
300
|
// right but the server overrode it.
|
|
266
|
-
|
|
301
|
+
// Clamped through the same helper as the local stamp -
|
|
302
|
+
// an unclamped boundary would bypass the newest-acked
|
|
303
|
+
// ceiling on the one path where a bad value is permanent
|
|
304
|
+
// (the private-store merge is forward-only, bug #38).
|
|
305
|
+
visibleRoomTs: getFlushBoundaryTs(
|
|
306
|
+
rooms?.[activeRoomJID],
|
|
307
|
+
heapState,
|
|
308
|
+
boundaryTs,
|
|
309
|
+
),
|
|
267
310
|
})
|
|
268
311
|
.catch(() => {});
|
|
269
312
|
}
|
|
270
313
|
dispatch(deleteRoomMessage({ roomJID: activeRoomJID, messageId: 'delimiter-new' }));
|
|
314
|
+
// The room is genuinely being left (unmount, or activeRoomJID
|
|
315
|
+
// changed to a different room) - release the boundary now that
|
|
316
|
+
// it's been consumed, so a stale value can't leak into the next
|
|
317
|
+
// time this room becomes visible.
|
|
318
|
+
dispatch(clearReadBoundary({ jid: activeRoomJID }));
|
|
319
|
+
lastBoundarySentRef.current = null;
|
|
271
320
|
setIsLoadingMore(false);
|
|
272
321
|
};
|
|
273
322
|
}, [activeRoomJID, dispatch]);
|
|
@@ -303,6 +352,8 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
|
|
|
303
352
|
const inputDockPaddingBottom = getInputDockPaddingBottom({
|
|
304
353
|
platform: Platform.OS,
|
|
305
354
|
bottomInset: insets.bottom,
|
|
355
|
+
configuredPadding: configWithEventHandlers?.inputDockPaddingBottom,
|
|
356
|
+
hostOwnsLayout: !!configWithEventHandlers?.disableKeyboardAvoidingView,
|
|
306
357
|
});
|
|
307
358
|
|
|
308
359
|
// Keyboard avoidance is delegated to react-native-keyboard-controller's
|
|
@@ -48,7 +48,10 @@ import {
|
|
|
48
48
|
deleteRoomMessage,
|
|
49
49
|
setUnreadSyncing,
|
|
50
50
|
} from '../roomStore/roomsSlice';
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
getFlushBoundaryTs,
|
|
53
|
+
getReadMarkerTimestamp,
|
|
54
|
+
} from '../helpers/getServerReadTimestamp';
|
|
52
55
|
import { runHistoryPreloadScheduler } from '../helpers/historyPreloadScheduler';
|
|
53
56
|
import { updateMessagesTillLast } from '../helpers/updateMessagesTillLast';
|
|
54
57
|
import { secureUserStorage } from '../helpers/secureUserStorage';
|
|
@@ -638,7 +641,8 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
|
|
|
638
641
|
// Mark the open room "not visible" while backgrounded so messages
|
|
639
642
|
// that arrive (or MAM-replay on reconnect) count as unread instead
|
|
640
643
|
// of being silently treated as read — the "mounted == visible ==
|
|
641
|
-
// read" gap. Stamp lastViewed=<
|
|
644
|
+
// read" gap. Stamp lastViewed=<the read boundary, if the user left
|
|
645
|
+
// scrolled up, else the newest server-acked message>, not
|
|
642
646
|
// Date.now(), as the read baseline: a device clock running ahead
|
|
643
647
|
// would otherwise write a future marker that the forward-only
|
|
644
648
|
// private-store merge can never correct again (bug #38). Then
|
|
@@ -647,11 +651,23 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
|
|
|
647
651
|
// here in the provider so consumers using the component as a
|
|
648
652
|
// package get correct unread without reaching into the chat store
|
|
649
653
|
// themselves.
|
|
654
|
+
//
|
|
655
|
+
// The read boundary (rooms.readBoundaries) is deliberately left
|
|
656
|
+
// untouched here - MessageList normally stays mounted through a
|
|
657
|
+
// simple background/foreground cycle, so its own scroll-tracking
|
|
658
|
+
// ref (the only thing that could re-derive this value) is still
|
|
659
|
+
// intact, and clearing the redux copy would desync the two. It's
|
|
660
|
+
// only USED here for the one-shot stamp+flush, matching customer
|
|
661
|
+
// #42: leaving (or backgrounding) a room while scrolled up must not
|
|
662
|
+
// mark messages the user never reached as read.
|
|
650
663
|
visibleBeforeBackground = visibleRoomJID;
|
|
664
|
+
let boundaryTs: number | null = null;
|
|
651
665
|
if (visibleRoomJID) {
|
|
652
|
-
|
|
666
|
+
boundaryTs = state.rooms?.readBoundaries?.[visibleRoomJID] ?? null;
|
|
667
|
+
const readTs = getReadMarkerTimestamp(
|
|
653
668
|
rooms?.[visibleRoomJID],
|
|
654
|
-
state.roomHeapSlice
|
|
669
|
+
state.roomHeapSlice,
|
|
670
|
+
boundaryTs
|
|
655
671
|
);
|
|
656
672
|
// Nothing to anchor to yet (no known messages, no prior marker)
|
|
657
673
|
// - skip the local stamp rather than fall back to the device
|
|
@@ -671,10 +687,21 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
|
|
|
671
687
|
store.dispatch(clearVisibleRoom());
|
|
672
688
|
}
|
|
673
689
|
// Fire-and-forget — we're going to the background and don't
|
|
674
|
-
// care about the resolution path.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
690
|
+
// care about the resolution path. `visibleRoomTs` carries the same
|
|
691
|
+
// boundary to the SERVER marker - without it the flush defaults to
|
|
692
|
+
// "everything" for the visible room, overriding a correct smaller
|
|
693
|
+
// local count.
|
|
694
|
+
c.flushLastViewedToPrivateStoreStanza(rooms, {
|
|
695
|
+
visibleRoomJID,
|
|
696
|
+
// Clamped through the same helper as the local stamp above.
|
|
697
|
+
visibleRoomTs: visibleRoomJID
|
|
698
|
+
? getFlushBoundaryTs(
|
|
699
|
+
rooms?.[visibleRoomJID],
|
|
700
|
+
state.roomHeapSlice,
|
|
701
|
+
boundaryTs
|
|
702
|
+
)
|
|
703
|
+
: undefined,
|
|
704
|
+
}).catch(() => {});
|
|
678
705
|
});
|
|
679
706
|
return () => sub.remove();
|
|
680
707
|
}, [client, config?.xmppSettings?.keepAliveInBackground]);
|
|
@@ -705,13 +732,24 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
|
|
|
705
732
|
store.dispatch(setVisibleRoom({ roomJID: activeRoomJID }));
|
|
706
733
|
}
|
|
707
734
|
} else {
|
|
735
|
+
// The read boundary (rooms.readBoundaries) is USED for the stamp
|
|
736
|
+
// and flush below but deliberately left in place - a host using
|
|
737
|
+
// `isVisible` keeps <Chat> mounted while hidden, so MessageList's
|
|
738
|
+
// own scroll-tracking ref is still intact and the redux copy must
|
|
739
|
+
// stay in sync with it. Only consumed, never Date.now(): a fast
|
|
740
|
+
// device clock would write a future marker that the forward-only
|
|
741
|
+
// private-store merge could then never correct (bug #38). And
|
|
742
|
+
// never "the newest acked message" unconditionally either - that
|
|
743
|
+
// silently marks messages the user scrolled past and never reached
|
|
744
|
+
// as read (bug #42, a regression of #33).
|
|
745
|
+
const boundaryTs = activeRoomJID
|
|
746
|
+
? state.rooms?.readBoundaries?.[activeRoomJID] ?? null
|
|
747
|
+
: null;
|
|
708
748
|
if (wasVisible && activeRoomJID) {
|
|
709
|
-
|
|
710
|
-
// marker that the forward-only private-store merge could then
|
|
711
|
-
// never correct (bug #38).
|
|
712
|
-
const readTs = getServerReadTimestamp(
|
|
749
|
+
const readTs = getReadMarkerTimestamp(
|
|
713
750
|
rooms?.[activeRoomJID],
|
|
714
|
-
state.roomHeapSlice
|
|
751
|
+
state.roomHeapSlice,
|
|
752
|
+
boundaryTs
|
|
715
753
|
);
|
|
716
754
|
if (readTs > 0) {
|
|
717
755
|
store.dispatch(
|
|
@@ -728,7 +766,19 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
|
|
|
728
766
|
store.dispatch(clearVisibleRoom());
|
|
729
767
|
if (wasVisible && client?.flushLastViewedToPrivateStoreStanza) {
|
|
730
768
|
client
|
|
731
|
-
.flushLastViewedToPrivateStoreStanza(rooms, {
|
|
769
|
+
.flushLastViewedToPrivateStoreStanza(rooms, {
|
|
770
|
+
visibleRoomJID: activeRoomJID,
|
|
771
|
+
// Carry the same boundary to the SERVER marker - without it
|
|
772
|
+
// the flush defaults to "everything" for the visible room.
|
|
773
|
+
// Clamped exactly like the local stamp above.
|
|
774
|
+
visibleRoomTs: activeRoomJID
|
|
775
|
+
? getFlushBoundaryTs(
|
|
776
|
+
rooms?.[activeRoomJID],
|
|
777
|
+
state.roomHeapSlice,
|
|
778
|
+
boundaryTs
|
|
779
|
+
)
|
|
780
|
+
: undefined,
|
|
781
|
+
})
|
|
732
782
|
.catch(() => {});
|
|
733
783
|
}
|
|
734
784
|
}
|
|
@@ -749,13 +799,21 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
|
|
|
749
799
|
if (room.messages === lastMessagesRef) {return;}
|
|
750
800
|
lastMessagesRef = room.messages;
|
|
751
801
|
|
|
752
|
-
//
|
|
802
|
+
// getReadMarkerTimestamp (not a hand-rolled scan + Date.now()):
|
|
753
803
|
// it excludes pending/optimistic messages, which carry the DEVICE
|
|
754
804
|
// send time via `date` until the server echoes them back - a
|
|
755
805
|
// manual scan without that exclusion could pick up a pending
|
|
756
806
|
// message's device timestamp here and write a future marker the
|
|
757
|
-
// same way a raw Date.now() would (bug #38).
|
|
758
|
-
|
|
807
|
+
// same way a raw Date.now() would (bug #38). And when the user has
|
|
808
|
+
// scrolled up (a boundary is set for this room), it clamps to that
|
|
809
|
+
// boundary instead of the newest message - without this, every
|
|
810
|
+
// incoming message while scrolled up advanced the marker to
|
|
811
|
+
// "newest" regardless of scroll position, and the later, smaller
|
|
812
|
+
// boundary write on leave was rejected by the forward-only
|
|
813
|
+
// private-store merge, leaving 0 unread even after a restart
|
|
814
|
+
// (customer #42, a regression of #33 reintroduced by #38).
|
|
815
|
+
const boundaryTs = s.rooms?.readBoundaries?.[jid] ?? null;
|
|
816
|
+
const newest = getReadMarkerTimestamp(room, s.roomHeapSlice, boundaryTs);
|
|
759
817
|
if (!newest || newest <= lastStampedMs) {return;}
|
|
760
818
|
lastStampedMs = newest;
|
|
761
819
|
|
|
@@ -766,9 +824,20 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
|
|
|
766
824
|
if (flushTimer) {clearTimeout(flushTimer);}
|
|
767
825
|
if (client?.flushLastViewedToPrivateStoreStanza) {
|
|
768
826
|
flushTimer = setTimeout(() => {
|
|
827
|
+
// Re-read fresh state rather than closing over `boundaryTs`:
|
|
828
|
+
// the user may have scrolled back to the bottom (clearing the
|
|
829
|
+
// boundary) in the 2s between the stamp and this flush, and
|
|
830
|
+
// the flush should reflect that.
|
|
831
|
+
const freshState = store.getState();
|
|
832
|
+
const freshBoundaryTs = freshState.rooms?.readBoundaries?.[jid] ?? null;
|
|
769
833
|
client
|
|
770
|
-
.flushLastViewedToPrivateStoreStanza(
|
|
834
|
+
.flushLastViewedToPrivateStoreStanza(freshState.rooms?.rooms, {
|
|
771
835
|
visibleRoomJID: jid,
|
|
836
|
+
visibleRoomTs: getFlushBoundaryTs(
|
|
837
|
+
freshState.rooms?.rooms?.[jid],
|
|
838
|
+
freshState.roomHeapSlice,
|
|
839
|
+
freshBoundaryTs
|
|
840
|
+
),
|
|
772
841
|
})
|
|
773
842
|
.catch(() => {});
|
|
774
843
|
}, 2000);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IMessage, IRoom } from '../types/types';
|
|
2
|
-
import { msgSortableMs } from '
|
|
2
|
+
import { msgSortableMs } from './msgSortableMs';
|
|
3
3
|
|
|
4
4
|
// A stored/stamped read marker more than this far past the newest
|
|
5
5
|
// message we actually know about for a room is not "the user is a
|
|
@@ -42,7 +42,7 @@ type RoomForReadTimestamp = Pick<IRoom, 'jid' | 'messages' | 'lastViewedTimestam
|
|
|
42
42
|
* `heapState.failedMessages` (a send that never reached the server
|
|
43
43
|
* at all).
|
|
44
44
|
*
|
|
45
|
-
* `msgSortableMs` (
|
|
45
|
+
* `msgSortableMs` (helpers/msgSortableMs.ts) reads the server-assigned microsecond
|
|
46
46
|
* timestamp encoded in the first 13 digits of the message id - the same
|
|
47
47
|
* source already used for message ordering and unread counting - so
|
|
48
48
|
* this stays consistent with the rest of the read/unread pipeline.
|
|
@@ -118,3 +118,80 @@ export function isCorruptFutureReadMarker(
|
|
|
118
118
|
if (marker <= newestAckedMs + FUTURE_READ_MARKER_TOLERANCE_MS) {return false;}
|
|
119
119
|
return marker > Date.now() + FUTURE_READ_MARKER_TOLERANCE_MS;
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the marker to STAMP (locally and to the server) for `room`,
|
|
124
|
+
* honouring an explicit read boundary when one is set.
|
|
125
|
+
*
|
|
126
|
+
* Bug #42 (a regression of #33, reintroduced by #38): #38 made every
|
|
127
|
+
* caller stamp the newest server-acknowledged message unconditionally.
|
|
128
|
+
* That is correct for a user who is at the bottom of the room, but wrong
|
|
129
|
+
* for a user who scrolled up, received messages, and left without
|
|
130
|
+
* scrolling back down - stamping "newest" there silently marks
|
|
131
|
+
* messages they never saw as read, both locally and (because the
|
|
132
|
+
* private-store merge is forward-only) permanently on the server.
|
|
133
|
+
*
|
|
134
|
+
* `boundaryTs` is the msgSortableMs of the newest message the user
|
|
135
|
+
* actually reached (see `readBoundaries` on `RoomMessagesState`,
|
|
136
|
+
* populated by MessageList's `onReadBoundaryChange`). When it's set and
|
|
137
|
+
* positive, it wins - but is still clamped to never exceed the newest
|
|
138
|
+
* server-acknowledged message, so a stale/corrupt boundary can't stamp a
|
|
139
|
+
* marker further ahead than the room's own history supports. When no
|
|
140
|
+
* boundary is set (the common case - the user is at the bottom), this is
|
|
141
|
+
* exactly `getServerReadTimestamp`.
|
|
142
|
+
*
|
|
143
|
+
* Never returns `Date.now()`, directly or indirectly - same rule as
|
|
144
|
+
* `getServerReadTimestamp` (bug #38).
|
|
145
|
+
*/
|
|
146
|
+
export function getReadMarkerTimestamp(
|
|
147
|
+
room: RoomForReadTimestamp | null | undefined,
|
|
148
|
+
heapState?: ReadTimestampHeapState | null,
|
|
149
|
+
boundaryTs?: number | null
|
|
150
|
+
): number {
|
|
151
|
+
const newestAcked = getServerReadTimestamp(room, heapState);
|
|
152
|
+
if (
|
|
153
|
+
typeof boundaryTs === 'number' &&
|
|
154
|
+
Number.isFinite(boundaryTs) &&
|
|
155
|
+
boundaryTs > 0
|
|
156
|
+
) {
|
|
157
|
+
// `newestAcked === 0` means this room has NOTHING server-acked and no
|
|
158
|
+
// prior marker, so there is nothing to clamp the boundary against -
|
|
159
|
+
// and MessageList derives the boundary from the rendered list, which
|
|
160
|
+
// at that point can only contain pending/failed sends carrying the
|
|
161
|
+
// DEVICE clock via `date`. Returning it here would smuggle a
|
|
162
|
+
// device-clock marker back in through the boundary (bug #38). Return
|
|
163
|
+
// 0 = "nothing to anchor to, skip the write", same contract as
|
|
164
|
+
// getServerReadTimestamp.
|
|
165
|
+
return newestAcked > 0 ? Math.min(boundaryTs, newestAcked) : 0;
|
|
166
|
+
}
|
|
167
|
+
return newestAcked;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The value to hand `flushLastViewedToPrivateStore` as `visibleRoomTs`
|
|
172
|
+
* for a room being left, or `undefined` when no boundary applies (the
|
|
173
|
+
* flush then uses its own newest-acked default).
|
|
174
|
+
*
|
|
175
|
+
* Exists so the SERVER write goes through the SAME clamp as the local
|
|
176
|
+
* stamp. Passing the raw redux boundary straight through would bypass
|
|
177
|
+
* `getReadMarkerTimestamp`'s clamp on exactly the path where a bad value
|
|
178
|
+
* is permanent: the private-store merge is forward-only, so a boundary
|
|
179
|
+
* that (through a pending own message's device `date`, or a stale
|
|
180
|
+
* readBoundaries entry) sat ahead of the room's real history would be
|
|
181
|
+
* written verbatim and could never be corrected again (bug #38).
|
|
182
|
+
*/
|
|
183
|
+
export function getFlushBoundaryTs(
|
|
184
|
+
room: RoomForReadTimestamp | null | undefined,
|
|
185
|
+
heapState?: ReadTimestampHeapState | null,
|
|
186
|
+
boundaryTs?: number | null
|
|
187
|
+
): number | undefined {
|
|
188
|
+
if (
|
|
189
|
+
typeof boundaryTs !== 'number' ||
|
|
190
|
+
!Number.isFinite(boundaryTs) ||
|
|
191
|
+
boundaryTs <= 0
|
|
192
|
+
) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const clamped = getReadMarkerTimestamp(room, heapState, boundaryTs);
|
|
196
|
+
return clamped > 0 ? clamped : undefined;
|
|
197
|
+
}
|
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
import { IMessage } from '../types/types';
|
|
2
2
|
import { isDateAfter, isDateBefore } from './dateComparison';
|
|
3
|
+
import { msgSortableMs } from './msgSortableMs';
|
|
4
|
+
|
|
5
|
+
// Robustly resolve a caller-supplied read marker to epoch milliseconds.
|
|
6
|
+
// Callers pass a plain number (the redux read boundary, or a room's
|
|
7
|
+
// lastViewedTimestamp), a Date object (older call sites), or occasionally
|
|
8
|
+
// a numeric string. These are NOT interchangeable through the Date
|
|
9
|
+
// constructor: `new Date(1757000000123)` (a number) reads as epoch-ms,
|
|
10
|
+
// but `new Date("1757000000123")` (a string) is parsed as a date STRING
|
|
11
|
+
// - a bare 13-digit string matches no recognized date format, so the
|
|
12
|
+
// result is silently Invalid Date and every comparison against it is
|
|
13
|
+
// `false`. Try Number() first so a numeric string/number is read as
|
|
14
|
+
// epoch-ms; only fall through to Date parsing for genuine date/ISO
|
|
15
|
+
// strings (e.g. a Date object's own `.toString()`). Never falls back to
|
|
16
|
+
// `Date.now()` - a marker we can't resolve is treated as "no marker",
|
|
17
|
+
// not "now" (bug #38).
|
|
18
|
+
function resolveMarkerMs(
|
|
19
|
+
marker: number | { toString: () => string } | null | undefined
|
|
20
|
+
): number {
|
|
21
|
+
if (marker == null) {return 0;}
|
|
22
|
+
if (typeof marker === 'number') {
|
|
23
|
+
return Number.isFinite(marker) ? marker : 0;
|
|
24
|
+
}
|
|
25
|
+
const raw = marker.toString();
|
|
26
|
+
if (raw.trim() !== '') {
|
|
27
|
+
const asNumber = Number(raw);
|
|
28
|
+
if (Number.isFinite(asNumber)) {return asNumber;}
|
|
29
|
+
}
|
|
30
|
+
const parsed = Date.parse(raw);
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
32
|
+
}
|
|
3
33
|
|
|
4
34
|
function deepMerge(target: any, source: any): any {
|
|
5
35
|
for (const key in source) {
|
|
@@ -19,7 +49,7 @@ function deepMerge(target: any, source: any): any {
|
|
|
19
49
|
export function insertMessageWithDelimiter(
|
|
20
50
|
roomMessages: Partial<IMessage>[],
|
|
21
51
|
message: IMessage,
|
|
22
|
-
lastViewedTimestamp: { toString: () => string } | null,
|
|
52
|
+
lastViewedTimestamp: number | { toString: () => string } | null,
|
|
23
53
|
) {
|
|
24
54
|
const existingIndex = roomMessages.findIndex(
|
|
25
55
|
(msg) =>
|
|
@@ -62,33 +92,39 @@ export function insertMessageWithDelimiter(
|
|
|
62
92
|
roomMessages.push(message);
|
|
63
93
|
}
|
|
64
94
|
|
|
95
|
+
const lastViewedMs = resolveMarkerMs(lastViewedTimestamp);
|
|
96
|
+
const newMessageMs = msgSortableMs(message);
|
|
65
97
|
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
98
|
+
lastViewedMs > 0 &&
|
|
99
|
+
newMessageMs > lastViewedMs &&
|
|
100
|
+
!roomMessages.some((msg) => msg.id === 'delimiter-new')
|
|
69
101
|
) {
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
// Find the first message STRICTLY newer than the marker, using the
|
|
103
|
+
// SAME timestamp source (msgSortableMs - the server id-encoded ms)
|
|
104
|
+
// as the unread middleware and countNewerMessages use to decide
|
|
105
|
+
// what counts as unread. Comparing against a DIFFERENT source (the
|
|
106
|
+
// old code compared `msg.date` strings) risks the boundary message
|
|
107
|
+
// itself landing on the wrong side of the cut: since bug #38 the
|
|
108
|
+
// marker can equal a real message's own timestamp exactly, and any
|
|
109
|
+
// drift between `date` and the id-encoded ms - or a raw numeric
|
|
110
|
+
// marker silently producing an Invalid Date via the Date
|
|
111
|
+
// constructor's string-parsing path - could count that message as
|
|
112
|
+
// unread, pushing the divider one message too early (ABOVE the
|
|
113
|
+
// last read message instead of directly below it). Customer #42.
|
|
114
|
+
const delimiterIndex = roomMessages.findIndex(
|
|
115
|
+
(msg) => msg.id !== 'delimiter-new' && msgSortableMs(msg) > lastViewedMs
|
|
72
116
|
);
|
|
73
117
|
|
|
74
118
|
if (delimiterIndex !== -1) {
|
|
75
119
|
// The divider's date is what ORDERS it: roomsSlice re-sorts every
|
|
76
120
|
// merged page by timestamp (see `byMs`), so a divider stamped with
|
|
77
|
-
// `new Date()`
|
|
121
|
+
// `new Date()` (i.e. now) sorted past every real message and
|
|
78
122
|
// rendered at the very bottom, BELOW the new messages it is
|
|
79
123
|
// supposed to introduce. Anchor it a hair before the first unread
|
|
80
124
|
// instead, which is where it belongs and where any later sort keeps
|
|
81
|
-
// it.
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
String(roomMessages[delimiterIndex]?.date ?? '')
|
|
85
|
-
);
|
|
86
|
-
const rawLastViewed = lastViewedTimestamp.toString();
|
|
87
|
-
const lastViewedMs =
|
|
88
|
-
Date.parse(rawLastViewed) || Number(rawLastViewed) || Date.now();
|
|
89
|
-
const anchorMs = Number.isFinite(firstUnreadMs)
|
|
90
|
-
? firstUnreadMs - 1
|
|
91
|
-
: lastViewedMs;
|
|
125
|
+
// it.
|
|
126
|
+
const firstUnreadMs = msgSortableMs(roomMessages[delimiterIndex]);
|
|
127
|
+
const anchorMs = firstUnreadMs > 0 ? firstUnreadMs - 1 : lastViewedMs;
|
|
92
128
|
|
|
93
129
|
roomMessages.splice(delimiterIndex, 0, {
|
|
94
130
|
id: 'delimiter-new',
|
|
@@ -103,7 +139,7 @@ export function insertMessageWithDelimiter(
|
|
|
103
139
|
roomJid: '',
|
|
104
140
|
});
|
|
105
141
|
}
|
|
106
|
-
|
|
142
|
+
}
|
|
107
143
|
} else if (
|
|
108
144
|
isDateBefore(newMessageDate.toString(), firstMessage?.date?.toString() ?? '')
|
|
109
145
|
) {
|
|
@@ -14,9 +14,30 @@ export const getKeyboardVerticalOffset = ({
|
|
|
14
14
|
}: KeyboardLayoutArgs): number =>
|
|
15
15
|
configuredOffset + (platform === 'ios' ? bottomInset : 0);
|
|
16
16
|
|
|
17
|
+
type InputDockPaddingArgs = Omit<KeyboardLayoutArgs, 'configuredOffset'> & {
|
|
18
|
+
// Explicit `inputDockPaddingBottom` config value, verbatim on both
|
|
19
|
+
// platforms when provided (0 allowed). Takes priority over every other
|
|
20
|
+
// rule below, including `hostOwnsLayout`.
|
|
21
|
+
configuredPadding?: number;
|
|
22
|
+
// True when the host app owns keyboard layout entirely (i.e.
|
|
23
|
+
// `disableKeyboardAvoidingView` is set) and `configuredPadding` was not
|
|
24
|
+
// given. The dock then gets no built-in bottom padding of its own,
|
|
25
|
+
// since it already sits above the host's own chrome (e.g. its tab bar).
|
|
26
|
+
hostOwnsLayout?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
17
29
|
export const getInputDockPaddingBottom = ({
|
|
18
30
|
platform,
|
|
19
31
|
bottomInset = 0,
|
|
20
32
|
keyboardVisible = false,
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
configuredPadding,
|
|
34
|
+
hostOwnsLayout = false,
|
|
35
|
+
}: InputDockPaddingArgs): number => {
|
|
36
|
+
if (typeof configuredPadding === 'number') {
|
|
37
|
+
return configuredPadding;
|
|
38
|
+
}
|
|
39
|
+
if (hostOwnsLayout) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
return platform === 'ios' ? bottomInset : ANDROID_INPUT_DOCK_GAP;
|
|
43
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Count messages strictly newer than a given millisecond timestamp using
|
|
2
|
+
// `msg.id` (the server-authoritative microsecond timestamp prefixed by a
|
|
3
|
+
// 13-digit millis, see `helpers/dateComparison`'s
|
|
4
|
+
// `getHighResolutionTimestamp`) rather than `msg.date`, which can be
|
|
5
|
+
// derived client-side (`createMessageFromXml` falls back to `Date.now()`
|
|
6
|
+
// for realtime stanzas without a `date` attr) and drift from what the
|
|
7
|
+
// server actually assigned.
|
|
8
|
+
//
|
|
9
|
+
// Extracted to its own module (rather than living in roomsSlice.ts, its
|
|
10
|
+
// original home) so helpers that need the SAME ordering source - notably
|
|
11
|
+
// `insertMessageWithDelimiter.ts`, which decides where the "New messages"
|
|
12
|
+
// divider sits - can import it without creating a roomStore <-> helpers
|
|
13
|
+
// import cycle (roomsSlice.ts already imports insertMessageWithDelimiter).
|
|
14
|
+
// roomsSlice.ts re-exports this for backward compatibility with existing
|
|
15
|
+
// call sites.
|
|
16
|
+
export const msgSortableMs = (msg: any): number => {
|
|
17
|
+
const id = String(msg?.id || '');
|
|
18
|
+
const m = /^(\d{13})/.exec(id);
|
|
19
|
+
if (m) {return Number(m[1]);}
|
|
20
|
+
if (msg?.date) {
|
|
21
|
+
const t = new Date(msg.date as any).getTime();
|
|
22
|
+
if (Number.isFinite(t)) {return t;}
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
};
|