@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.
- package/CHANGELOG.md +5 -0
- package/lib/commonjs/components/Header/HeaderRoomList.js +6 -1
- package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatHeader.js +5 -2
- package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
- package/lib/commonjs/components/MainComponents/RoomList.js +179 -36
- package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
- package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +20 -12
- package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
- package/lib/commonjs/context/xmppProvider.js +17 -3
- package/lib/commonjs/context/xmppProvider.js.map +1 -1
- package/lib/commonjs/networking/authRefresh.js +12 -2
- package/lib/commonjs/networking/authRefresh.js.map +1 -1
- package/lib/commonjs/networking/xmpp/presenceInRoom.xmpp.js +8 -1
- package/lib/commonjs/networking/xmpp/presenceInRoom.xmpp.js.map +1 -1
- package/lib/module/components/Header/HeaderRoomList.js +6 -1
- package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
- package/lib/module/components/MainComponents/ChatHeader.js +5 -2
- package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
- package/lib/module/components/MainComponents/RoomList.js +178 -36
- package/lib/module/components/MainComponents/RoomList.js.map +1 -1
- package/lib/module/components/Menu/HeaderRoomListMenu.js +22 -14
- package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
- package/lib/module/context/xmppProvider.js +17 -3
- package/lib/module/context/xmppProvider.js.map +1 -1
- package/lib/module/networking/authRefresh.js +12 -2
- package/lib/module/networking/authRefresh.js.map +1 -1
- package/lib/module/networking/xmpp/presenceInRoom.xmpp.js +8 -1
- package/lib/module/networking/xmpp/presenceInRoom.xmpp.js.map +1 -1
- package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/RoomList.d.ts +7 -0
- package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
- package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
- package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
- package/lib/typescript/networking/authRefresh.d.ts +4 -1
- package/lib/typescript/networking/authRefresh.d.ts.map +1 -1
- package/lib/typescript/networking/xmpp/presenceInRoom.xmpp.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +10 -0
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Header/HeaderRoomList.tsx +4 -1
- package/src/components/MainComponents/ChatHeader.tsx +5 -2
- package/src/components/MainComponents/RoomList.tsx +206 -40
- package/src/components/Menu/HeaderRoomListMenu.tsx +29 -15
- package/src/context/xmppProvider.tsx +19 -5
- package/src/networking/authRefresh.ts +25 -4
- package/src/networking/xmpp/presenceInRoom.xmpp.ts +6 -1
- package/src/types/types.ts +10 -0
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
FlatList,
|
|
14
14
|
Pressable,
|
|
15
15
|
Animated,
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
/**
|
|
43
|
-
*
|
|
44
|
-
|
|
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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
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)
|
|
233
|
-
|
|
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)
|
|
403
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/src/types/types.ts
CHANGED
|
@@ -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;
|