@ethora/chat-component-rn 26.7.1 → 26.7.3

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 (127) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +22 -0
  3. package/lib/commonjs/assets/icons.js +2 -2
  4. package/lib/commonjs/assets/icons.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatRoom.js +60 -3
  6. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/ChatWrapper.js +18 -0
  8. package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/MessageList.js +9 -3
  10. package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
  11. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +110 -37
  12. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
  13. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +16 -14
  14. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  15. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +130 -125
  16. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  17. package/lib/commonjs/components/styled/SendInput.js +0 -10
  18. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  19. package/lib/commonjs/context/xmppProvider.js +12 -0
  20. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  21. package/lib/commonjs/helpers/historyPreloadScheduler.js +65 -2
  22. package/lib/commonjs/helpers/historyPreloadScheduler.js.map +1 -1
  23. package/lib/commonjs/helpers/shallowEqual.js +36 -0
  24. package/lib/commonjs/helpers/shallowEqual.js.map +1 -0
  25. package/lib/commonjs/hooks/useChatWrapperInit.js +15 -0
  26. package/lib/commonjs/hooks/useChatWrapperInit.js.map +1 -1
  27. package/lib/commonjs/hooks/useComposing.js +27 -0
  28. package/lib/commonjs/hooks/useComposing.js.map +1 -1
  29. package/lib/commonjs/hooks/useSendMessage.js +27 -1
  30. package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
  31. package/lib/commonjs/hooks/useUnreadMessagesCounter.js +6 -1
  32. package/lib/commonjs/hooks/useUnreadMessagesCounter.js.map +1 -1
  33. package/lib/commonjs/main.js +6 -0
  34. package/lib/commonjs/main.js.map +1 -1
  35. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js +6 -2
  36. package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  37. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  38. package/lib/commonjs/roomStore/roomsSlice.js +2 -2
  39. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  40. package/lib/module/assets/icons.js +1 -1
  41. package/lib/module/assets/icons.js.map +1 -1
  42. package/lib/module/components/MainComponents/ChatRoom.js +61 -4
  43. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  44. package/lib/module/components/MainComponents/ChatWrapper.js +19 -1
  45. package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
  46. package/lib/module/components/MainComponents/MessageList.js +9 -3
  47. package/lib/module/components/MainComponents/MessageList.js.map +1 -1
  48. package/lib/module/components/Menu/HeaderRoomListMenu.js +110 -38
  49. package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
  50. package/lib/module/components/Modals/AttachSheet/AttachSheet.js +16 -14
  51. package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  52. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +130 -125
  53. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  54. package/lib/module/components/styled/SendInput.js +1 -11
  55. package/lib/module/components/styled/SendInput.js.map +1 -1
  56. package/lib/module/context/xmppProvider.js +12 -0
  57. package/lib/module/context/xmppProvider.js.map +1 -1
  58. package/lib/module/helpers/historyPreloadScheduler.js +66 -3
  59. package/lib/module/helpers/historyPreloadScheduler.js.map +1 -1
  60. package/lib/module/helpers/shallowEqual.js +31 -0
  61. package/lib/module/helpers/shallowEqual.js.map +1 -0
  62. package/lib/module/hooks/useChatWrapperInit.js +15 -0
  63. package/lib/module/hooks/useChatWrapperInit.js.map +1 -1
  64. package/lib/module/hooks/useComposing.js +28 -1
  65. package/lib/module/hooks/useComposing.js.map +1 -1
  66. package/lib/module/hooks/useSendMessage.js +28 -2
  67. package/lib/module/hooks/useSendMessage.js.map +1 -1
  68. package/lib/module/hooks/useUnreadMessagesCounter.js +6 -1
  69. package/lib/module/hooks/useUnreadMessagesCounter.js.map +1 -1
  70. package/lib/module/main.js +1 -1
  71. package/lib/module/main.js.map +1 -1
  72. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js +6 -2
  73. package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
  74. package/lib/module/networking/xmppClient.js.map +1 -1
  75. package/lib/module/roomStore/roomsSlice.js +1 -1
  76. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  77. package/lib/typescript/assets/icons.d.ts +4 -0
  78. package/lib/typescript/assets/icons.d.ts.map +1 -1
  79. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  80. package/lib/typescript/components/MainComponents/ChatWrapper.d.ts.map +1 -1
  81. package/lib/typescript/components/MainComponents/MessageList.d.ts +10 -1
  82. package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
  83. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts +3 -0
  84. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
  85. package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
  86. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  87. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  88. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  89. package/lib/typescript/helpers/historyPreloadScheduler.d.ts.map +1 -1
  90. package/lib/typescript/helpers/shallowEqual.d.ts +11 -0
  91. package/lib/typescript/helpers/shallowEqual.d.ts.map +1 -0
  92. package/lib/typescript/hooks/useChatWrapperInit.d.ts.map +1 -1
  93. package/lib/typescript/hooks/useComposing.d.ts.map +1 -1
  94. package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
  95. package/lib/typescript/hooks/useUnreadMessagesCounter.d.ts +1 -0
  96. package/lib/typescript/hooks/useUnreadMessagesCounter.d.ts.map +1 -1
  97. package/lib/typescript/main.d.ts +1 -1
  98. package/lib/typescript/main.d.ts.map +1 -1
  99. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts +1 -0
  100. package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts.map +1 -1
  101. package/lib/typescript/networking/xmppClient.d.ts +1 -0
  102. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  103. package/lib/typescript/roomStore/roomsSlice.d.ts +1 -0
  104. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  105. package/lib/typescript/types/types.d.ts +13 -0
  106. package/lib/typescript/types/types.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/assets/icons.tsx +19 -0
  109. package/src/components/MainComponents/ChatRoom.tsx +63 -1
  110. package/src/components/MainComponents/ChatWrapper.tsx +19 -1
  111. package/src/components/MainComponents/MessageList.tsx +19 -2
  112. package/src/components/Menu/HeaderRoomListMenu.tsx +105 -11
  113. package/src/components/Modals/AttachSheet/AttachSheet.tsx +16 -14
  114. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +8 -6
  115. package/src/components/styled/SendInput.tsx +0 -10
  116. package/src/context/xmppProvider.tsx +14 -0
  117. package/src/helpers/historyPreloadScheduler.ts +80 -3
  118. package/src/helpers/shallowEqual.ts +33 -0
  119. package/src/hooks/useChatWrapperInit.ts +15 -0
  120. package/src/hooks/useComposing.tsx +37 -1
  121. package/src/hooks/useSendMessage.tsx +32 -2
  122. package/src/hooks/useUnreadMessagesCounter.ts +11 -0
  123. package/src/main.ts +1 -1
  124. package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +17 -3
  125. package/src/networking/xmppClient.ts +5 -1
  126. package/src/roomStore/roomsSlice.ts +1 -1
  127. package/src/types/types.ts +22 -0
@@ -31,6 +31,9 @@ import CustomTypingIndicator from '../styled/StyledInputComponents/CustomTypingI
31
31
  // import {PanGestureHandler} from 'react-native-gesture-handler';
32
32
  import { FlatList } from 'react-native';
33
33
  import {
34
+ AppState,
35
+ AppStateStatus,
36
+ Keyboard,
34
37
  Platform,
35
38
  TouchableWithoutFeedback,
36
39
  View,
@@ -106,6 +109,45 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
106
109
  configWithEventHandlers || storeConfig,
107
110
  );
108
111
 
112
+ // Under the sticky keyboard strategy, the input dock's position is
113
+ // driven by KeyboardStickyView's native-animated offset. If the app
114
+ // is backgrounded while the keyboard-close animation hasn't finished,
115
+ // the OS removes the keyboard from the app-switcher snapshot but the
116
+ // shared value hasn't caught up to "closed" yet, so the input appears
117
+ // to float in that preview.
118
+ //
119
+ // NOTE on what this does and doesn't fix: iOS actually captures that
120
+ // preview during the active→inactive transition, BEFORE 'background'
121
+ // fires — so dismissing here on 'background' lands too late to affect
122
+ // the snapshot already taken, and is effectively a no-op for the exact
123
+ // iOS symptom in bug #37. An earlier version keyed this off 'inactive'
124
+ // instead, which does land in time, but 'inactive' also fires for
125
+ // Control Center, share sheets, and permission prompts — none of which
126
+ // should close the user's keyboard — so that traded a rare cosmetic
127
+ // preview glitch for a much more common false dismissal. Kept on
128
+ // 'background': it still resets the offset for genuine backgrounding
129
+ // (home button, app switch) and for Android's recents preview (RN's
130
+ // AppState has no 'inactive' there), at the cost of leaving the iOS
131
+ // switcher-preview glitch itself unresolved. Both symptoms are cosmetic
132
+ // per the QA report, which explicitly accepted leaving this imperfect
133
+ // rather than risk destabilizing the sticky strategy. Sticky-only;
134
+ // doesn't touch the other keyboard strategies.
135
+ const stickyInputEnabled =
136
+ !configWithEventHandlers?.disableKeyboardAvoidingView &&
137
+ !!configWithEventHandlers?.keyboardStickyInput;
138
+ useEffect(() => {
139
+ if (!stickyInputEnabled) {
140
+ return;
141
+ }
142
+ const onAppStateChange = (nextState: AppStateStatus) => {
143
+ if (nextState === 'background') {
144
+ Keyboard.dismiss();
145
+ }
146
+ };
147
+ const subscription = AppState.addEventListener('change', onAppStateChange);
148
+ return () => subscription.remove();
149
+ }, [stickyInputEnabled]);
150
+
109
151
  const sendMessage = useCallback(
110
152
  (message: string) => {
111
153
  if (!activeRoomJID) {
@@ -170,15 +212,28 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
170
212
  clientRef.current = client;
171
213
  }, [client]);
172
214
 
215
+ // Tracks what the user actually saw: `null` while they're at the
216
+ // bottom (safe to mark everything read), or the timestamp of the
217
+ // newest message visible when they scrolled away from it. Reported
218
+ // by MessageList via onReadBoundaryChange. Without this, leaving a
219
+ // room (or backgrounding) while scrolled up stamped `now()` as read
220
+ // and silently discarded genuinely-unread messages. Customer-
221
+ // reported #33.
222
+ const readBoundaryRef = useRef<number | null>(null);
223
+ const handleReadBoundaryChange = useCallback((boundaryTs: number | null) => {
224
+ readBoundaryRef.current = boundaryTs;
225
+ }, []);
226
+
173
227
  useEffect(() => {
174
228
  if (!activeRoomJID) {
175
229
  return;
176
230
  }
177
231
 
232
+ readBoundaryRef.current = null;
178
233
  dispatch(setVisibleRoom({ roomJID: activeRoomJID }));
179
234
  setIsLoadingMore(false);
180
235
  return () => {
181
- const timestamp = new Date().getTime();
236
+ const timestamp = readBoundaryRef.current ?? new Date().getTime();
182
237
  dispatch(
183
238
  setLastViewedTimestamp({
184
239
  chatJID: activeRoomJID,
@@ -191,6 +246,12 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
191
246
  liveClient
192
247
  .flushLastViewedToPrivateStoreStanza(store.getState().rooms?.rooms, {
193
248
  visibleRoomJID: activeRoomJID,
249
+ // Carry the same boundary to the SERVER marker. Without
250
+ // this the flush defaults to Date.now() for the visible
251
+ // room, so messages the user never scrolled down to come
252
+ // back as read on the next login — the local count was
253
+ // right but the server overrode it.
254
+ visibleRoomTs: readBoundaryRef.current,
194
255
  })
195
256
  .catch(() => {});
196
257
  }
@@ -404,6 +465,7 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
404
465
  config={configWithEventHandlers}
405
466
  loading={isLoadingMore}
406
467
  isReply={false}
468
+ onReadBoundaryChange={handleReadBoundaryChange}
407
469
  />
408
470
  )}
409
471
  </View>
@@ -1,4 +1,4 @@
1
- import React, {FC, useEffect, useMemo, useState} from 'react';
1
+ import React, {FC, useEffect, useMemo, useRef, useState} from 'react';
2
2
  import {useDispatch, useSelector} from 'react-redux';
3
3
  import ChatRoom from './ChatRoom';
4
4
  import {
@@ -34,6 +34,7 @@ import {DeviceEventEmitter, Pressable, Text, View} from 'react-native';
34
34
  import {pushLog as devPushLog} from '../../utils/devLogger';
35
35
  import {normalizeRoomJid} from '../../helpers/normalizeRoomJid';
36
36
  import {buildSeedRoom} from '../../helpers/buildSeedRoom';
37
+ import {shallowEqual} from '../../helpers/shallowEqual';
37
38
  import {InteractionsOverlayProvider} from '../MessageBubble/InteractionsOverlay';
38
39
 
39
40
  interface ChatWrapperProps {
@@ -161,6 +162,23 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
161
162
  dispatch(addRoom({roomData: buildSeedRoom(seededRoomJID)}));
162
163
  }, [seededRoomJID, seededRoomMissing, dispatch]);
163
164
 
165
+ // Presentational config flags (e.g. `disableHeader`) can change while the
166
+ // chat stays mounted — sync them to redux whenever `config` actually
167
+ // changes, not just at init. Kept separate from the init effect below so
168
+ // toggling a flag doesn't re-run client/token setup. Guarded with a
169
+ // shallow-equal check (not just the `config` reference) because hosts
170
+ // commonly pass an inline object literal (`<Chat config={{...}}/>`) that
171
+ // is a new reference on every render even when nothing in it changed —
172
+ // dispatching unconditionally there would re-render every config
173
+ // consumer (ChatRoom, modals, ...) on every host render.
174
+ const lastSyncedConfigRef = useRef<IConfig | undefined>(undefined);
175
+ useEffect(() => {
176
+ if (config && !shallowEqual(lastSyncedConfigRef.current, config)) {
177
+ lastSyncedConfigRef.current = config;
178
+ dispatch(setConfig(config));
179
+ }
180
+ }, [config, dispatch]);
181
+
164
182
  useEffect(() => {
165
183
  if (roomJID) {
166
184
  dispatch(
@@ -53,6 +53,15 @@ interface MessageListProps<TMessage extends IMessage> {
53
53
  config?: IConfig;
54
54
  isReply: boolean;
55
55
  activeMessage?: IMessage;
56
+ /**
57
+ * Fired whenever the "read up to" boundary changes: `null` while the
58
+ * user is at the bottom (everything is read), or the timestamp of the
59
+ * newest message they'd actually seen when they scrolled away from it.
60
+ * Lets the host mark only what was actually viewed as read instead of
61
+ * stamping "now" on unmount/leave, which would wrongly clear unread
62
+ * messages the user scrolled up and never got back down to.
63
+ */
64
+ onReadBoundaryChange?: (boundaryTs: number | null) => void;
56
65
  }
57
66
 
58
67
  const MessageList = <TMessage extends IMessage>({
@@ -66,6 +75,7 @@ const MessageList = <TMessage extends IMessage>({
66
75
  loading,
67
76
  isReply,
68
77
  activeMessage,
78
+ onReadBoundaryChange,
69
79
  }: MessageListProps<TMessage>) => {
70
80
  const { composing, messages, composingList } = useRoomState(roomJID)
71
81
  .room! as IRoom;
@@ -332,6 +342,7 @@ const MessageList = <TMessage extends IMessage>({
332
342
  // counter so the badge disappears with the arrow.
333
343
  setUnreadWhileScrolledUp(0);
334
344
  newestSeenTsRef.current = null;
345
+ onReadBoundaryChange?.(null);
335
346
  } else {
336
347
  isUserAtBottomRef.current = false;
337
348
  if (hasUserScrolledRef.current) {
@@ -349,11 +360,12 @@ const MessageList = <TMessage extends IMessage>({
349
360
  },
350
361
  0
351
362
  );
363
+ onReadBoundaryChange?.(newestSeenTsRef.current);
352
364
  }
353
365
  setIsUserAtBottom(false);
354
366
  }
355
367
  },
356
- [memoizedMessages]
368
+ [memoizedMessages, onReadBoundaryChange]
357
369
  );
358
370
 
359
371
  // Keep the badge in sync with messages that arrive while the user is
@@ -382,6 +394,7 @@ const MessageList = <TMessage extends IMessage>({
382
394
  setIsUserAtBottom(true);
383
395
  setUnreadWhileScrolledUp(0);
384
396
  newestSeenTsRef.current = null;
397
+ onReadBoundaryChange?.(null);
385
398
  scrollToBottom();
386
399
  };
387
400
 
@@ -392,6 +405,8 @@ const MessageList = <TMessage extends IMessage>({
392
405
  setIsUserAtBottom(true);
393
406
  setUnreadWhileScrolledUp(0);
394
407
  newestSeenTsRef.current = null;
408
+ onReadBoundaryChange?.(null);
409
+ // eslint-disable-next-line react-hooks/exhaustive-deps
395
410
  }, [roomJID]);
396
411
 
397
412
  const BackgroundImage = useMemo(() => {
@@ -442,7 +457,9 @@ const MessageList = <TMessage extends IMessage>({
442
457
  onLayout={handleLayout}
443
458
  inverted={true}
444
459
  keyboardShouldPersistTaps="handled"
445
- keyboardDismissMode="interactive"
460
+ keyboardDismissMode={
461
+ config?.keepKeyboardOpenOnScroll ? 'none' : 'interactive'
462
+ }
446
463
  contentContainerStyle={styles.flatListContent}
447
464
  ListFooterComponent={
448
465
  loading && memoizedMessages.length > 15 ? (
@@ -1,5 +1,6 @@
1
1
  import React, { FC, useMemo } from 'react';
2
2
  import {
3
+ Alert,
3
4
  Animated,
4
5
  Text,
5
6
  TouchableOpacity,
@@ -8,10 +9,81 @@ import {
8
9
  Dimensions,
9
10
  View,
10
11
  } from 'react-native';
11
- import { AddNewIcon, ProfileIcon, SettingIcon } from '../../assets/icons';
12
+ import {
13
+ AddNewIcon,
14
+ LogoutIcon,
15
+ ProfileIcon,
16
+ SettingIcon,
17
+ } from '../../assets/icons';
12
18
  import { useDispatch } from 'react-redux';
13
19
  import { setActiveModal } from '../../roomStore/chatSettingsSlice';
14
20
  import { MODAL_TYPES } from '../../helpers/constants/MODAL_TYPES';
21
+ import { useChatSettingState } from '../../hooks/useChatSettingState';
22
+ import { useLogout } from '../../hooks/useLogout';
23
+ import type { IConfig } from '../../types/types';
24
+
25
+ type LogoutConfig = NonNullable<IConfig['logout']>;
26
+
27
+ const DEFAULT_LOGOUT_LABEL = 'Sign out';
28
+
29
+ const confirmLogout = (
30
+ confirm: LogoutConfig['confirm'],
31
+ label: string
32
+ ): Promise<boolean> => {
33
+ if (confirm === false) {
34
+ return Promise.resolve(true);
35
+ }
36
+ const copy = typeof confirm === 'object' && confirm ? confirm : {};
37
+ return new Promise<boolean>((resolve) => {
38
+ Alert.alert(
39
+ copy.title ?? label,
40
+ copy.message ?? 'Are you sure you want to sign out?',
41
+ [
42
+ {
43
+ text: copy.cancelText ?? 'Cancel',
44
+ style: 'cancel',
45
+ onPress: () => resolve(false),
46
+ },
47
+ {
48
+ text: copy.confirmText ?? label,
49
+ style: 'destructive',
50
+ onPress: () => resolve(true),
51
+ },
52
+ ],
53
+ { cancelable: true, onDismiss: () => resolve(false) }
54
+ );
55
+ });
56
+ };
57
+
58
+ export const runLogoutFlow = async (
59
+ logoutConfig: LogoutConfig,
60
+ performLogout: () => Promise<void>
61
+ ): Promise<void> => {
62
+ const label = logoutConfig.label ?? DEFAULT_LOGOUT_LABEL;
63
+ const confirmed = await confirmLogout(logoutConfig.confirm ?? true, label);
64
+ if (!confirmed) {
65
+ return;
66
+ }
67
+ if (logoutConfig.onBeforeLogout) {
68
+ try {
69
+ const proceed = await logoutConfig.onBeforeLogout();
70
+ if (proceed === false) {
71
+ return;
72
+ }
73
+ } catch (e) {
74
+ console.warn('HeaderRoomListMenu: onBeforeLogout threw, logout cancelled', e);
75
+ return;
76
+ }
77
+ }
78
+ await performLogout(); // never rejects (see useLogout)
79
+ if (logoutConfig.onAfterLogout) {
80
+ try {
81
+ await logoutConfig.onAfterLogout();
82
+ } catch (e) {
83
+ console.warn('HeaderRoomListMenu: onAfterLogout threw', e);
84
+ }
85
+ }
86
+ };
15
87
 
16
88
  const { width } = Dimensions.get('window');
17
89
 
@@ -29,14 +101,25 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
29
101
  closeDrawer,
30
102
  }) => {
31
103
  const dispatch = useDispatch();
104
+ const { config } = useChatSettingState();
105
+ const performLogout = useLogout();
106
+ const logoutConfig = config?.logout?.enabled ? config.logout : undefined;
107
+ const primaryColor = config?.colors?.primary ?? '#0052CD';
32
108
 
33
109
  const drawerTranslateX = drawerAnimation.interpolate({
34
110
  inputRange: [0, 1],
35
111
  outputRange: [width, 0],
36
112
  });
37
113
 
38
- const menuOptions = useMemo(
39
- () => [
114
+ const menuOptions = useMemo(() => {
115
+ const options: {
116
+ label: string;
117
+ icon: React.ReactElement;
118
+ onClick: () => void;
119
+ styles?: { color: string };
120
+ /** When true the row closes the drawer itself before acting. */
121
+ closesDrawer?: boolean;
122
+ }[] = [
40
123
  {
41
124
  label: 'New Chat',
42
125
  icon: <AddNewIcon color="#8C8C8C" />,
@@ -62,13 +145,21 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
62
145
  console.log('Settings clicked');
63
146
  },
64
147
  },
65
- // {
66
- // label: "Logout",
67
- // onClick: handleLogout,
68
- // },
69
- ],
70
- []
71
- );
148
+ ];
149
+ if (logoutConfig) {
150
+ options.push({
151
+ label: logoutConfig.label ?? DEFAULT_LOGOUT_LABEL,
152
+ icon: <LogoutIcon color={primaryColor} />,
153
+ styles: { color: primaryColor },
154
+ closesDrawer: true,
155
+ onClick: () => {
156
+ closeDrawer();
157
+ runLogoutFlow(logoutConfig, performLogout).catch(() => {});
158
+ },
159
+ });
160
+ }
161
+ return options;
162
+ }, [dispatch, logoutConfig, performLogout, primaryColor, closeDrawer]);
72
163
 
73
164
  return (
74
165
  <>
@@ -82,9 +173,12 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
82
173
  <View key={index} style={styles.menuItemWrapper}>
83
174
  <TouchableOpacity
84
175
  style={styles.menuItem}
176
+ testID={`header-menu-${option.label}`}
85
177
  onPress={() => {
86
178
  option.onClick();
87
- closeDrawer();
179
+ if (!option.closesDrawer) {
180
+ closeDrawer();
181
+ }
88
182
  }}
89
183
  >
90
184
  <View
@@ -39,12 +39,13 @@ const AttachSheet: React.FC<AttachSheetProps> = ({
39
39
  }) => {
40
40
  const { config } = useChatSettingState();
41
41
  const ts = config?.typography?.attachSheet;
42
- // Pending handler captured on row tap — runs once the EXIT animation
43
- // finishes so iOS doesn't try to present an image picker over a
44
- // still-dismissing modal (was the original reason for the
45
- // platform-split + Modal.onDismiss dance). The animation completion
46
- // callback now drives this on both platforms.
47
42
  const pendingRef = useRef<(() => void) | null>(null);
43
+ const runPending = () => {
44
+ console.log('[timing] runPending', Date.now());
45
+ const fn = pendingRef.current;
46
+ pendingRef.current = null;
47
+ fn?.();
48
+ };
48
49
 
49
50
  // Local "modal stays mounted while animating out" state. The parent
50
51
  // controls `visible`; we mirror it but delay unmount until the exit
@@ -95,8 +96,6 @@ const AttachSheet: React.FC<AttachSheetProps> = ({
95
96
  return () => cancelAnimationFrame(raf);
96
97
  }
97
98
  if (!mounted) {return;}
98
- // Exit: fade the backdrop, slide the sheet down, then unmount and
99
- // fire any pending picker handler.
100
99
  Animated.parallel([
101
100
  Animated.timing(backdropOpacity, {
102
101
  toValue: 0,
@@ -113,19 +112,19 @@ const AttachSheet: React.FC<AttachSheetProps> = ({
113
112
  ]).start(({ finished }) => {
114
113
  if (!finished) {return;} // interrupted (rapid re-open) → leave state alone
115
114
  setMounted(false);
116
- const fn = pendingRef.current;
117
- pendingRef.current = null;
118
- fn?.();
115
+ if (Platform.OS !== 'ios') {
116
+ runPending();
117
+ }
119
118
  });
120
119
  return undefined;
121
120
  }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
122
121
 
123
122
  const trigger = (handler: () => void) => () => {
124
- // Capture the handler and ask parent to close. The exit-animation
125
- // completion above fires `handler` AFTER the sheet has slid away,
126
- // so iOS no longer tries to present a picker over a dismissing
127
- // modal — and Android no longer needs the ad-hoc 150ms setTimeout.
123
+ console.log('[timing] row tap', Date.now());
128
124
  pendingRef.current = handler;
125
+ if (Platform.OS === 'ios') {
126
+ setMounted(false);
127
+ }
129
128
  onClose();
130
129
  };
131
130
 
@@ -166,11 +165,13 @@ const AttachSheet: React.FC<AttachSheetProps> = ({
166
165
  onRequestClose={onClose}
167
166
  // Hardware-back / iOS gesture also routes through onClose, which
168
167
  // flips `visible` and triggers our exit animation above.
168
+ onDismiss={Platform.OS === 'ios' ? runPending : undefined}
169
169
  >
170
170
  <Animated.View
171
171
  style={[styles.backdrop, { opacity: backdropOpacity }]}
172
172
  >
173
173
  <TouchableOpacity
174
+ testID="attach-backdrop"
174
175
  style={StyleSheet.absoluteFill}
175
176
  activeOpacity={1}
176
177
  onPress={onClose}
@@ -187,6 +188,7 @@ const AttachSheet: React.FC<AttachSheetProps> = ({
187
188
  {rows.map(({ label, hint, Icon, handler }, idx) => (
188
189
  <TouchableOpacity
189
190
  key={label}
191
+ testID={`attach-row-${label}`}
190
192
  activeOpacity={0.6}
191
193
  style={[
192
194
  styles.row,
@@ -295,6 +295,11 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
295
295
  </>
296
296
  }
297
297
  />
298
+ <ScrollView
299
+ style={{ width: '100%', flex: 1 }}
300
+ contentContainerStyle={{ flexGrow: 1 }}
301
+ showsVerticalScrollIndicator={true}
302
+ >
298
303
  <CenterContainer>
299
304
  <ProfileImagePlaceholder
300
305
  name={activeRoom.title || activeRoom.name}
@@ -378,11 +383,7 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
378
383
  Member list unavailable
379
384
  </Label>
380
385
  ) : (
381
- <ScrollView
382
- style={{ maxHeight: 400 }}
383
- showsVerticalScrollIndicator={true}
384
- nestedScrollEnabled={true}
385
- >
386
+ <View>
386
387
  {activeRoom?.roomMembers?.map((user, index) => (
387
388
  <View
388
389
  key={user.xmppUsername}
@@ -477,11 +478,12 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
477
478
  {index < (activeRoom?.roomMembers?.length || 0) - 1 && <Divider />}
478
479
  </View>
479
480
  ))}
480
- </ScrollView>
481
+ </View>
481
482
  )}
482
483
  </BorderedContainer>
483
484
  )}
484
485
  </CenterContainer>
486
+ </ScrollView>
485
487
 
486
488
  <DeleteChatModal
487
489
  isModalOpen={isModalOpen}
@@ -265,16 +265,6 @@ 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
- }
278
268
  const result = await DocumentPicker.getDocumentAsync({
279
269
  type: '*/*',
280
270
  multiple: false,
@@ -49,6 +49,7 @@ import {
49
49
  setUnreadSyncing,
50
50
  } from '../roomStore/roomsSlice';
51
51
  import { runHistoryPreloadScheduler } from '../helpers/historyPreloadScheduler';
52
+ import { updateMessagesTillLast } from '../helpers/updateMessagesTillLast';
52
53
  import { secureUserStorage } from '../helpers/secureUserStorage';
53
54
  import { clearPersistedState } from '../roomStore/persistence';
54
55
  import { pushLog as devPushLog } from '../utils/devLogger';
@@ -239,6 +240,19 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
239
240
  .catch((e: unknown) =>
240
241
  devPushLog('warn', 'reconnect: privateStore refresh failed', e)
241
242
  );
243
+ // Pull whatever landed in each room's archive while the stream was
244
+ // down. MUC presence re-join above only restores delivery for
245
+ // messages sent AFTER this reconnect — anything sent during the
246
+ // offline window has no live stanza to catch, and previously only
247
+ // came back on a full app restart (fresh useChatWrapperInit mount
248
+ // re-running its own one-shot boot sync). Safe on the very first
249
+ // connect too: rooms have no `lastMessageTimestamp` yet at that
250
+ // point, so this is a no-op until the bootstrap's own history load
251
+ // populates it. Customer-reported #32.
252
+ const rooms = store.getState().rooms?.rooms || {};
253
+ updateMessagesTillLast(rooms, created).catch((e: unknown) =>
254
+ devPushLog('warn', 'reconnect: offline catch-up sync failed', e)
255
+ );
242
256
  });
243
257
 
244
258
  setClient(created);
@@ -1,7 +1,11 @@
1
1
  import { AppState } from 'react-native';
2
2
  import XmppClient from '../networking/xmppClient';
3
3
  import { store } from '../roomStore';
4
- import { applyRoomsPreloadBatch, setUnreadSyncing } from '../roomStore/roomsSlice';
4
+ import {
5
+ applyRoomsPreloadBatch,
6
+ setUnreadSyncing,
7
+ RUNTIME_MESSAGE_LIMIT,
8
+ } from '../roomStore/roomsSlice';
5
9
  import { IMessage, IRoom } from '../types/types';
6
10
 
7
11
  interface HistoryPreloadSchedulerOptions {
@@ -30,6 +34,13 @@ const DEFAULT_CONCURRENCY = 3;
30
34
  // `mergeHistoryIntoCache` rarely has to fall back to clear-and-replace.
31
35
  const DEFAULT_PAGE_SIZE = 20;
32
36
  const DEFAULT_RETRY_LIMIT = 2;
37
+ // When the first page is entirely unread (every fetched message is newer
38
+ // than `lastViewedTimestamp`), the true unread count could be far bigger
39
+ // than `pageSize` — `useUnread()` only ever sees what's been loaded. Page
40
+ // further back, up to this many extra fetches, until we either find the
41
+ // boundary or give up (`unreadCapped` then stays true so callers at least
42
+ // know the count is a floor, not exact). Customer-reported #34.
43
+ const MAX_UNREAD_CATCHUP_PAGES = 8;
33
44
 
34
45
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
35
46
 
@@ -226,9 +237,75 @@ export const runHistoryPreloadScheduler = async (
226
237
  }
227
238
 
228
239
  const nextRoom = store.getState().rooms.rooms[item.jid];
240
+ let combined: IMessage[] = fetchedMessages || [];
241
+ const lastViewed = Number(nextRoom?.lastViewedTimestamp) || 0;
242
+
243
+ // The first page was entirely unread — keep paging older until
244
+ // we find a message at/before `lastViewedTimestamp` (the true
245
+ // boundary) or run out of catch-up budget, so the count isn't
246
+ // silently truncated at `pageSize`.
247
+ if (lastViewed > 0 && computeUnreadCapped(nextRoom, combined, pageSize)) {
248
+ let lastPageLen = combined.length;
249
+ let extraPages = 0;
250
+ while (
251
+ extraPages < MAX_UNREAD_CATCHUP_PAGES &&
252
+ lastPageLen >= pageSize &&
253
+ !signal?.aborted
254
+ ) {
255
+ const oldestId = combined.reduce<number | null>((min, m) => {
256
+ const idNum = Number((m as any)?.id);
257
+ if (!Number.isFinite(idNum)) {return min;}
258
+ return min === null || idNum < min ? idNum : min;
259
+ }, null);
260
+ if (oldestId === null) {break;}
261
+
262
+ let older: IMessage[] | undefined;
263
+ try {
264
+ older = await client.getHistoryStanza(
265
+ item.jid,
266
+ pageSize,
267
+ oldestId,
268
+ undefined,
269
+ {
270
+ coalesceRoom: true,
271
+ skipIfPreloaded: !forceReload,
272
+ source: 'background',
273
+ }
274
+ );
275
+ } catch {
276
+ break;
277
+ }
278
+ if (!older || !older.length) {break;}
279
+
280
+ combined = [...older, ...combined];
281
+ lastPageLen = older.length;
282
+ extraPages++;
283
+
284
+ const oldestTs = combined.reduce<number>((minTs, m) => {
285
+ const ts = messageTimestamp(m);
286
+ return ts > 0 ? Math.min(minTs, ts) : minTs;
287
+ }, Number.MAX_SAFE_INTEGER);
288
+ if (oldestTs !== Number.MAX_SAFE_INTEGER && oldestTs <= lastViewed) {
289
+ break;
290
+ }
291
+ }
292
+ }
293
+
294
+ // `mergeHistoryIntoCache` (roomsSlice) caps whatever we dispatch
295
+ // here to the newest RUNTIME_MESSAGE_LIMIT messages before it
296
+ // ever reaches `unreadMiddleware` — so a catch-up fetch that
297
+ // pulled the true boundary can still have it silently dropped
298
+ // by that cap, leaving a falsely-confident `unreadCapped:false`
299
+ // (bug #34's failure mode again, just one layer downstream).
300
+ // Simulate that cap here so the flag reflects what will
301
+ // actually survive, not just what this fetch retrieved.
302
+ const simulatedStored =
303
+ combined.length > RUNTIME_MESSAGE_LIMIT
304
+ ? combined.slice(-RUNTIME_MESSAGE_LIMIT)
305
+ : combined;
229
306
  const unreadCapped = computeUnreadCapped(
230
307
  nextRoom,
231
- fetchedMessages || [],
308
+ simulatedStored,
232
309
  pageSize
233
310
  );
234
311
 
@@ -237,7 +314,7 @@ export const runHistoryPreloadScheduler = async (
237
314
  rooms: [
238
315
  {
239
316
  jid: item.jid,
240
- messages: fetchedMessages || [],
317
+ messages: combined,
241
318
  unreadCapped,
242
319
  historyPreloadState: 'done',
243
320
  },
@@ -0,0 +1,33 @@
1
+ /**
2
+ * One-level shallow equality for plain objects. Used to avoid re-dispatching
3
+ * config into redux on every render just because the host passed a fresh
4
+ * object literal with the same values (e.g. `<Chat config={{...}} />`
5
+ * re-created inline) — reference equality alone would fire far too often,
6
+ * and a deep/JSON comparison risks throwing on non-serializable fields
7
+ * (functions, React elements) that configs legitimately carry.
8
+ */
9
+ export function shallowEqual(a: unknown, b: unknown): boolean {
10
+ if (a === b) {return true;}
11
+ if (
12
+ typeof a !== 'object' ||
13
+ typeof b !== 'object' ||
14
+ a === null ||
15
+ b === null
16
+ ) {
17
+ return false;
18
+ }
19
+ const aKeys = Object.keys(a as Record<string, unknown>);
20
+ const bKeys = Object.keys(b as Record<string, unknown>);
21
+ if (aKeys.length !== bKeys.length) {return false;}
22
+ for (const key of aKeys) {
23
+ if (
24
+ (a as Record<string, unknown>)[key] !==
25
+ (b as Record<string, unknown>)[key]
26
+ ) {
27
+ return false;
28
+ }
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export default shallowEqual;