@ethora/chat-component-rn 26.8.4 → 26.8.5
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/lib/commonjs/components/Header/HeaderRoomList.js +12 -7
- package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatRoom.js +23 -3
- package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatWrapper.js +40 -9
- package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
- package/lib/commonjs/components/MainComponents/KeyboardInputDock.js +40 -0
- package/lib/commonjs/components/MainComponents/KeyboardInputDock.js.map +1 -0
- package/lib/commonjs/components/MainComponents/RoomStack.js +202 -0
- package/lib/commonjs/components/MainComponents/RoomStack.js.map +1 -0
- package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +6 -1
- package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
- package/lib/commonjs/components/Modals/Modal/Modal.js +24 -0
- package/lib/commonjs/components/Modals/Modal/Modal.js.map +1 -1
- package/lib/commonjs/components/Modals/Modal/SwipeBackLayer.js +146 -0
- package/lib/commonjs/components/Modals/Modal/SwipeBackLayer.js.map +1 -0
- package/lib/commonjs/components/Modals/ProfileHeader/ProfileHeader.js +28 -10
- package/lib/commonjs/components/Modals/ProfileHeader/ProfileHeader.js.map +1 -1
- package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js +9 -3
- package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
- package/lib/commonjs/helpers/keyboardLayout.js +23 -1
- package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
- package/lib/module/components/Header/HeaderRoomList.js +12 -7
- package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
- package/lib/module/components/MainComponents/ChatRoom.js +24 -4
- package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/module/components/MainComponents/ChatWrapper.js +41 -10
- package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
- package/lib/module/components/MainComponents/KeyboardInputDock.js +33 -0
- package/lib/module/components/MainComponents/KeyboardInputDock.js.map +1 -0
- package/lib/module/components/MainComponents/RoomStack.js +198 -0
- package/lib/module/components/MainComponents/RoomStack.js.map +1 -0
- package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +6 -1
- package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
- package/lib/module/components/Modals/Modal/Modal.js +25 -1
- package/lib/module/components/Modals/Modal/Modal.js.map +1 -1
- package/lib/module/components/Modals/Modal/SwipeBackLayer.js +140 -0
- package/lib/module/components/Modals/Modal/SwipeBackLayer.js.map +1 -0
- package/lib/module/components/Modals/ProfileHeader/ProfileHeader.js +29 -11
- package/lib/module/components/Modals/ProfileHeader/ProfileHeader.js.map +1 -1
- package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js +10 -4
- package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
- package/lib/module/helpers/keyboardLayout.js +20 -0
- package/lib/module/helpers/keyboardLayout.js.map +1 -1
- package/lib/typescript/components/Header/HeaderRoomList.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/KeyboardInputDock.d.ts +16 -0
- package/lib/typescript/components/MainComponents/KeyboardInputDock.d.ts.map +1 -0
- package/lib/typescript/components/MainComponents/RoomStack.d.ts +37 -0
- package/lib/typescript/components/MainComponents/RoomStack.d.ts.map +1 -0
- package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
- package/lib/typescript/components/Modals/Modal/Modal.d.ts.map +1 -1
- package/lib/typescript/components/Modals/Modal/SwipeBackLayer.d.ts +15 -0
- package/lib/typescript/components/Modals/Modal/SwipeBackLayer.d.ts.map +1 -0
- package/lib/typescript/components/Modals/ProfileHeader/ProfileHeader.d.ts.map +1 -1
- package/lib/typescript/components/Modals/UserProfileModal/UserProfileModal.d.ts.map +1 -1
- package/lib/typescript/helpers/keyboardLayout.d.ts +6 -0
- package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Header/HeaderRoomList.tsx +7 -5
- package/src/components/MainComponents/ChatRoom.tsx +29 -3
- package/src/components/MainComponents/ChatWrapper.tsx +46 -12
- package/src/components/MainComponents/KeyboardInputDock.tsx +47 -0
- package/src/components/MainComponents/RoomStack.tsx +236 -0
- package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +4 -0
- package/src/components/Modals/Modal/Modal.tsx +32 -1
- package/src/components/Modals/Modal/SwipeBackLayer.tsx +170 -0
- package/src/components/Modals/ProfileHeader/ProfileHeader.tsx +29 -8
- package/src/components/Modals/UserProfileModal/UserProfileModal.tsx +6 -3
- package/src/helpers/keyboardLayout.ts +26 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react';
|
|
2
|
+
interface SwipeBackLayerProps {
|
|
3
|
+
/** Called once the screen has left (swipe, back button, hardware back). */
|
|
4
|
+
onClose: () => void;
|
|
5
|
+
/** Receives the animated close, to hand to the screen's own back button. */
|
|
6
|
+
children: (close: () => void) => ReactNode;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Full-screen modal layer with the same navigation feel as RoomStack: it
|
|
10
|
+
* slides in from the right, and a swipe from the left edge drags it back
|
|
11
|
+
* out over whatever is underneath (the room or the list).
|
|
12
|
+
*/
|
|
13
|
+
export declare const SwipeBackLayer: React.FC<SwipeBackLayerProps>;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=SwipeBackLayer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SwipeBackLayer.d.ts","sourceRoot":"","sources":["../../../../../src/components/Modals/Modal/SwipeBackLayer.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,SAAS,EAAkC,MAAM,OAAO,CAAC;AAyBzE,UAAU,mBAAmB;IAC3B,2EAA2E;IAC3E,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,IAAI,KAAK,SAAS,CAAC;CAC5C;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC,mBAAmB,CAqGxD,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProfileHeader.d.ts","sourceRoot":"","sources":["../../../../../src/components/Modals/ProfileHeader/ProfileHeader.tsx"],"names":[],"mappings":"AAAA,cAAc;AAEd,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EACL,QAAQ,
|
|
1
|
+
{"version":3,"file":"ProfileHeader.d.ts","sourceRoot":"","sources":["../../../../../src/components/Modals/ProfileHeader/ProfileHeader.tsx"],"names":[],"mappings":"AAAA,cAAc;AAEd,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EACL,QAAQ,EAGR,SAAS,EAGT,SAAS,EAGV,MAAM,cAAc,CAAC;AAKtB,yCAAyC;AACzC,eAAO,MAAM,WAAW,MAAM,CAAC;AAC/B,2DAA2D;AAC3D,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,KAAK,CAAC;AAC/B,qEAAqE;AACrE,eAAO,MAAM,YAAY,QAAiC,CAAC;AA+C3D,wEAAwE;AACxE,eAAO,MAAM,aAAa,eAAmB,CAAC;AAyC9C,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,KAAK,CAAC,SAAS,CAAC;IACzC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,WAAW;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,4EAA4E;IAC5E,aAAa,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,QAAQ,CAAC,KAAK,CAAC;IACxB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB;;;IAMzB,uEAAuE;;CAG1E,CAAC;AAYF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAChC,WAAW,GAAG;IACZ,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,sEAAsE;IACtE,cAAc,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACjC,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CACtC,CAsLF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAClC,WAAW,GAAG;IACZ,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CACnC,CA0GF,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"UserProfileModal.d.ts","sourceRoot":"","sources":["../../../../../src/components/Modals/UserProfileModal/UserProfileModal.tsx"],"names":[],"mappings":"AAAA,cAAc;AAEd,OAAO,KAA4D,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"UserProfileModal.d.ts","sourceRoot":"","sources":["../../../../../src/components/Modals/UserProfileModal/UserProfileModal.tsx"],"names":[],"mappings":"AAAA,cAAc;AAEd,OAAO,KAA4D,MAAM,OAAO,CAAC;AA+DjF,KAAK,MAAM,GAAG,UAAU,GAAG,OAAO,GAAG,WAAW,CAAC;AAEjD,4DAA4D;AAC5D,eAAO,MAAM,YAAY,GAAI,OAAO,MAAM,GAAG,IAAI,KAAG,MAOnD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM;IAChC,YAAY,EAAE,OAAO,CAAC;IACtB,iBAAiB,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,OAAO,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;CACvB,KAAG,MAAM,EAOT,CAAC;AAEF,UAAU,qBAAqB;IAC7B,gBAAgB,EAAE,GAAG,CAAC;CACvB;AAED,QAAA,MAAM,gBAAgB,EAAE,KAAK,CAAC,EAAE,CAAC,qBAAqB,CAifrD,CAAC;AAqIF,eAAe,gBAAgB,CAAC"}
|
|
@@ -5,7 +5,13 @@ type KeyboardLayoutArgs = {
|
|
|
5
5
|
keyboardVisible?: boolean;
|
|
6
6
|
};
|
|
7
7
|
export declare const ANDROID_INPUT_DOCK_GAP = 12;
|
|
8
|
+
export declare const IOS_KEYBOARD_DOCK_GAP = 4;
|
|
8
9
|
export declare const getKeyboardVerticalOffset: ({ platform, configuredOffset, bottomInset, }: KeyboardLayoutArgs) => number;
|
|
10
|
+
export declare const getKeyboardAvoidingOffset: ({ configuredOffset, }: Pick<KeyboardLayoutArgs, "configuredOffset">) => number;
|
|
11
|
+
export declare const getInputDockKeyboardPadding: ({ platform, inputDockPaddingBottom, }: {
|
|
12
|
+
platform: string;
|
|
13
|
+
inputDockPaddingBottom: number;
|
|
14
|
+
}) => number;
|
|
9
15
|
type InputDockPaddingArgs = Omit<KeyboardLayoutArgs, 'configuredOffset'> & {
|
|
10
16
|
configuredPadding?: number;
|
|
11
17
|
hostOwnsLayout?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"keyboardLayout.d.ts","sourceRoot":"","sources":["../../../src/helpers/keyboardLayout.ts"],"names":[],"mappings":"AAAA,KAAK,kBAAkB,GAAG;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,eAAO,MAAM,sBAAsB,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"keyboardLayout.d.ts","sourceRoot":"","sources":["../../../src/helpers/keyboardLayout.ts"],"names":[],"mappings":"AAAA,KAAK,kBAAkB,GAAG;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAGzC,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC,eAAO,MAAM,yBAAyB,GAAI,8CAIvC,kBAAkB,KAAG,MACmC,CAAC;AAO5D,eAAO,MAAM,yBAAyB,GAAI,uBAEvC,IAAI,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,KAAG,MAA0B,CAAC;AAK7E,eAAO,MAAM,2BAA2B,GAAI,uCAGzC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,sBAAsB,EAAE,MAAM,CAAC;CAChC,KAAG,MAGwB,CAAC;AAE7B,KAAK,oBAAoB,GAAG,IAAI,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,GAAG;IAIzE,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAK3B,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAI,gFAMvC,oBAAoB,KAAG,MAQzB,CAAC"}
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import { BurgerMenuIcon } from '../../assets/icons';
|
|
|
9
9
|
import Button from '../styled/Button';
|
|
10
10
|
import { useTheme } from '../../hooks/useTheme';
|
|
11
11
|
import { useT } from '../../i18n/useT';
|
|
12
|
+
import { resolveHeaderHeight } from '../../helpers/headerLayout';
|
|
12
13
|
|
|
13
14
|
interface HeaderRoomListProps {
|
|
14
15
|
setDrawerOpen: () => void;
|
|
@@ -20,6 +21,9 @@ export const HeaderRoomList: FC<HeaderRoomListProps> = ({ setDrawerOpen }) => {
|
|
|
20
21
|
const t = useT();
|
|
21
22
|
const insets = useSafeAreaInsets();
|
|
22
23
|
const topInset = config?.headerLayout?.safeAreaTop ? insets.top : 0;
|
|
24
|
+
// Same fixed band as ChatHeader / modal headers: the safe-area inset is
|
|
25
|
+
// padded on top and the row (buttons + title) centers in the band below it.
|
|
26
|
+
const headerHeight = resolveHeaderHeight(config?.headerLayout?.height) + topInset;
|
|
23
27
|
|
|
24
28
|
const modalUser: any = selectedUser ?? user;
|
|
25
29
|
|
|
@@ -45,7 +49,7 @@ export const HeaderRoomList: FC<HeaderRoomListProps> = ({ setDrawerOpen }) => {
|
|
|
45
49
|
style={[
|
|
46
50
|
styles.headerContainer,
|
|
47
51
|
{ backgroundColor: theme.surface, shadowColor: theme.shadow },
|
|
48
|
-
|
|
52
|
+
{ height: headerHeight, paddingTop: topInset },
|
|
49
53
|
]}
|
|
50
54
|
>
|
|
51
55
|
{!config?.disableRoomMenu && config?.headerMenu ? (
|
|
@@ -66,7 +70,7 @@ export const HeaderRoomList: FC<HeaderRoomListProps> = ({ setDrawerOpen }) => {
|
|
|
66
70
|
) : (
|
|
67
71
|
<View style={styles.leftContainer} />
|
|
68
72
|
)}
|
|
69
|
-
<View style={styles.centerContainer}>
|
|
73
|
+
<View style={[styles.centerContainer, { top: topInset }]}>
|
|
70
74
|
{config?.headerLogo ? (
|
|
71
75
|
HeaderLogo
|
|
72
76
|
) : (
|
|
@@ -95,9 +99,6 @@ const styles = StyleSheet.create({
|
|
|
95
99
|
flexDirection: 'row',
|
|
96
100
|
alignItems: 'center',
|
|
97
101
|
paddingHorizontal: 16,
|
|
98
|
-
paddingTop: 16,
|
|
99
|
-
paddingBottom: 12,
|
|
100
|
-
minHeight: 56,
|
|
101
102
|
justifyContent: 'space-between',
|
|
102
103
|
borderBottomLeftRadius: 20,
|
|
103
104
|
borderBottomRightRadius: 20,
|
|
@@ -121,6 +122,7 @@ const styles = StyleSheet.create({
|
|
|
121
122
|
position: 'absolute',
|
|
122
123
|
left: 0,
|
|
123
124
|
right: 0,
|
|
125
|
+
bottom: 0,
|
|
124
126
|
alignItems: 'center',
|
|
125
127
|
justifyContent: 'center',
|
|
126
128
|
},
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
KeyboardAvoidingView,
|
|
49
49
|
KeyboardStickyView,
|
|
50
50
|
} from 'react-native-keyboard-controller';
|
|
51
|
+
import { KeyboardInputDock } from './KeyboardInputDock';
|
|
51
52
|
import useComposing from '../../hooks/useComposing';
|
|
52
53
|
import { store } from '../../roomStore';
|
|
53
54
|
import {
|
|
@@ -58,6 +59,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
58
59
|
import {
|
|
59
60
|
getInputDockPaddingBottom,
|
|
60
61
|
getKeyboardVerticalOffset,
|
|
62
|
+
getKeyboardAvoidingOffset,
|
|
63
|
+
getInputDockKeyboardPadding,
|
|
61
64
|
} from '../../helpers/keyboardLayout';
|
|
62
65
|
|
|
63
66
|
interface ChatRoomProps {
|
|
@@ -357,6 +360,13 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
|
|
|
357
360
|
configuredPadding: configWithEventHandlers?.inputDockPaddingBottom,
|
|
358
361
|
hostOwnsLayout: !!configWithEventHandlers?.disableKeyboardAvoidingView,
|
|
359
362
|
});
|
|
363
|
+
const keyboardAvoidingOffset = getKeyboardAvoidingOffset({
|
|
364
|
+
configuredOffset: configWithEventHandlers?.keyboardVerticalOffset ?? 0,
|
|
365
|
+
});
|
|
366
|
+
const inputDockKeyboardPadding = getInputDockKeyboardPadding({
|
|
367
|
+
platform: Platform.OS,
|
|
368
|
+
inputDockPaddingBottom,
|
|
369
|
+
});
|
|
360
370
|
|
|
361
371
|
// Keyboard avoidance is delegated to react-native-keyboard-controller's
|
|
362
372
|
// KeyboardAvoidingView. When the HOST app supplies its own keyboard
|
|
@@ -394,16 +404,26 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
|
|
|
394
404
|
: View;
|
|
395
405
|
const keyboardWrapperProps = avoidKeyboard
|
|
396
406
|
? {
|
|
397
|
-
|
|
407
|
+
// The dock's colour: the keyboard-height padding below the dock is
|
|
408
|
+
// this view's own box, and it shows around the keyboard's rounded
|
|
409
|
+
// top corners — it must read as a continuation of the dock.
|
|
410
|
+
style: { flex: 1, backgroundColor: theme.surface },
|
|
398
411
|
behavior: 'padding' as const,
|
|
399
|
-
keyboardVerticalOffset,
|
|
412
|
+
keyboardVerticalOffset: keyboardAvoidingOffset,
|
|
400
413
|
}
|
|
401
414
|
: { style: { flex: 1 } };
|
|
402
415
|
// Input dock: a plain View normally; under the sticky strategy it becomes
|
|
403
416
|
// a KeyboardStickyView so it (and only it) lifts with the keyboard.
|
|
417
|
+
// Under the avoiding-view strategy the dock's safe-area padding collapses
|
|
418
|
+
// while the keyboard is open (KeyboardInputDock), so the composer is glued
|
|
419
|
+
// to the keyboard.
|
|
420
|
+
const collapsingDock =
|
|
421
|
+
avoidKeyboard && inputDockKeyboardPadding !== inputDockPaddingBottom;
|
|
404
422
|
const InputDockTag: React.ComponentType<any> = stickyInput
|
|
405
423
|
? KeyboardStickyView
|
|
406
|
-
:
|
|
424
|
+
: collapsingDock
|
|
425
|
+
? KeyboardInputDock
|
|
426
|
+
: View;
|
|
407
427
|
const inputDockProps: any = {
|
|
408
428
|
// The dock carries the composer's white surface all the way to the
|
|
409
429
|
// bottom edge, so it needs the same rounded top and upward shadow —
|
|
@@ -422,6 +442,12 @@ const ChatRoom: React.FC<ChatRoomProps> = React.memo(
|
|
|
422
442
|
...(stickyInput
|
|
423
443
|
? { offset: { closed: 0, opened: keyboardVerticalOffset } }
|
|
424
444
|
: {}),
|
|
445
|
+
...(collapsingDock
|
|
446
|
+
? {
|
|
447
|
+
closedPadding: inputDockPaddingBottom,
|
|
448
|
+
openedPadding: inputDockKeyboardPadding,
|
|
449
|
+
}
|
|
450
|
+
: {}),
|
|
425
451
|
};
|
|
426
452
|
|
|
427
453
|
return (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
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
|
+
import {RoomStack, RoomStackHandle} from './RoomStack';
|
|
4
5
|
import {
|
|
5
6
|
setActiveModal,
|
|
6
7
|
setConfig,
|
|
@@ -37,7 +38,7 @@ import {ModalWrapper} from '../Modals/ModalWrapper/ModalWrapper';
|
|
|
37
38
|
import {useChatSettingState} from '../../hooks/useChatSettingState';
|
|
38
39
|
import {useTheme} from '../../hooks/useTheme';
|
|
39
40
|
import { usePendingNotification } from '../../hooks/usePendingNotification';
|
|
40
|
-
import {DeviceEventEmitter, Pressable, Text, View} from 'react-native';
|
|
41
|
+
import {DeviceEventEmitter, Keyboard, Pressable, Text, View} from 'react-native';
|
|
41
42
|
import {pushLog as devPushLog} from '../../utils/devLogger';
|
|
42
43
|
import {normalizeRoomJid} from '../../helpers/normalizeRoomJid';
|
|
43
44
|
import {buildSeedRoom} from '../../helpers/buildSeedRoom';
|
|
@@ -70,6 +71,7 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
|
|
|
70
71
|
|
|
71
72
|
usePendingNotification();
|
|
72
73
|
const theme = useTheme();
|
|
74
|
+
const roomStackRef = useRef<RoomStackHandle>(null);
|
|
73
75
|
|
|
74
76
|
const [isInited, setInited] = useState(false);
|
|
75
77
|
const [showModal, setShowModal] = useState(false);
|
|
@@ -123,6 +125,14 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
|
|
|
123
125
|
dispatch(setDeleteModal({isDeleteModal: false}));
|
|
124
126
|
};
|
|
125
127
|
|
|
128
|
+
// A modal (chat profile, user profile, ...) covers the chat, so the
|
|
129
|
+
// composer's keyboard must not stay open on top of it.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (activeModal) {
|
|
132
|
+
Keyboard.dismiss();
|
|
133
|
+
}
|
|
134
|
+
}, [activeModal]);
|
|
135
|
+
|
|
126
136
|
// A host drives the reader's language from OUTSIDE the component through
|
|
127
137
|
// `config.translates.readerLocale` (their own switcher, or the testbed's
|
|
128
138
|
// Setup tab). Nothing was syncing it into `langSource`, so pinning it only
|
|
@@ -425,6 +435,12 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
|
|
|
425
435
|
// the list. With `roomJID` or `disableRooms`, skip the list entirely.
|
|
426
436
|
const showRoomList =
|
|
427
437
|
!config?.disableRooms && !roomJID && !activeRoomJID;
|
|
438
|
+
// List mode: the room is pushed over the list and can be swiped back
|
|
439
|
+
// (RoomStack). With `roomJID` / `disableRooms` there is no list to return to.
|
|
440
|
+
const listMode = !config?.disableRooms && !roomJID;
|
|
441
|
+
const backToList = () => {
|
|
442
|
+
dispatch(setCurrentRoom({ roomJID: '' }));
|
|
443
|
+
};
|
|
428
444
|
|
|
429
445
|
return (
|
|
430
446
|
<>
|
|
@@ -489,10 +505,35 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
|
|
|
489
505
|
Android. Wraps only the chat area — the global Modal /
|
|
490
506
|
ModalWrapper below stay above it. */}
|
|
491
507
|
<InteractionsOverlayProvider>
|
|
492
|
-
{
|
|
493
|
-
<
|
|
494
|
-
|
|
495
|
-
|
|
508
|
+
{listMode ? (
|
|
509
|
+
<RoomStack
|
|
510
|
+
ref={roomStackRef}
|
|
511
|
+
onBack={backToList}
|
|
512
|
+
roomBackground={theme.chatBackground}
|
|
513
|
+
// An open modal covers the room: back must not pull the
|
|
514
|
+
// room out from under it.
|
|
515
|
+
hardwareBack={!activeModal}
|
|
516
|
+
list={
|
|
517
|
+
<RoomList
|
|
518
|
+
chats={Object.values(rooms)}
|
|
519
|
+
onRoomClick={handleChangeChat}
|
|
520
|
+
/>
|
|
521
|
+
}
|
|
522
|
+
room={
|
|
523
|
+
showRoomList ? null : (
|
|
524
|
+
<ChatWrapperBox
|
|
525
|
+
style={{
|
|
526
|
+
...MainComponentStyles,
|
|
527
|
+
}}>
|
|
528
|
+
<ChatRoom
|
|
529
|
+
CustomMessageComponent={
|
|
530
|
+
CustomMessageComponent || Message
|
|
531
|
+
}
|
|
532
|
+
handleBackClick={() => roomStackRef.current?.pop()}
|
|
533
|
+
/>
|
|
534
|
+
</ChatWrapperBox>
|
|
535
|
+
)
|
|
536
|
+
}
|
|
496
537
|
/>
|
|
497
538
|
) : (
|
|
498
539
|
<ChatWrapperBox
|
|
@@ -501,13 +542,6 @@ const ChatWrapper: FC<ChatWrapperProps> = ({
|
|
|
501
542
|
}}>
|
|
502
543
|
<ChatRoom
|
|
503
544
|
CustomMessageComponent={CustomMessageComponent || Message}
|
|
504
|
-
handleBackClick={
|
|
505
|
-
roomJID || config?.disableRooms
|
|
506
|
-
? undefined
|
|
507
|
-
: () => {
|
|
508
|
-
dispatch(setCurrentRoom({ roomJID: '' }));
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
545
|
/>
|
|
512
546
|
</ChatWrapperBox>
|
|
513
547
|
)}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React, { FC } from 'react';
|
|
2
|
+
import { ViewProps } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
interpolate,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
} from 'react-native-reanimated';
|
|
7
|
+
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller';
|
|
8
|
+
|
|
9
|
+
interface KeyboardInputDockProps extends ViewProps {
|
|
10
|
+
// Bottom padding while the keyboard is closed (the safe-area inset).
|
|
11
|
+
closedPadding: number;
|
|
12
|
+
// Bottom padding while the keyboard is open.
|
|
13
|
+
openedPadding: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Input dock for the KeyboardAvoidingView strategy on iOS. The dock's
|
|
18
|
+
* safe-area padding is only needed while the keyboard is closed; with the
|
|
19
|
+
* keyboard open it collapses in sync with the keyboard animation, so the
|
|
20
|
+
* composer sits right on top of the keyboard instead of leaving a band of
|
|
21
|
+
* dock (or chat background) between them.
|
|
22
|
+
*/
|
|
23
|
+
export const KeyboardInputDock: FC<KeyboardInputDockProps> = ({
|
|
24
|
+
closedPadding,
|
|
25
|
+
openedPadding,
|
|
26
|
+
style,
|
|
27
|
+
children,
|
|
28
|
+
...rest
|
|
29
|
+
}) => {
|
|
30
|
+
const { progress } = useReanimatedKeyboardAnimation();
|
|
31
|
+
const animatedStyle = useAnimatedStyle(
|
|
32
|
+
() => ({
|
|
33
|
+
paddingBottom: interpolate(
|
|
34
|
+
progress.value,
|
|
35
|
+
[0, 1],
|
|
36
|
+
[closedPadding, openedPadding]
|
|
37
|
+
),
|
|
38
|
+
}),
|
|
39
|
+
[closedPadding, openedPadding]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Animated.View {...rest} style={[style, animatedStyle]}>
|
|
44
|
+
{children}
|
|
45
|
+
</Animated.View>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useRef,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import {
|
|
10
|
+
BackHandler,
|
|
11
|
+
Keyboard,
|
|
12
|
+
LayoutChangeEvent,
|
|
13
|
+
StyleSheet,
|
|
14
|
+
useWindowDimensions,
|
|
15
|
+
View,
|
|
16
|
+
} from 'react-native';
|
|
17
|
+
import Animated, {
|
|
18
|
+
Easing,
|
|
19
|
+
interpolate,
|
|
20
|
+
runOnJS,
|
|
21
|
+
useAnimatedStyle,
|
|
22
|
+
useSharedValue,
|
|
23
|
+
withTiming,
|
|
24
|
+
} from 'react-native-reanimated';
|
|
25
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
26
|
+
|
|
27
|
+
// Width of the left-edge strip that starts the back swipe.
|
|
28
|
+
export const EDGE_WIDTH = 28;
|
|
29
|
+
// Released past this share of the width (or faster than the velocity
|
|
30
|
+
// below), the swipe completes; otherwise the room settles back.
|
|
31
|
+
export const COMPLETE_DISTANCE = 0.35;
|
|
32
|
+
export const COMPLETE_VELOCITY = 600;
|
|
33
|
+
// How far the list underneath is shifted left while fully covered.
|
|
34
|
+
const LIST_PARALLAX = 0.3;
|
|
35
|
+
export const LIST_DIM = 0.25;
|
|
36
|
+
|
|
37
|
+
export const PUSH = { duration: 320, easing: Easing.bezier(0.2, 0.8, 0.2, 1) };
|
|
38
|
+
export const POP = { duration: 240, easing: Easing.out(Easing.cubic) };
|
|
39
|
+
|
|
40
|
+
export interface RoomStackHandle {
|
|
41
|
+
/** Slide the room out, then call `onBack`. */
|
|
42
|
+
pop: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RoomStackProps {
|
|
46
|
+
/** The room list — stays mounted under the room so it is there to reveal. */
|
|
47
|
+
list: ReactNode;
|
|
48
|
+
/** The open room, or null while only the list is shown. */
|
|
49
|
+
room: ReactNode | null;
|
|
50
|
+
/** Called once the room has left the screen (swipe, back button, pop()). */
|
|
51
|
+
onBack: () => void;
|
|
52
|
+
/** Background behind the room's content. */
|
|
53
|
+
roomBackground?: string;
|
|
54
|
+
/** Android hardware back / system back gesture returns to the list. */
|
|
55
|
+
hardwareBack?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Two-layer stack for the list → room navigation: the room slides in over
|
|
60
|
+
* the list, and a swipe from the left edge drags it back out, following the
|
|
61
|
+
* finger, with the list parallaxing in underneath (the iOS navigation feel).
|
|
62
|
+
*/
|
|
63
|
+
export const RoomStack = forwardRef<RoomStackHandle, RoomStackProps>(
|
|
64
|
+
({ list, room, onBack, roomBackground, hardwareBack = true }, ref) => {
|
|
65
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
66
|
+
const width = useSharedValue(windowWidth);
|
|
67
|
+
const hasRoom = !!room;
|
|
68
|
+
// Room's horizontal offset: 0 = covering the list, `width` = off-screen.
|
|
69
|
+
// A room already open on the first render is shown in place.
|
|
70
|
+
const x = useSharedValue(hasRoom ? 0 : windowWidth);
|
|
71
|
+
const closing = useSharedValue(false);
|
|
72
|
+
|
|
73
|
+
const isFirstRender = useRef(true);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (isFirstRender.current) {
|
|
76
|
+
isFirstRender.current = false;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
closing.value = false;
|
|
80
|
+
if (hasRoom) {
|
|
81
|
+
x.value = width.value;
|
|
82
|
+
x.value = withTiming(0, PUSH);
|
|
83
|
+
} else {
|
|
84
|
+
x.value = width.value;
|
|
85
|
+
}
|
|
86
|
+
}, [hasRoom]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
87
|
+
|
|
88
|
+
const onBackRef = useRef(onBack);
|
|
89
|
+
onBackRef.current = onBack;
|
|
90
|
+
const finish = useCallback(() => onBackRef.current(), []);
|
|
91
|
+
|
|
92
|
+
const pop = useCallback(() => {
|
|
93
|
+
if (closing.value) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
closing.value = true;
|
|
97
|
+
Keyboard.dismiss();
|
|
98
|
+
x.value = withTiming(width.value, POP, finished => {
|
|
99
|
+
if (finished) {
|
|
100
|
+
runOnJS(finish)();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}, [finish]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
104
|
+
|
|
105
|
+
useImperativeHandle(ref, () => ({ pop }), [pop]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!hasRoom || !hardwareBack) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
112
|
+
pop();
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
return () => sub.remove();
|
|
116
|
+
}, [hasRoom, hardwareBack, pop]);
|
|
117
|
+
|
|
118
|
+
const dismissKeyboard = useCallback(() => Keyboard.dismiss(), []);
|
|
119
|
+
|
|
120
|
+
const swipeBack = Gesture.Pan()
|
|
121
|
+
.hitSlop({ left: 0, width: EDGE_WIDTH })
|
|
122
|
+
.activeOffsetX(12)
|
|
123
|
+
.failOffsetY([-16, 16])
|
|
124
|
+
.onStart(() => {
|
|
125
|
+
runOnJS(dismissKeyboard)();
|
|
126
|
+
})
|
|
127
|
+
.onUpdate(e => {
|
|
128
|
+
if (closing.value) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
x.value = Math.min(Math.max(e.translationX, 0), width.value);
|
|
132
|
+
})
|
|
133
|
+
.onEnd(e => {
|
|
134
|
+
if (closing.value) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const complete =
|
|
138
|
+
x.value > width.value * COMPLETE_DISTANCE ||
|
|
139
|
+
e.velocityX > COMPLETE_VELOCITY;
|
|
140
|
+
if (complete) {
|
|
141
|
+
closing.value = true;
|
|
142
|
+
x.value = withTiming(width.value, POP, finished => {
|
|
143
|
+
if (finished) {
|
|
144
|
+
runOnJS(finish)();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
x.value = withTiming(0, POP);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
|
153
|
+
const next = e.nativeEvent.layout.width;
|
|
154
|
+
if (next > 0 && next !== width.value) {
|
|
155
|
+
// Keep an off-screen room off-screen when the container resizes.
|
|
156
|
+
if (x.value === width.value) {
|
|
157
|
+
x.value = next;
|
|
158
|
+
}
|
|
159
|
+
width.value = next;
|
|
160
|
+
}
|
|
161
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
162
|
+
|
|
163
|
+
const roomStyle = useAnimatedStyle(() => ({
|
|
164
|
+
transform: [{ translateX: x.value }],
|
|
165
|
+
}));
|
|
166
|
+
// 1 while the room fully covers the list, 0 once it is gone.
|
|
167
|
+
const listStyle = useAnimatedStyle(() => {
|
|
168
|
+
const covered = 1 - Math.min(Math.max(x.value / width.value, 0), 1);
|
|
169
|
+
return {
|
|
170
|
+
transform: [{ translateX: -width.value * LIST_PARALLAX * covered }],
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
const dimStyle = useAnimatedStyle(() => ({
|
|
174
|
+
opacity: interpolate(x.value, [0, width.value], [LIST_DIM, 0]),
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<View style={styles.container} onLayout={onLayout}>
|
|
179
|
+
<Animated.View
|
|
180
|
+
style={[styles.layer, listStyle]}
|
|
181
|
+
pointerEvents={hasRoom ? 'none' : 'auto'}
|
|
182
|
+
accessibilityElementsHidden={hasRoom}
|
|
183
|
+
importantForAccessibility={hasRoom ? 'no-hide-descendants' : 'auto'}
|
|
184
|
+
>
|
|
185
|
+
{list}
|
|
186
|
+
</Animated.View>
|
|
187
|
+
{hasRoom && (
|
|
188
|
+
<>
|
|
189
|
+
<Animated.View
|
|
190
|
+
pointerEvents="none"
|
|
191
|
+
style={[styles.layer, styles.dim, dimStyle]}
|
|
192
|
+
/>
|
|
193
|
+
<GestureDetector gesture={swipeBack}>
|
|
194
|
+
<Animated.View
|
|
195
|
+
style={[
|
|
196
|
+
styles.layer,
|
|
197
|
+
styles.room,
|
|
198
|
+
{ backgroundColor: roomBackground },
|
|
199
|
+
roomStyle,
|
|
200
|
+
]}
|
|
201
|
+
>
|
|
202
|
+
{room}
|
|
203
|
+
</Animated.View>
|
|
204
|
+
</GestureDetector>
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
</View>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
RoomStack.displayName = 'RoomStack';
|
|
213
|
+
|
|
214
|
+
const styles = StyleSheet.create({
|
|
215
|
+
container: {
|
|
216
|
+
flex: 1,
|
|
217
|
+
width: '100%',
|
|
218
|
+
overflow: 'hidden',
|
|
219
|
+
},
|
|
220
|
+
layer: {
|
|
221
|
+
position: 'absolute',
|
|
222
|
+
top: 0,
|
|
223
|
+
right: 0,
|
|
224
|
+
bottom: 0,
|
|
225
|
+
left: 0,
|
|
226
|
+
},
|
|
227
|
+
dim: {
|
|
228
|
+
backgroundColor: '#000',
|
|
229
|
+
},
|
|
230
|
+
room: {
|
|
231
|
+
shadowColor: '#000',
|
|
232
|
+
shadowOffset: { width: -4, height: 0 },
|
|
233
|
+
shadowOpacity: 0.18,
|
|
234
|
+
shadowRadius: 12,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
@@ -510,6 +510,10 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
|
|
|
510
510
|
]}
|
|
511
511
|
onLayout={(e) => setViewportHeight(e.nativeEvent.layout.height)}
|
|
512
512
|
showsVerticalScrollIndicator={false}
|
|
513
|
+
// Magnet: a header released part-way settles fully open or fully
|
|
514
|
+
// collapsed; past the collapsed point the content scrolls freely.
|
|
515
|
+
snapToOffsets={[0, collapseDistance]}
|
|
516
|
+
snapToEnd={false}
|
|
513
517
|
keyboardShouldPersistTaps="handled"
|
|
514
518
|
scrollEventThrottle={16}
|
|
515
519
|
onScroll={Animated.event(
|
|
@@ -2,9 +2,13 @@ import React from 'react';
|
|
|
2
2
|
import { ModalBackground } from '../styledModalComponents';
|
|
3
3
|
import { ModalType } from '../../../types/types';
|
|
4
4
|
import { useDispatch } from 'react-redux';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
setActiveModal,
|
|
7
|
+
setSelectedUser,
|
|
8
|
+
} from '../../../roomStore/chatSettingsSlice';
|
|
6
9
|
import { MODAL_TYPES } from '../../../helpers/constants/MODAL_TYPES';
|
|
7
10
|
import { MODAL_COMPONENTS } from '../../../helpers/constants/MODAL_COMPONENTS';
|
|
11
|
+
import { SwipeBackLayer } from './SwipeBackLayer';
|
|
8
12
|
|
|
9
13
|
interface ModalProps {
|
|
10
14
|
children?: React.ReactNode;
|
|
@@ -44,6 +48,33 @@ const Modal: React.FC<ModalProps> = ({ children, modal, setOpenModal }) => {
|
|
|
44
48
|
return <>{renderModalContent()}{children}</>;
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
// The profile screens are full-screen pages rather than dialogs: they
|
|
52
|
+
// slide in and can be swiped back from the left edge, like a room. One
|
|
53
|
+
// layer serves both, so going chat profile → member profile swaps the
|
|
54
|
+
// page in place instead of replaying the entrance.
|
|
55
|
+
if (modal === MODAL_TYPES.PROFILE || modal === MODAL_TYPES.CHAT_PROFILE) {
|
|
56
|
+
const ProfileComponent = MODAL_COMPONENTS[modal];
|
|
57
|
+
if (ProfileComponent) {
|
|
58
|
+
return (
|
|
59
|
+
<SwipeBackLayer
|
|
60
|
+
onClose={() => {
|
|
61
|
+
// The viewed member is cleared only once the page is gone, so
|
|
62
|
+
// it doesn't flip to the user's own profile mid-slide.
|
|
63
|
+
dispatch(setSelectedUser(undefined));
|
|
64
|
+
setOpenModal();
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{(close) => (
|
|
68
|
+
<>
|
|
69
|
+
<ProfileComponent handleCloseModal={close} />
|
|
70
|
+
{children}
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
</SwipeBackLayer>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
return (
|
|
48
79
|
modal && (
|
|
49
80
|
<ModalBackground
|