@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
@@ -1,13 +1,17 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { useDispatch } from 'react-redux';
3
3
  import {
4
+ clearReadBoundary,
4
5
  clearVisibleRoom,
5
6
  setCurrentRoom,
6
7
  setLastViewedTimestamp,
7
8
  setVisibleRoom,
8
9
  } from '../roomStore/roomsSlice';
9
10
  import { store } from '../roomStore';
10
- import { getServerReadTimestamp } from '../helpers/getServerReadTimestamp';
11
+ import {
12
+ getFlushBoundaryTs,
13
+ getReadMarkerTimestamp,
14
+ } from '../helpers/getServerReadTimestamp';
11
15
 
12
16
  interface UseChatRoomFocusOptions {
13
17
  /** The room JID that the consumer's tab/screen is currently showing. */
@@ -62,25 +66,52 @@ export const useChatRoomFocus = ({
62
66
  isFocused: false,
63
67
  });
64
68
 
65
- const leaveRoom = (jid: string) => {
69
+ // `clearBoundary`: pass true only when the room the user is leaving is
70
+ // genuinely being released (switching to a different room, or this
71
+ // hook unmounting) - not on a mere same-room blur. `<ChatRoom>`'s
72
+ // MessageList typically stays mounted across a same-room focus loss
73
+ // (the whole point of this hook is that the host keeps `<Chat>`
74
+ // mounted in a hidden tab), so its own scroll-tracking ref is still
75
+ // intact and the redux boundary must stay in sync with it - clearing
76
+ // it here would desync the two.
77
+ const leaveRoom = (jid: string, clearBoundary: boolean) => {
66
78
  const state = store.getState();
67
79
  const rooms = state.rooms?.rooms;
68
- const timestamp = getServerReadTimestamp(rooms?.[jid], state.roomHeapSlice);
69
- // Only stamp when we actually have something to anchor to - skip
70
- // rather than fall back to the device clock (bug #38).
80
+ const boundaryTs = state.rooms?.readBoundaries?.[jid] ?? null;
81
+ // getReadMarkerTimestamp: honour the boundary (the newest message the
82
+ // user actually reached) when the user left this room scrolled up,
83
+ // otherwise fall back to the newest server-acked message. Only stamp
84
+ // when we actually have something to anchor to - skip rather than
85
+ // fall back to the device clock (bug #38). Ignoring the boundary here
86
+ // would stamp "everything read" regardless of scroll position (bug
87
+ // #42, a regression of #33 reintroduced by #38).
88
+ const timestamp = getReadMarkerTimestamp(rooms?.[jid], state.roomHeapSlice, boundaryTs);
71
89
  if (timestamp > 0) {
72
90
  dispatch(setLastViewedTimestamp({ chatJID: jid, timestamp }));
73
91
  }
74
92
  dispatch(clearVisibleRoom());
75
93
  (state.chatSettingStore as any)?.client
76
- ?.flushLastViewedToPrivateStoreStanza(rooms, { visibleRoomJID: jid })
94
+ ?.flushLastViewedToPrivateStoreStanza(rooms, {
95
+ visibleRoomJID: jid,
96
+ // Carry the same boundary to the SERVER marker - without it the
97
+ // flush defaults to "everything" for the visible room. Clamped
98
+ // through the same helper as the local stamp above.
99
+ visibleRoomTs: getFlushBoundaryTs(
100
+ rooms?.[jid],
101
+ state.roomHeapSlice,
102
+ boundaryTs
103
+ ),
104
+ })
77
105
  .catch(() => {});
106
+ if (clearBoundary) {
107
+ dispatch(clearReadBoundary({ jid }));
108
+ }
78
109
  };
79
110
 
80
111
  useEffect(() => {
81
112
  const prev = prevRef.current;
82
113
  if (prev.isFocused && prev.roomJID && (prev.roomJID !== roomJID || !isFocused)) {
83
- leaveRoom(prev.roomJID);
114
+ leaveRoom(prev.roomJID, prev.roomJID !== roomJID);
84
115
  }
85
116
 
86
117
  if (roomJID && isFocused) {
@@ -95,7 +126,7 @@ export const useChatRoomFocus = ({
95
126
  return () => {
96
127
  const prev = prevRef.current;
97
128
  if (prev.isFocused && prev.roomJID) {
98
- leaveRoom(prev.roomJID);
129
+ leaveRoom(prev.roomJID, true);
99
130
  }
100
131
  };
101
132
  }, []);
@@ -10,6 +10,7 @@ import { pushSubscriptionService } from '../services/pushSubscriptionService';
10
10
  import { clearRoomsRestCache } from '../networking/api-requests/rooms.api';
11
11
  import { clearPersistedState } from '../roomStore/persistence';
12
12
  import { secureUserStorage } from '../helpers/secureUserStorage';
13
+ import { getFlushBoundaryTs } from '../helpers/getServerReadTimestamp';
13
14
 
14
15
  // AsyncStorage keys the library writes but that aren't cleared by any
15
16
  // slice reducer. Listed here so a single logout call wipes the full
@@ -59,10 +60,23 @@ const logoutService = {
59
60
  const client = (state.chatSettingStore as any)?.client;
60
61
  const rooms = state.rooms?.rooms;
61
62
  const visibleRoomJID = state.rooms?.visibleRoomJID || null;
63
+ // The visible room is exempt from `onlyIfNoUnread`'s skip (it's
64
+ // meant to write "read up to now" for whatever's open), so if the
65
+ // user is logging out while scrolled up with unread messages
66
+ // above, the boundary must still be carried through here - same
67
+ // reasoning as every other "leaving a room" path (bug #42).
68
+ const boundaryTs = visibleRoomJID
69
+ ? getFlushBoundaryTs(
70
+ rooms?.[visibleRoomJID],
71
+ state.roomHeapSlice,
72
+ state.rooms?.readBoundaries?.[visibleRoomJID] ?? null
73
+ )
74
+ : undefined;
62
75
  if (client?.flushLastViewedToPrivateStoreStanza) {
63
76
  await Promise.race([
64
77
  client.flushLastViewedToPrivateStoreStanza(rooms, {
65
78
  visibleRoomJID,
79
+ visibleRoomTs: boundaryTs,
66
80
  onlyIfNoUnread: true,
67
81
  }),
68
82
  new Promise((res) => setTimeout(res, 2000)),
@@ -2,6 +2,7 @@ import { createAsyncThunk, createSlice, PayloadAction, type Slice } from '@redux
2
2
  import type { WritableDraft } from 'immer';
3
3
  import { EditAction, HistoryPreloadState, IMessage, IRoom } from '../types/types';
4
4
  import { insertMessageWithDelimiter } from '../helpers/insertMessageWithDelimiter';
5
+ import { msgSortableMs } from '../helpers/msgSortableMs';
5
6
  import type XmppClient from '../networking/xmppClient';
6
7
 
7
8
  // Per-room runtime message cap. Mirrors the persistence layer's
@@ -236,6 +237,33 @@ export interface RoomMessagesState {
236
237
  // — see `applyPrivateStoreMarkers`. Re-fetched every init/reconnect,
237
238
  // so it is intentionally NOT persisted.
238
239
  privateStoreMarkers: Record<string, number>;
240
+ // The single source of truth for "read up to here, but the user
241
+ // hasn't reached the bottom yet" (`{ roomJID: boundaryMs }`).
242
+ // Set by MessageList (via ChatRoom's `onReadBoundaryChange`) the
243
+ // moment the user first scrolls away from the bottom, to the
244
+ // msgSortableMs of the newest message they'd actually seen. Every
245
+ // path that stamps a read marker for a room the user is leaving
246
+ // (ChatRoom unmount, xmppProvider's AppState background handler and
247
+ // `isVisible=false` handler, the live `advance()` effect, and
248
+ // `useChatRoomFocus`'s `leaveRoom`) must consult this instead of
249
+ // unconditionally stamping "the newest acked message" - otherwise
250
+ // leaving a room while scrolled up marks messages the user never
251
+ // reached as read (customer #42, a regression of #33 reintroduced by
252
+ // #38's server-timestamp marker). See `getReadMarkerTimestamp` in
253
+ // helpers/getServerReadTimestamp.ts.
254
+ //
255
+ // Cleared when the boundary no longer applies: the user scrolls back
256
+ // to the bottom, the visible room changes, or the room is truly left
257
+ // (ChatRoom unmounts, or a tab-navigator focus hook releases the
258
+ // room). NOT cleared merely because the app backgrounds or a host's
259
+ // `isVisible` flips false - MessageList typically stays mounted
260
+ // through those, so its own scroll-tracking ref (the only thing that
261
+ // could re-derive this value) is still intact and the boundary must
262
+ // stay in sync with it. Deliberately NOT persisted (this key isn't
263
+ // read by `persistence.ts`, which only ever picks `rooms` back out of
264
+ // this slice) - it's meaningless across a process restart, where
265
+ // MessageList always mounts fresh at the bottom.
266
+ readBoundaries: Record<string, number>;
239
267
  }
240
268
 
241
269
  const initialState: RoomMessagesState = {
@@ -252,6 +280,7 @@ const initialState: RoomMessagesState = {
252
280
  },
253
281
  pendingNotificationJid: null,
254
282
  privateStoreMarkers: {},
283
+ readBoundaries: {},
255
284
  };
256
285
 
257
286
  const isValidRoomJid = (jid: unknown): jid is string => {
@@ -518,7 +547,7 @@ const reducers = {
518
547
  state.visibleRoomJID === roomJID
519
548
  ? null
520
549
  : lastViewedValue
521
- ? new Date(lastViewedValue)
550
+ ? lastViewedValue
522
551
  : null;
523
552
 
524
553
  insertMessageWithDelimiter(roomMessages, message, lastViewedTimestamp);
@@ -536,6 +565,7 @@ const reducers = {
536
565
  state.rooms = {};
537
566
  state.visibleRoomJID = null;
538
567
  state.privateStoreMarkers = {};
568
+ state.readBoundaries = {};
539
569
  state.isUnreadSyncing = false;
540
570
  },
541
571
  setComposing(
@@ -659,6 +689,39 @@ const reducers = {
659
689
  }
660
690
  }
661
691
  },
692
+ /**
693
+ * Set (or clear) the "read up to here, but not further" boundary for
694
+ * a room - see `readBoundaries` on `RoomMessagesState` for the full
695
+ * contract. `ts` is the msgSortableMs of the newest message the user
696
+ * actually reached before scrolling away from the bottom; `null`/`0`
697
+ * clears it (the user is at the bottom, or nothing was reached yet).
698
+ */
699
+ setReadBoundary: (
700
+ state: WritableDraft<RoomMessagesState>,
701
+ action: PayloadAction<{ jid: string; ts: number | null }>
702
+ ) => {
703
+ const { jid, ts } = action.payload;
704
+ if (!jid) {return;}
705
+ if (!state.readBoundaries) {state.readBoundaries = {};}
706
+ if (typeof ts === 'number' && Number.isFinite(ts) && ts > 0) {
707
+ state.readBoundaries[jid] = ts;
708
+ } else {
709
+ delete state.readBoundaries[jid];
710
+ }
711
+ },
712
+ /**
713
+ * Release a room's read boundary once it has been consumed by a
714
+ * genuine "leave" (ChatRoom unmount, or a tab-navigator focus hook
715
+ * switching to a different room) - see `readBoundaries` for why the
716
+ * background/`isVisible=false` paths deliberately do NOT call this.
717
+ */
718
+ clearReadBoundary: (
719
+ state: WritableDraft<RoomMessagesState>,
720
+ action: PayloadAction<{ jid: string }>
721
+ ) => {
722
+ const { jid } = action.payload;
723
+ if (jid && state.readBoundaries) {delete state.readBoundaries[jid];}
724
+ },
662
725
  setRoomRole: (
663
726
  state: WritableDraft<RoomMessagesState>,
664
727
  action: PayloadAction<{ chatJID: string; role: string }>
@@ -752,6 +815,7 @@ const reducers = {
752
815
  state.isLoading = false;
753
816
  state.isUnreadSyncing = false;
754
817
  state.privateStoreMarkers = {};
818
+ state.readBoundaries = {};
755
819
  },
756
820
  setActiveMessage: (
757
821
  state: WritableDraft<RoomMessagesState>,
@@ -873,27 +937,13 @@ export const roomsStore: Slice<RoomMessagesState, typeof reducers, 'roomMessages
873
937
  reducers,
874
938
  });
875
939
 
876
- // Count messages strictly newer than the given millisecond timestamp.
877
- // Uses `msg.id` (server-authoritative microsecond timestamp prefixed by
878
- // 13-digit millis — see helpers/dateComparison `getHighResolutionTimestamp`)
879
- // because `msg.date` can be derived client-side (createMessageFromXml
880
- // falls back to `Date.now()` for realtime stanzas without a `date`
881
- // attr), which makes the comparison drift vs what the server assigned.
882
- // Excludes the "delimiter-new" sentinel, pending sends, and the current
883
- // user's own messages (parity with unreadMiddleware's isOwnMessage
884
- // filter — without this, the reducer and the middleware disagree about
885
- // the count and we get a flicker as the badge gets written twice with
886
- // different values on every message).
887
- export const msgSortableMs = (msg: any): number => {
888
- const id = String(msg?.id || '');
889
- const m = /^(\d{13})/.exec(id);
890
- if (m) {return Number(m[1]);}
891
- if (msg?.date) {
892
- const t = new Date(msg.date as any).getTime();
893
- if (Number.isFinite(t)) {return t;}
894
- }
895
- return 0;
896
- };
940
+ // Re-exported for backward compatibility - every existing call site
941
+ // imports `msgSortableMs` from here. The implementation now lives in
942
+ // helpers/msgSortableMs.ts (imported above), so insertMessageWithDelimiter.ts
943
+ // (which this file itself imports) can use the SAME ordering source
944
+ // without a roomStore <-> helpers import cycle. See that file for the
945
+ // full rationale (server id vs. client `date`).
946
+ export { msgSortableMs };
897
947
 
898
948
  const norm = (s: any): string => {
899
949
  if (s == null) {return '';}
@@ -980,6 +1030,8 @@ export const {
980
1030
  setUnreadSyncing,
981
1031
  setLastViewedTimestamp,
982
1032
  applyPrivateStoreMarkers,
1033
+ setReadBoundary,
1034
+ clearReadBoundary,
983
1035
  setRoomNoMessages,
984
1036
  setCurrentRoom,
985
1037
  setVisibleRoom,
@@ -585,6 +585,20 @@ export interface IConfig {
585
585
  // `disableKeyboardAvoidingView` is set. Needs on-device tuning of any
586
586
  // `keyboardVerticalOffset`.
587
587
  keyboardStickyInput?: boolean;
588
+ // Bottom padding applied to the input dock (the composer's outer
589
+ // wrapper), on both iOS and Android. When a number is provided (0 is
590
+ // allowed) it is used verbatim on both platforms, overriding the
591
+ // built-in default below.
592
+ //
593
+ // Left unset, the default is:
594
+ // - 0 when `disableKeyboardAvoidingView` is set, since the host owns
595
+ // layout entirely and typically already places the chat above its
596
+ // own chrome (e.g. a tab bar) - the built-in padding would just add
597
+ // an extra gap between the composer and that chrome.
598
+ // - otherwise unchanged: the iOS safe-area bottom inset, or a fixed
599
+ // 12dp gap on Android (`ANDROID_INPUT_DOCK_GAP`).
600
+ // Customer-reported #44.
601
+ inputDockPaddingBottom?: number;
588
602
  // The message list's FlatList uses keyboardDismissMode="interactive" by
589
603
  // default, so a drag can be used to pull the keyboard closed. Because
590
604
  // the list is `inverted`, an ordinary scroll gesture through history can