@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/README.md +10 -0
  3. package/lib/commonjs/components/MainComponents/ChatRoom.js +63 -15
  4. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  5. package/lib/commonjs/context/xmppProvider.js +58 -14
  6. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  7. package/lib/commonjs/helpers/getServerReadTimestamp.js +66 -3
  8. package/lib/commonjs/helpers/getServerReadTimestamp.js.map +1 -1
  9. package/lib/commonjs/helpers/insertMessageWithDelimiter.js +51 -9
  10. package/lib/commonjs/helpers/insertMessageWithDelimiter.js.map +1 -1
  11. package/lib/commonjs/helpers/keyboardLayout.js +12 -2
  12. package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
  13. package/lib/commonjs/helpers/msgSortableMs.js +37 -0
  14. package/lib/commonjs/helpers/msgSortableMs.js.map +1 -0
  15. package/lib/commonjs/hooks/useChatRoomFocus.js +31 -7
  16. package/lib/commonjs/hooks/useChatRoomFocus.js.map +1 -1
  17. package/lib/commonjs/hooks/useLogout.js +8 -0
  18. package/lib/commonjs/hooks/useLogout.js.map +1 -1
  19. package/lib/commonjs/roomStore/roomsSlice.js +66 -32
  20. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  21. package/lib/module/components/MainComponents/ChatRoom.js +65 -17
  22. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  23. package/lib/module/context/xmppProvider.js +59 -15
  24. package/lib/module/context/xmppProvider.js.map +1 -1
  25. package/lib/module/helpers/getServerReadTimestamp.js +63 -2
  26. package/lib/module/helpers/getServerReadTimestamp.js.map +1 -1
  27. package/lib/module/helpers/insertMessageWithDelimiter.js +52 -9
  28. package/lib/module/helpers/insertMessageWithDelimiter.js.map +1 -1
  29. package/lib/module/helpers/keyboardLayout.js +12 -2
  30. package/lib/module/helpers/keyboardLayout.js.map +1 -1
  31. package/lib/module/helpers/msgSortableMs.js +32 -0
  32. package/lib/module/helpers/msgSortableMs.js.map +1 -0
  33. package/lib/module/hooks/useChatRoomFocus.js +33 -9
  34. package/lib/module/hooks/useChatRoomFocus.js.map +1 -1
  35. package/lib/module/hooks/useLogout.js +8 -0
  36. package/lib/module/hooks/useLogout.js.map +1 -1
  37. package/lib/module/roomStore/roomsSlice.js +53 -27
  38. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  39. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  40. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  41. package/lib/typescript/helpers/getServerReadTimestamp.d.ts +40 -1
  42. package/lib/typescript/helpers/getServerReadTimestamp.d.ts.map +1 -1
  43. package/lib/typescript/helpers/insertMessageWithDelimiter.d.ts +1 -1
  44. package/lib/typescript/helpers/insertMessageWithDelimiter.d.ts.map +1 -1
  45. package/lib/typescript/helpers/keyboardLayout.d.ts +5 -1
  46. package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
  47. package/lib/typescript/helpers/msgSortableMs.d.ts +2 -0
  48. package/lib/typescript/helpers/msgSortableMs.d.ts.map +1 -0
  49. package/lib/typescript/hooks/useChatRoomFocus.d.ts.map +1 -1
  50. package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
  51. package/lib/typescript/roomStore/roomsSlice.d.ts +29 -2
  52. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  53. package/lib/typescript/types/types.d.ts +1 -0
  54. package/lib/typescript/types/types.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/components/MainComponents/ChatRoom.tsx +68 -17
  57. package/src/context/xmppProvider.tsx +87 -18
  58. package/src/helpers/getServerReadTimestamp.ts +79 -2
  59. package/src/helpers/insertMessageWithDelimiter.ts +55 -19
  60. package/src/helpers/keyboardLayout.ts +23 -2
  61. package/src/helpers/msgSortableMs.ts +25 -0
  62. package/src/hooks/useChatRoomFocus.ts +39 -8
  63. package/src/hooks/useLogout.tsx +14 -0
  64. package/src/roomStore/roomsSlice.ts +74 -22
  65. 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 { getServerReadTimestamp } from '../../helpers/getServerReadTimestamp';
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. Without this, leaving a
222
- // room (or backgrounding) while scrolled up stamped `now()` as read
223
- // and silently discarded genuinely-unread messages. Customer-
224
- // reported #33.
225
- const readBoundaryRef = useRef<number | null>(null);
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
- readBoundaryRef.current = boundaryTs;
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
- readBoundaryRef.current = null;
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 rooms = store.getState().rooms?.rooms;
240
- const heapState = store.getState().roomHeapSlice;
241
- // Fall back to the newest server-acked message, never the device
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
- readBoundaryRef.current ??
246
- getServerReadTimestamp(rooms?.[activeRoomJID], heapState);
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 — the local count was
299
+ // back as read on the next login - the local count was
265
300
  // right but the server overrode it.
266
- visibleRoomTs: readBoundaryRef.current,
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 { getServerReadTimestamp } from '../helpers/getServerReadTimestamp';
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=<newest server-acked message>, not
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
- const readTs = getServerReadTimestamp(
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
- c.flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID }).catch(
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
- // Never Date.now(): a fast device clock would write a future
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, { visibleRoomJID: activeRoomJID })
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
- // getServerReadTimestamp (not a hand-rolled scan + Date.now()):
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
- const newest = getServerReadTimestamp(room, s.roomHeapSlice);
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(store.getState().rooms?.rooms, {
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 '../roomStore/roomsSlice';
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` (roomsSlice.ts) reads the server-assigned microsecond
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
- lastViewedTimestamp &&
67
- !roomMessages.some((msg) => msg.id === 'delimiter-new') &&
68
- isDateAfter(newMessageDate.toString(), lastViewedTimestamp.toString())
98
+ lastViewedMs > 0 &&
99
+ newMessageMs > lastViewedMs &&
100
+ !roomMessages.some((msg) => msg.id === 'delimiter-new')
69
101
  ) {
70
- const delimiterIndex = roomMessages.findIndex((msg) =>
71
- isDateAfter(msg.date?.toString() ?? '', lastViewedTimestamp.toString())
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()` — i.e. now — sorted past every real message and
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. Derived from that message rather than from
82
- // lastViewedTimestamp so it works whatever format the caller passes.
83
- const firstUnreadMs = Date.parse(
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
- }: Omit<KeyboardLayoutArgs, 'configuredOffset'>): number =>
22
- platform === 'ios' ? bottomInset : ANDROID_INPUT_DOCK_GAP;
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
+ };