@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.
- package/CHANGELOG.md +9 -0
- package/README.md +22 -0
- package/lib/commonjs/assets/icons.js +2 -2
- package/lib/commonjs/assets/icons.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatRoom.js +60 -3
- package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatWrapper.js +18 -0
- package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
- package/lib/commonjs/components/MainComponents/MessageList.js +9 -3
- package/lib/commonjs/components/MainComponents/MessageList.js.map +1 -1
- package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +110 -37
- package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
- package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +16 -14
- package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
- package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +130 -125
- package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
- package/lib/commonjs/components/styled/SendInput.js +0 -10
- package/lib/commonjs/components/styled/SendInput.js.map +1 -1
- package/lib/commonjs/context/xmppProvider.js +12 -0
- package/lib/commonjs/context/xmppProvider.js.map +1 -1
- package/lib/commonjs/helpers/historyPreloadScheduler.js +65 -2
- package/lib/commonjs/helpers/historyPreloadScheduler.js.map +1 -1
- package/lib/commonjs/helpers/shallowEqual.js +36 -0
- package/lib/commonjs/helpers/shallowEqual.js.map +1 -0
- package/lib/commonjs/hooks/useChatWrapperInit.js +15 -0
- package/lib/commonjs/hooks/useChatWrapperInit.js.map +1 -1
- package/lib/commonjs/hooks/useComposing.js +27 -0
- package/lib/commonjs/hooks/useComposing.js.map +1 -1
- package/lib/commonjs/hooks/useSendMessage.js +27 -1
- package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
- package/lib/commonjs/hooks/useUnreadMessagesCounter.js +6 -1
- package/lib/commonjs/hooks/useUnreadMessagesCounter.js.map +1 -1
- package/lib/commonjs/main.js +6 -0
- package/lib/commonjs/main.js.map +1 -1
- package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js +6 -2
- package/lib/commonjs/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
- package/lib/commonjs/networking/xmppClient.js.map +1 -1
- package/lib/commonjs/roomStore/roomsSlice.js +2 -2
- package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
- package/lib/module/assets/icons.js +1 -1
- package/lib/module/assets/icons.js.map +1 -1
- package/lib/module/components/MainComponents/ChatRoom.js +61 -4
- package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/module/components/MainComponents/ChatWrapper.js +19 -1
- package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
- package/lib/module/components/MainComponents/MessageList.js +9 -3
- package/lib/module/components/MainComponents/MessageList.js.map +1 -1
- package/lib/module/components/Menu/HeaderRoomListMenu.js +110 -38
- package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
- package/lib/module/components/Modals/AttachSheet/AttachSheet.js +16 -14
- package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
- package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +130 -125
- package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
- package/lib/module/components/styled/SendInput.js +1 -11
- package/lib/module/components/styled/SendInput.js.map +1 -1
- package/lib/module/context/xmppProvider.js +12 -0
- package/lib/module/context/xmppProvider.js.map +1 -1
- package/lib/module/helpers/historyPreloadScheduler.js +66 -3
- package/lib/module/helpers/historyPreloadScheduler.js.map +1 -1
- package/lib/module/helpers/shallowEqual.js +31 -0
- package/lib/module/helpers/shallowEqual.js.map +1 -0
- package/lib/module/hooks/useChatWrapperInit.js +15 -0
- package/lib/module/hooks/useChatWrapperInit.js.map +1 -1
- package/lib/module/hooks/useComposing.js +28 -1
- package/lib/module/hooks/useComposing.js.map +1 -1
- package/lib/module/hooks/useSendMessage.js +28 -2
- package/lib/module/hooks/useSendMessage.js.map +1 -1
- package/lib/module/hooks/useUnreadMessagesCounter.js +6 -1
- package/lib/module/hooks/useUnreadMessagesCounter.js.map +1 -1
- package/lib/module/main.js +1 -1
- package/lib/module/main.js.map +1 -1
- package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js +6 -2
- package/lib/module/networking/xmpp/flushLastViewedToPrivateStore.js.map +1 -1
- package/lib/module/networking/xmppClient.js.map +1 -1
- package/lib/module/roomStore/roomsSlice.js +1 -1
- package/lib/module/roomStore/roomsSlice.js.map +1 -1
- package/lib/typescript/assets/icons.d.ts +4 -0
- package/lib/typescript/assets/icons.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ChatWrapper.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/MessageList.d.ts +10 -1
- package/lib/typescript/components/MainComponents/MessageList.d.ts.map +1 -1
- package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts +3 -0
- package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
- package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
- package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
- package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
- package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
- package/lib/typescript/helpers/historyPreloadScheduler.d.ts.map +1 -1
- package/lib/typescript/helpers/shallowEqual.d.ts +11 -0
- package/lib/typescript/helpers/shallowEqual.d.ts.map +1 -0
- package/lib/typescript/hooks/useChatWrapperInit.d.ts.map +1 -1
- package/lib/typescript/hooks/useComposing.d.ts.map +1 -1
- package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
- package/lib/typescript/hooks/useUnreadMessagesCounter.d.ts +1 -0
- package/lib/typescript/hooks/useUnreadMessagesCounter.d.ts.map +1 -1
- package/lib/typescript/main.d.ts +1 -1
- package/lib/typescript/main.d.ts.map +1 -1
- package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts +1 -0
- package/lib/typescript/networking/xmpp/flushLastViewedToPrivateStore.d.ts.map +1 -1
- package/lib/typescript/networking/xmppClient.d.ts +1 -0
- package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
- package/lib/typescript/roomStore/roomsSlice.d.ts +1 -0
- package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +13 -0
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/assets/icons.tsx +19 -0
- package/src/components/MainComponents/ChatRoom.tsx +63 -1
- package/src/components/MainComponents/ChatWrapper.tsx +19 -1
- package/src/components/MainComponents/MessageList.tsx +19 -2
- package/src/components/Menu/HeaderRoomListMenu.tsx +105 -11
- package/src/components/Modals/AttachSheet/AttachSheet.tsx +16 -14
- package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +8 -6
- package/src/components/styled/SendInput.tsx +0 -10
- package/src/context/xmppProvider.tsx +14 -0
- package/src/helpers/historyPreloadScheduler.ts +80 -3
- package/src/helpers/shallowEqual.ts +33 -0
- package/src/hooks/useChatWrapperInit.ts +15 -0
- package/src/hooks/useComposing.tsx +37 -1
- package/src/hooks/useSendMessage.tsx +32 -2
- package/src/hooks/useUnreadMessagesCounter.ts +11 -0
- package/src/main.ts +1 -1
- package/src/networking/xmpp/flushLastViewedToPrivateStore.ts +17 -3
- package/src/networking/xmppClient.ts +5 -1
- package/src/roomStore/roomsSlice.ts +1 -1
- 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=
|
|
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 {
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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;
|