@ethora/chat-component-rn 26.7.4 → 26.7.6

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 (49) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/lib/commonjs/components/Header/HeaderRoomList.js +6 -1
  3. package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
  4. package/lib/commonjs/components/MainComponents/ChatHeader.js +5 -2
  5. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  6. package/lib/commonjs/components/MainComponents/RoomList.js +179 -36
  7. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  8. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +20 -12
  9. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
  10. package/lib/commonjs/context/xmppProvider.js +17 -3
  11. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  12. package/lib/commonjs/networking/authRefresh.js +12 -2
  13. package/lib/commonjs/networking/authRefresh.js.map +1 -1
  14. package/lib/commonjs/networking/xmpp/presenceInRoom.xmpp.js +8 -1
  15. package/lib/commonjs/networking/xmpp/presenceInRoom.xmpp.js.map +1 -1
  16. package/lib/module/components/Header/HeaderRoomList.js +6 -1
  17. package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
  18. package/lib/module/components/MainComponents/ChatHeader.js +5 -2
  19. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  20. package/lib/module/components/MainComponents/RoomList.js +178 -36
  21. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  22. package/lib/module/components/Menu/HeaderRoomListMenu.js +22 -14
  23. package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
  24. package/lib/module/context/xmppProvider.js +17 -3
  25. package/lib/module/context/xmppProvider.js.map +1 -1
  26. package/lib/module/networking/authRefresh.js +12 -2
  27. package/lib/module/networking/authRefresh.js.map +1 -1
  28. package/lib/module/networking/xmpp/presenceInRoom.xmpp.js +8 -1
  29. package/lib/module/networking/xmpp/presenceInRoom.xmpp.js.map +1 -1
  30. package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
  31. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  32. package/lib/typescript/components/MainComponents/RoomList.d.ts +7 -0
  33. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  34. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
  35. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  36. package/lib/typescript/networking/authRefresh.d.ts +4 -1
  37. package/lib/typescript/networking/authRefresh.d.ts.map +1 -1
  38. package/lib/typescript/networking/xmpp/presenceInRoom.xmpp.d.ts.map +1 -1
  39. package/lib/typescript/types/types.d.ts +10 -0
  40. package/lib/typescript/types/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/Header/HeaderRoomList.tsx +4 -1
  43. package/src/components/MainComponents/ChatHeader.tsx +5 -2
  44. package/src/components/MainComponents/RoomList.tsx +206 -40
  45. package/src/components/Menu/HeaderRoomListMenu.tsx +29 -15
  46. package/src/context/xmppProvider.tsx +19 -5
  47. package/src/networking/authRefresh.ts +25 -4
  48. package/src/networking/xmpp/presenceInRoom.xmpp.ts +6 -1
  49. package/src/types/types.ts +10 -0
@@ -13,8 +13,8 @@ import {
13
13
  FlatList,
14
14
  Pressable,
15
15
  Animated,
16
- Text,
17
- TouchableOpacity,
16
+ NativeScrollEvent,
17
+ NativeSyntheticEvent,
18
18
  } from 'react-native';
19
19
  import { IRoom } from '../../types/types';
20
20
  import { SearchInput } from '../InputComponents/Search';
@@ -39,9 +39,37 @@ interface RoomListProps {
39
39
  * rounded bottom corners. */
40
40
  const LIST_BACKGROUND = '#E8EDF2';
41
41
 
42
- /** First-paint fallback until the floating search strip reports its real
43
- * height; the list's top padding follows the measurement after that. */
44
- const SEARCH_BAR_ESTIMATE = 60;
42
+ /** A release that leaves the search strip in between is settled by
43
+ * scrolling to whichever end is nearer; this delay lets the platform tell
44
+ * us first whether the finger threw the list (momentum) or just let go. */
45
+ const SNAP_SETTLE_DELAY = 60;
46
+
47
+ const COLLAPSED_SCALE = 0.86;
48
+
49
+ const SEARCH_REST_GAP = 16;
50
+
51
+ export const searchRevealStyle = (
52
+ scrollY: Animated.Value,
53
+ hiddenOffset: number
54
+ ) => {
55
+ const travel = Math.max(hiddenOffset, 1);
56
+ return {
57
+ opacity: scrollY.interpolate({
58
+ inputRange: [0, travel * 0.85],
59
+ outputRange: [1, 0],
60
+ extrapolate: 'clamp' as const,
61
+ }),
62
+ transform: [
63
+ {
64
+ scale: scrollY.interpolate({
65
+ inputRange: [0, travel],
66
+ outputRange: [1, COLLAPSED_SCALE],
67
+ extrapolate: 'clamp' as const,
68
+ }),
69
+ },
70
+ ],
71
+ };
72
+ };
45
73
 
46
74
  const RoomList: React.FC<RoomListProps> = ({
47
75
  chats,
@@ -55,9 +83,12 @@ const RoomList: React.FC<RoomListProps> = ({
55
83
  const [searchTerm, setSearchTerm] = useState('');
56
84
  const [isLongPress, setIsLongPress] = useState(false);
57
85
  const [isDrawerOpen, setDrawerOpen] = useState(false);
86
+ const [isSearchFocused, setSearchFocused] = useState(false);
58
87
 
59
88
  const pressTimer = useRef<NodeJS.Timeout | null>(null);
60
89
  const containerRef = useRef<View>(null);
90
+ const listRef = useRef<FlatList<IRoom> | null>(null);
91
+ const scrollY = useRef(new Animated.Value(0)).current;
61
92
 
62
93
  const drawerAnimation = useRef(new Animated.Value(0)).current;
63
94
  const overlayAnimation = useRef(new Animated.Value(0)).current;
@@ -128,9 +159,123 @@ const RoomList: React.FC<RoomListProps> = ({
128
159
  }
129
160
  }, [burgerMenu]);
130
161
 
131
- // Measured, so the list's top padding always matches the floating
132
- // search strip — including when a larger font or a taller field grows it.
133
- const [searchBarHeight, setSearchBarHeight] = useState(SEARCH_BAR_ESTIMATE);
162
+ // The search field is the list's own header rather than a bar pinned
163
+ // above it: it lives in the scrollable content, so the list opens
164
+ // already scrolled past it (rooms first, no search in sight) and
165
+ // dragging the content down brings it back with the finger — the way
166
+ // Telegram's chat list behaves. A pinned bar could not do this on
167
+ // Android, where a list sitting at offset 0 has nothing left to drag.
168
+ const searchBarHeight = useRef(0);
169
+ const [measuredBarHeight, setMeasuredBarHeight] = useState(0);
170
+ const listHeight = useRef(0);
171
+ const contentHeight = useRef(0);
172
+ /** The list starts hidden-search only once, and only before the user
173
+ * has touched it — chats arriving later must not yank the view. */
174
+ const didInitialHide = useRef(false);
175
+ const hasDragged = useRef(false);
176
+ const inMomentum = useRef(false);
177
+ const settleTimer = useRef<NodeJS.Timeout | null>(null);
178
+
179
+ // A search being typed in (or just focused) is never tucked away
180
+ // under the header, whichever way the list is then dragged.
181
+ const searchIsActive = useRef(false);
182
+ searchIsActive.current = isSearchFocused || searchTerm.length > 0;
183
+
184
+ /** How far the list can scroll — the strip can only be hidden fully
185
+ * when the rooms below it are tall enough to take its place. */
186
+ const maxOffset = () => contentHeight.current - listHeight.current;
187
+
188
+ const hiddenOffset = () =>
189
+ Math.max(searchBarHeight.current - SEARCH_REST_GAP, 0);
190
+
191
+ const hideSearchInitially = useCallback(() => {
192
+ if (didInitialHide.current || hasDragged.current) return;
193
+ const hidden = hiddenOffset();
194
+ if (!hidden || !listHeight.current || !contentHeight.current) return;
195
+ if (maxOffset() < hidden) return;
196
+ didInitialHide.current = true;
197
+ listRef.current?.scrollToOffset({ offset: hidden, animated: false });
198
+ }, []);
199
+
200
+ /** The magnet: a strip left half-way in or out settles to whichever
201
+ * end it is closer to. */
202
+ const snapSearch = useCallback((offset: number) => {
203
+ const hidden = hiddenOffset();
204
+ if (!hidden || maxOffset() < hidden) return;
205
+ if (offset <= 0 || offset >= hidden) return;
206
+ const hide = !searchIsActive.current && offset > hidden / 2;
207
+ listRef.current?.scrollToOffset({
208
+ offset: hide ? hidden : 0,
209
+ animated: true,
210
+ });
211
+ }, []);
212
+
213
+ const clearSettleTimer = () => {
214
+ if (settleTimer.current) {
215
+ clearTimeout(settleTimer.current);
216
+ settleTimer.current = null;
217
+ }
218
+ };
219
+
220
+ useEffect(() => clearSettleTimer, []);
221
+
222
+ const handleScroll = useMemo(
223
+ () =>
224
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
225
+ useNativeDriver: true,
226
+ }),
227
+ [scrollY]
228
+ );
229
+
230
+ const revealStyle = useMemo(
231
+ () =>
232
+ searchRevealStyle(
233
+ scrollY,
234
+ Math.max(measuredBarHeight - SEARCH_REST_GAP, 0)
235
+ ),
236
+ [scrollY, measuredBarHeight]
237
+ );
238
+
239
+ const handleScrollBeginDrag = useCallback(() => {
240
+ hasDragged.current = true;
241
+ clearSettleTimer();
242
+ }, []);
243
+
244
+ const handleScrollEndDrag = useCallback(
245
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
246
+ const offset = e.nativeEvent.contentOffset.y;
247
+ inMomentum.current = false;
248
+ clearSettleTimer();
249
+ // A flick keeps scrolling after the release; snapping now would
250
+ // fight it, so wait a beat and let momentum claim the gesture.
251
+ settleTimer.current = setTimeout(() => {
252
+ settleTimer.current = null;
253
+ if (!inMomentum.current) snapSearch(offset);
254
+ }, SNAP_SETTLE_DELAY);
255
+ },
256
+ [snapSearch]
257
+ );
258
+
259
+ const handleMomentumBegin = useCallback(() => {
260
+ inMomentum.current = true;
261
+ clearSettleTimer();
262
+ }, []);
263
+
264
+ const handleMomentumEnd = useCallback(
265
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
266
+ inMomentum.current = false;
267
+ snapSearch(e.nativeEvent.contentOffset.y);
268
+ },
269
+ [snapSearch]
270
+ );
271
+
272
+ // Tapping the field pulls it fully into view even if it was caught
273
+ // half-way, so the caret never sits under the header.
274
+ useEffect(() => {
275
+ if (isSearchFocused) {
276
+ listRef.current?.scrollToOffset({ offset: 0, animated: true });
277
+ }
278
+ }, [isSearchFocused]);
134
279
 
135
280
  const toggleDrawer = () => {
136
281
  if (isDrawerOpen) {
@@ -169,6 +314,33 @@ const RoomList: React.FC<RoomListProps> = ({
169
314
  });
170
315
  };
171
316
 
317
+ const searchHeader = (
318
+ <View
319
+ testID="room-list-search"
320
+ style={styles.searchBar}
321
+ onLayout={(e) => {
322
+ const height = e.nativeEvent.layout.height;
323
+ searchBarHeight.current = height;
324
+ setMeasuredBarHeight((prev) => (prev === height ? prev : height));
325
+ hideSearchInitially();
326
+ }}
327
+ >
328
+ <Animated.View
329
+ testID="room-list-search-field"
330
+ style={[styles.searchField, revealStyle]}
331
+ >
332
+ <SearchInput
333
+ icon={<SearchIcon height={20} />}
334
+ value={searchTerm}
335
+ onChangeText={handleSearchChange}
336
+ onFocus={() => setSearchFocused(true)}
337
+ onBlur={() => setSearchFocused(false)}
338
+ placeholder={t('search.placeholder')}
339
+ />
340
+ </Animated.View>
341
+ </View>
342
+ );
343
+
172
344
  return (
173
345
  <>
174
346
  {burgerMenu && !open && (
@@ -192,19 +364,29 @@ const RoomList: React.FC<RoomListProps> = ({
192
364
  <>
193
365
  <View style={styles.scrollContainer}>
194
366
  <HeaderRoomList setDrawerOpen={toggleDrawer} />
195
- {/* The search field floats OVER the list rather than
196
- sitting above it in the column: its strip is transparent
197
- and the rooms scroll underneath, which is what the design
198
- shows. It is still fixed (not a ListHeaderComponent), so
199
- it stays reachable however far the list is scrolled — as a
200
- list header it scrolled away behind the opaque header. */}
201
367
  <View style={styles.listArea}>
202
- <FlatList
368
+ {/* Animated.FlatList, so the reveal runs on the native
369
+ thread off the very same scroll it follows. */}
370
+ <Animated.FlatList
371
+ ref={listRef}
203
372
  data={filteredChats}
204
373
  keyExtractor={(item) => item.jid}
205
- // Clears the floating field on first paint; scrolling
206
- // then slides the rooms under it.
207
- contentContainerStyle={{ paddingTop: searchBarHeight }}
374
+ ListHeaderComponent={searchHeader}
375
+ onLayout={(e) => {
376
+ listHeight.current = e.nativeEvent.layout.height;
377
+ hideSearchInitially();
378
+ }}
379
+ onContentSizeChange={(_w, h) => {
380
+ contentHeight.current = h;
381
+ hideSearchInitially();
382
+ }}
383
+ onScroll={handleScroll}
384
+ scrollEventThrottle={16}
385
+ onScrollBeginDrag={handleScrollBeginDrag}
386
+ onScrollEndDrag={handleScrollEndDrag}
387
+ onMomentumScrollBegin={handleMomentumBegin}
388
+ onMomentumScrollEnd={handleMomentumEnd}
389
+ keyboardShouldPersistTaps="handled"
208
390
  renderItem={({ item }) => (
209
391
  <Pressable
210
392
  // Stable testID so e2e drivers can target a room
@@ -221,20 +403,6 @@ const RoomList: React.FC<RoomListProps> = ({
221
403
  )}
222
404
  style={styles.chatList}
223
405
  />
224
- <View
225
- testID="room-list-search"
226
- style={styles.searchBar}
227
- onLayout={(e) =>
228
- setSearchBarHeight(e.nativeEvent.layout.height)
229
- }
230
- >
231
- <SearchInput
232
- icon={<SearchIcon height={20} />}
233
- value={searchTerm}
234
- onChangeText={handleSearchChange}
235
- placeholder={t('search.placeholder')}
236
- />
237
- </View>
238
406
  </View>
239
407
 
240
408
  <HeaderRoomListMenu
@@ -276,21 +444,19 @@ const styles = StyleSheet.create({
276
444
  flex: 1,
277
445
  },
278
446
  searchBar: {
447
+ paddingBottom: 4,
279
448
  // `row` matters: SearchInputWrapper is `flex: 1` plus a fixed 44px
280
449
  // height. In a column parent that flex resolves VERTICALLY against a
281
450
  // parent with no height of its own and collapses the field to nothing
282
- // but its magnifier. As a row it resolves to width, which is what it
283
- // meant back when the field was a list-header child.
451
+ // but its magnifier. As a row it resolves to width.
284
452
  flexDirection: 'row',
285
- position: 'absolute',
286
- top: 0,
287
- left: 0,
288
- right: 0,
289
453
  paddingTop: 8,
290
- paddingBottom: 8,
291
- paddingHorizontal: 16,
292
454
  backgroundColor: 'transparent',
293
455
  },
456
+ searchField: {
457
+ flexDirection: 'row',
458
+ flex: 1,
459
+ },
294
460
  chatList: {
295
461
  flex: 1,
296
462
  paddingHorizontal: 16,
@@ -1,4 +1,4 @@
1
- import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
1
+ import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  Alert,
4
4
  Animated,
@@ -9,6 +9,7 @@ import {
9
9
  Dimensions,
10
10
  Modal,
11
11
  PanResponder,
12
+ Platform,
12
13
  View,
13
14
  } from 'react-native';
14
15
  import {
@@ -130,6 +131,30 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
130
131
  const closeDrawerRef = useRef(closeDrawer);
131
132
  closeDrawerRef.current = closeDrawer;
132
133
 
134
+ const pendingActionRef = useRef<(() => void) | null>(null);
135
+
136
+ const runPendingAction = useCallback(() => {
137
+ const action = pendingActionRef.current;
138
+ pendingActionRef.current = null;
139
+ action?.();
140
+ }, []);
141
+
142
+ useEffect(() => {
143
+ if (Platform.OS === 'ios' || isDrawerOpen) {
144
+ return;
145
+ }
146
+ const id = setTimeout(runPendingAction, 0);
147
+ return () => clearTimeout(id);
148
+ }, [isDrawerOpen, runPendingAction]);
149
+
150
+ const handleOptionPress = useCallback(
151
+ (action: () => void) => {
152
+ pendingActionRef.current = action;
153
+ closeDrawer();
154
+ },
155
+ [closeDrawer]
156
+ );
157
+
133
158
  useEffect(() => {
134
159
  if (!isDrawerOpen) {
135
160
  dragY.setValue(0);
@@ -215,15 +240,12 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
215
240
  icon: React.ReactElement;
216
241
  onClick: () => void;
217
242
  styles?: { color: string };
218
- /** When true the row closes the drawer itself before acting. */
219
- closesDrawer?: boolean;
220
243
  }[] = [
221
244
  {
222
245
  label: 'New Chat',
223
246
  icon: <AddNewIcon color={primaryColor} />,
224
247
  onClick: () => {
225
248
  dispatch(setActiveModal(MODAL_TYPES.NEW_CHAT));
226
- console.log('New chat clicked');
227
249
  },
228
250
  styles: { color: '#141414' },
229
251
  },
@@ -232,7 +254,6 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
232
254
  icon: <ProfileIcon color={primaryColor} />,
233
255
  onClick: () => {
234
256
  dispatch(setActiveModal(MODAL_TYPES.PROFILE));
235
- console.log('Profile clicked');
236
257
  },
237
258
  },
238
259
  {
@@ -240,7 +261,6 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
240
261
  icon: <SettingIcon color={primaryColor} />,
241
262
  onClick: () => {
242
263
  dispatch(setActiveModal(MODAL_TYPES.SETTINGS));
243
- console.log('Settings clicked');
244
264
  },
245
265
  },
246
266
  ];
@@ -249,15 +269,13 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
249
269
  label: logoutConfig.label ?? DEFAULT_LOGOUT_LABEL,
250
270
  icon: <LogoutIcon color={primaryColor} />,
251
271
  styles: { color: primaryColor },
252
- closesDrawer: true,
253
272
  onClick: () => {
254
- closeDrawer();
255
273
  runLogoutFlow(logoutConfig, performLogout).catch(() => {});
256
274
  },
257
275
  });
258
276
  }
259
277
  return options;
260
- }, [dispatch, logoutConfig, performLogout, primaryColor, closeDrawer]);
278
+ }, [dispatch, logoutConfig, performLogout, primaryColor]);
261
279
 
262
280
  return (
263
281
  // Presented through a real <Modal>: as an in-tree overlay the sheet was
@@ -270,6 +288,7 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
270
288
  animationType="none"
271
289
  statusBarTranslucent
272
290
  onRequestClose={closeDrawer}
291
+ onDismiss={runPendingAction}
273
292
  >
274
293
  <TouchableWithoutFeedback onPress={closeDrawer}>
275
294
  <Animated.View
@@ -314,12 +333,7 @@ export const HeaderRoomListMenu: FC<HeaderRoomListMenuProps> = ({
314
333
  style={styles.menuItem}
315
334
  testID={`header-menu-${option.label}`}
316
335
  activeOpacity={0.6}
317
- onPress={() => {
318
- option.onClick();
319
- if (!option.closesDrawer) {
320
- closeDrawer();
321
- }
322
- }}
336
+ onPress={() => handleOptionPress(option.onClick)}
323
337
  >
324
338
  <View style={styles.iconSlot}>{option.icon}</View>
325
339
  <Text style={[styles.label, option?.styles]}>
@@ -37,6 +37,7 @@ import {
37
37
  import { ensureScopedChatCache } from '../helpers/ensureScopedChatCache';
38
38
  import { getRooms as prefetchRoomsViaRest } from '../networking/api-requests/rooms.api';
39
39
  import { allRoomPresences } from '../networking/xmpp/allRoomPresences.xmpp';
40
+ import { pushSubscriptionService } from '../services/pushSubscriptionService';
40
41
  import { store } from '../roomStore';
41
42
  import { logout, setStoreClient, setConfig } from '../roomStore/chatSettingsSlice';
42
43
  import {
@@ -83,6 +84,16 @@ interface XmppProviderProps {
83
84
 
84
85
  const LOGOUT_EVENT = 'ethora-xmpp-logout';
85
86
 
87
+ const subscribeAllRoomsForPush = (client: any, reason: string) => {
88
+ const rooms = Object.keys(store.getState().rooms.rooms || {});
89
+ if (!client || !rooms.length) {return;}
90
+ const nick = client.jid?.getLocal();
91
+ pushSubscriptionService
92
+ .subscribeToRooms(client, rooms, nick)
93
+ .then(() => devPushLog('xmpp', `${reason}: mucsub ok for ${rooms.length} rooms`))
94
+ .catch((e) => devPushLog('warn', `${reason}: mucsub subscribe failed`, e));
95
+ };
96
+
86
97
  export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, isVisible }) => {
87
98
  const [client, setClient] = useState<XmppClient | null>(null);
88
99
  const [providerBootstrapStatus, setProviderBootstrapStatus] =
@@ -115,7 +126,7 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
115
126
  // the shared rotation point covers both the consumer-supplied
116
127
  // function and the built-in endpoint.
117
128
  if (config?.refreshTokens?.enabled) {
118
- const rotated = await refreshAuthTokensQuietly();
129
+ const rotated = await refreshAuthTokensQuietly({ force: true });
119
130
 
120
131
  if (rotated?.xmppPassword) {
121
132
  const rotatedUser = store.getState().chatSettingStore.user;
@@ -229,9 +240,11 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
229
240
  created.setOnOnline(() => {
230
241
  const underlying = (created as any).client;
231
242
  if (!underlying) {return;}
232
- allRoomPresences(underlying).catch((e) =>
233
- devPushLog('warn', 'reconnect: allRoomPresences re-join failed', e)
234
- );
243
+ allRoomPresences(underlying)
244
+ .catch((e) =>
245
+ devPushLog('warn', 'reconnect: allRoomPresences re-join failed', e)
246
+ )
247
+ .finally(() => subscribeAllRoomsForPush(underlying, 'reconnect'));
235
248
  // Also refresh the private store so unread / lastViewed markers
236
249
  // are accurate after a long reconnect — the MUC re-join above only
237
250
  // restores delivery, not unread state. Idempotent on first connect.
@@ -384,7 +397,7 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
384
397
  // jwtLogin is skipped: /users/client just minted fresh creds.
385
398
  let connectPassword = resolved.xmppPassword;
386
399
  if (config?.refreshTokens?.enabled && !config?.jwtLogin?.enabled) {
387
- const rotated = await refreshAuthTokensQuietly();
400
+ const rotated = await refreshAuthTokensQuietly({ force: true });
388
401
  if (cancelled) {return;}
389
402
  if (rotated?.xmppPassword) {
390
403
  connectPassword = rotated.xmppPassword;
@@ -436,6 +449,7 @@ export const XmppProvider: React.FC<XmppProviderProps> = ({ children, config, is
436
449
  } catch (e) {
437
450
  devPushLog('warn', 'initBeforeLoad: allRoomPresences failed', e);
438
451
  }
452
+ subscribeAllRoomsForPush((c as any).client, 'initBeforeLoad');
439
453
 
440
454
  store.dispatch(setStoreClient(c));
441
455
  completedBootstrapKeyRef.current = key;
@@ -382,6 +382,9 @@ export interface RefreshOptions {
382
382
 
383
383
  let inflight: Promise<RefreshResult> | null = null;
384
384
 
385
+ let lastRotationAt = 0;
386
+ const QUIET_ROTATION_MIN_INTERVAL_MS = 5 * 60 * 1000;
387
+
385
388
  /**
386
389
  * Rotate the tokens. Concurrent callers share ONE request — this is the
387
390
  * lock the new backend scheme requires.
@@ -399,9 +402,14 @@ export function refreshAuthTokens(
399
402
  return inflight;
400
403
  }
401
404
 
402
- inflight = performRefresh(options?.refreshToken).finally(() => {
403
- inflight = null;
404
- });
405
+ inflight = performRefresh(options?.refreshToken)
406
+ .then((result) => {
407
+ lastRotationAt = Date.now();
408
+ return result;
409
+ })
410
+ .finally(() => {
411
+ inflight = null;
412
+ });
405
413
 
406
414
  return inflight;
407
415
  }
@@ -411,7 +419,19 @@ export function refreshAuthTokens(
411
419
  * tokens are fresh" and have no meaningful error handling — chat
412
420
  * bootstrap, XMPP reconnect. Never rejects.
413
421
  */
414
- export async function refreshAuthTokensQuietly(): Promise<RefreshResult | null> {
422
+ export interface QuietRefreshOptions {
423
+ force?: boolean;
424
+ }
425
+
426
+ export async function refreshAuthTokensQuietly(
427
+ options?: QuietRefreshOptions
428
+ ): Promise<RefreshResult | null> {
429
+ if (
430
+ !options?.force &&
431
+ Date.now() - lastRotationAt < QUIET_ROTATION_MIN_INTERVAL_MS
432
+ ) {
433
+ return null;
434
+ }
415
435
  try {
416
436
  return await refreshAuthTokens();
417
437
  } catch (error) {
@@ -427,4 +447,5 @@ export async function refreshAuthTokensQuietly(): Promise<RefreshResult | null>
427
447
  /** Test seam — drops any shared in-flight promise between cases. */
428
448
  export function __resetAuthRefreshStateForTests(): void {
429
449
  inflight = null;
450
+ lastRotationAt = 0;
430
451
  }
@@ -70,7 +70,12 @@ export const presenceInRoom = async (
70
70
  try {
71
71
  await createTimeoutPromise(2000, unsubscribe);
72
72
  } catch (err) {
73
- reject(err);
73
+ // The room's reply schedules resolve() `delay` ms later (2000 by
74
+ // default) — the same length as this timeout, so the timeout used
75
+ // to win the race and reject a join that had actually succeeded
76
+ // (allRoomPresences "failed" with undefined on every bootstrap).
77
+ // Only a join that never got an answer is a failure.
78
+ if (!settled) {reject(err);}
74
79
  }
75
80
  })();
76
81
  });
@@ -397,6 +397,16 @@ export interface IConfig {
397
397
  * value takes effect.
398
398
  */
399
399
  height?: number;
400
+ /**
401
+ * Extend the header cards (room list + chat) under the status bar:
402
+ * each pads its top by the safe-area inset and paints it white, so
403
+ * the card and the status-bar band are one surface. Use this instead
404
+ * of wrapping `<Chat>` in a top-edge SafeAreaView: a host-side white
405
+ * band above the card shows a hairline seam where the card's
406
+ * shadow/elevation starts. Off by default (hosts that already pad the
407
+ * top keep their layout).
408
+ */
409
+ safeAreaTop?: boolean;
400
410
  };
401
411
  disableHeader?: boolean;
402
412
  disableMedia?: boolean;