@ethora/chat-component-rn 26.8.3 → 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.
Files changed (131) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/commonjs/components/Header/HeaderRoomList.js +12 -7
  3. package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
  4. package/lib/commonjs/components/MainComponents/ChatRoom.js +23 -3
  5. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  6. package/lib/commonjs/components/MainComponents/ChatWrapper.js +40 -9
  7. package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
  8. package/lib/commonjs/components/MainComponents/KeyboardInputDock.js +40 -0
  9. package/lib/commonjs/components/MainComponents/KeyboardInputDock.js.map +1 -0
  10. package/lib/commonjs/components/MainComponents/ReduxWrapper.js +43 -6
  11. package/lib/commonjs/components/MainComponents/ReduxWrapper.js.map +1 -1
  12. package/lib/commonjs/components/MainComponents/RoomStack.js +202 -0
  13. package/lib/commonjs/components/MainComponents/RoomStack.js.map +1 -0
  14. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +6 -1
  15. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  16. package/lib/commonjs/components/Modals/Modal/Modal.js +24 -0
  17. package/lib/commonjs/components/Modals/Modal/Modal.js.map +1 -1
  18. package/lib/commonjs/components/Modals/Modal/SwipeBackLayer.js +146 -0
  19. package/lib/commonjs/components/Modals/Modal/SwipeBackLayer.js.map +1 -0
  20. package/lib/commonjs/components/Modals/ProfileHeader/ProfileHeader.js +28 -10
  21. package/lib/commonjs/components/Modals/ProfileHeader/ProfileHeader.js.map +1 -1
  22. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js +9 -3
  23. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  24. package/lib/commonjs/components/Modals/UserSettingsModal/UserSettingsModal.js +136 -3
  25. package/lib/commonjs/components/Modals/UserSettingsModal/UserSettingsModal.js.map +1 -1
  26. package/lib/commonjs/helpers/keyboardLayout.js +23 -1
  27. package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
  28. package/lib/commonjs/helpers/preferencesStorage.js +44 -0
  29. package/lib/commonjs/helpers/preferencesStorage.js.map +1 -0
  30. package/lib/commonjs/i18n/strings.js +42 -0
  31. package/lib/commonjs/i18n/strings.js.map +1 -1
  32. package/lib/commonjs/main.js +13 -0
  33. package/lib/commonjs/main.js.map +1 -1
  34. package/lib/commonjs/roomStore/chatSettingsSlice.js +17 -4
  35. package/lib/commonjs/roomStore/chatSettingsSlice.js.map +1 -1
  36. package/lib/commonjs/services/pushRegistration.js +13 -2
  37. package/lib/commonjs/services/pushRegistration.js.map +1 -1
  38. package/lib/commonjs/theme/theme.js +28 -4
  39. package/lib/commonjs/theme/theme.js.map +1 -1
  40. package/lib/commonjs/version.js +1 -1
  41. package/lib/module/components/Header/HeaderRoomList.js +12 -7
  42. package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
  43. package/lib/module/components/MainComponents/ChatRoom.js +24 -4
  44. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  45. package/lib/module/components/MainComponents/ChatWrapper.js +41 -10
  46. package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
  47. package/lib/module/components/MainComponents/KeyboardInputDock.js +33 -0
  48. package/lib/module/components/MainComponents/KeyboardInputDock.js.map +1 -0
  49. package/lib/module/components/MainComponents/ReduxWrapper.js +47 -10
  50. package/lib/module/components/MainComponents/ReduxWrapper.js.map +1 -1
  51. package/lib/module/components/MainComponents/RoomStack.js +198 -0
  52. package/lib/module/components/MainComponents/RoomStack.js.map +1 -0
  53. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +6 -1
  54. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  55. package/lib/module/components/Modals/Modal/Modal.js +25 -1
  56. package/lib/module/components/Modals/Modal/Modal.js.map +1 -1
  57. package/lib/module/components/Modals/Modal/SwipeBackLayer.js +140 -0
  58. package/lib/module/components/Modals/Modal/SwipeBackLayer.js.map +1 -0
  59. package/lib/module/components/Modals/ProfileHeader/ProfileHeader.js +29 -11
  60. package/lib/module/components/Modals/ProfileHeader/ProfileHeader.js.map +1 -1
  61. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js +10 -4
  62. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  63. package/lib/module/components/Modals/UserSettingsModal/UserSettingsModal.js +139 -6
  64. package/lib/module/components/Modals/UserSettingsModal/UserSettingsModal.js.map +1 -1
  65. package/lib/module/helpers/keyboardLayout.js +20 -0
  66. package/lib/module/helpers/keyboardLayout.js.map +1 -1
  67. package/lib/module/helpers/preferencesStorage.js +36 -0
  68. package/lib/module/helpers/preferencesStorage.js.map +1 -0
  69. package/lib/module/i18n/strings.js +42 -0
  70. package/lib/module/i18n/strings.js.map +1 -1
  71. package/lib/module/main.js +1 -0
  72. package/lib/module/main.js.map +1 -1
  73. package/lib/module/roomStore/chatSettingsSlice.js +14 -3
  74. package/lib/module/roomStore/chatSettingsSlice.js.map +1 -1
  75. package/lib/module/services/pushRegistration.js +13 -2
  76. package/lib/module/services/pushRegistration.js.map +1 -1
  77. package/lib/module/theme/theme.js +28 -3
  78. package/lib/module/theme/theme.js.map +1 -1
  79. package/lib/module/version.js +1 -1
  80. package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
  81. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  82. package/lib/typescript/components/MainComponents/ChatWrapper.d.ts.map +1 -1
  83. package/lib/typescript/components/MainComponents/KeyboardInputDock.d.ts +16 -0
  84. package/lib/typescript/components/MainComponents/KeyboardInputDock.d.ts.map +1 -0
  85. package/lib/typescript/components/MainComponents/ReduxWrapper.d.ts.map +1 -1
  86. package/lib/typescript/components/MainComponents/RoomStack.d.ts +37 -0
  87. package/lib/typescript/components/MainComponents/RoomStack.d.ts.map +1 -0
  88. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  89. package/lib/typescript/components/Modals/Modal/Modal.d.ts.map +1 -1
  90. package/lib/typescript/components/Modals/Modal/SwipeBackLayer.d.ts +15 -0
  91. package/lib/typescript/components/Modals/Modal/SwipeBackLayer.d.ts.map +1 -0
  92. package/lib/typescript/components/Modals/ProfileHeader/ProfileHeader.d.ts.map +1 -1
  93. package/lib/typescript/components/Modals/UserProfileModal/UserProfileModal.d.ts.map +1 -1
  94. package/lib/typescript/components/Modals/UserSettingsModal/UserSettingsModal.d.ts.map +1 -1
  95. package/lib/typescript/helpers/keyboardLayout.d.ts +6 -0
  96. package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
  97. package/lib/typescript/helpers/preferencesStorage.d.ts +11 -0
  98. package/lib/typescript/helpers/preferencesStorage.d.ts.map +1 -0
  99. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  100. package/lib/typescript/main.d.ts +2 -0
  101. package/lib/typescript/main.d.ts.map +1 -1
  102. package/lib/typescript/roomStore/chatSettingsSlice.d.ts +12 -1
  103. package/lib/typescript/roomStore/chatSettingsSlice.d.ts.map +1 -1
  104. package/lib/typescript/services/pushRegistration.d.ts.map +1 -1
  105. package/lib/typescript/theme/theme.d.ts +17 -3
  106. package/lib/typescript/theme/theme.d.ts.map +1 -1
  107. package/lib/typescript/types/types.d.ts +23 -1
  108. package/lib/typescript/types/types.d.ts.map +1 -1
  109. package/lib/typescript/version.d.ts +1 -1
  110. package/package.json +2 -2
  111. package/src/components/Header/HeaderRoomList.tsx +7 -5
  112. package/src/components/MainComponents/ChatRoom.tsx +29 -3
  113. package/src/components/MainComponents/ChatWrapper.tsx +46 -12
  114. package/src/components/MainComponents/KeyboardInputDock.tsx +47 -0
  115. package/src/components/MainComponents/ReduxWrapper.tsx +52 -11
  116. package/src/components/MainComponents/RoomStack.tsx +236 -0
  117. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +4 -0
  118. package/src/components/Modals/Modal/Modal.tsx +32 -1
  119. package/src/components/Modals/Modal/SwipeBackLayer.tsx +170 -0
  120. package/src/components/Modals/ProfileHeader/ProfileHeader.tsx +29 -8
  121. package/src/components/Modals/UserProfileModal/UserProfileModal.tsx +6 -3
  122. package/src/components/Modals/UserSettingsModal/UserSettingsModal.tsx +157 -4
  123. package/src/helpers/keyboardLayout.ts +26 -0
  124. package/src/helpers/preferencesStorage.ts +40 -0
  125. package/src/i18n/strings.ts +42 -0
  126. package/src/main.ts +2 -0
  127. package/src/roomStore/chatSettingsSlice.ts +24 -2
  128. package/src/services/pushRegistration.ts +15 -2
  129. package/src/theme/theme.ts +39 -5
  130. package/src/types/types.ts +24 -1
  131. package/src/version.ts +1 -1
@@ -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
- {showRoomList ? (
493
- <RoomList
494
- chats={Object.values(rooms)}
495
- onRoomClick={handleChangeChat}
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
+ };
@@ -1,11 +1,18 @@
1
- import React, {useMemo} from 'react';
2
- import {Provider, useDispatch} from 'react-redux';
1
+ import React, {useEffect, useMemo, useRef} from 'react';
2
+ import {useColorScheme} from 'react-native';
3
+ import {Provider, useDispatch, useSelector} from 'react-redux';
3
4
  import {KeyboardProvider} from 'react-native-keyboard-controller';
4
5
  import {SafeAreaProvider} from 'react-native-safe-area-context';
5
6
  import {ThemeProvider} from 'styled-components/native';
6
- import {resolveTheme} from '../../theme/theme';
7
+ import {applyThemePreference, resolveTheme} from '../../theme/theme';
7
8
  import {store} from '../../roomStore';
8
- import {setConfig} from '../../roomStore/chatSettingsSlice';
9
+ import type {RootState} from '../../roomStore';
10
+ import {
11
+ setConfig,
12
+ setPushEnabled,
13
+ setThemePreference,
14
+ } from '../../roomStore/chatSettingsSlice';
15
+ import {loadPreferences} from '../../helpers/preferencesStorage';
9
16
  import {ConfigUser, IConfig, MessageProps} from '../../types/types';
10
17
  import {XmppProvider} from '../../context/xmppProvider';
11
18
  import {MessageNotificationProvider} from '../../context/MessageNotificationContext';
@@ -53,6 +60,42 @@ const ConfigEnabler: React.FC<{config?: IConfig}> = ({config}) => {
53
60
  return null;
54
61
  };
55
62
 
63
+ const ChatThemeProvider: React.FC<{config?: IConfig; children: React.ReactNode}> = ({
64
+ config,
65
+ children,
66
+ }) => {
67
+ const dispatch = useDispatch();
68
+ const storeConfig = useSelector((s: RootState) => s.chatSettingStore.config);
69
+ const preference = useSelector(
70
+ (s: RootState) => s.chatSettingStore.themePreference,
71
+ );
72
+ const systemDark = useColorScheme() === 'dark';
73
+ const effective = applyThemePreference(storeConfig ?? config, preference);
74
+ const theme = useMemo(
75
+ () => resolveTheme(effective, systemDark),
76
+ [effective, systemDark],
77
+ );
78
+
79
+ const hydrated = useRef(false);
80
+ useEffect(() => {
81
+ if (hydrated.current) {return;}
82
+ hydrated.current = true;
83
+ loadPreferences().then((prefs) => {
84
+ if (prefs.theme) {dispatch(setThemePreference(prefs.theme));}
85
+ if (typeof prefs.pushEnabled === 'boolean') {
86
+ dispatch(setPushEnabled(prefs.pushEnabled));
87
+ }
88
+ });
89
+ }, [dispatch]);
90
+
91
+ const onThemeChange = config?.eventHandlers?.onThemeChange;
92
+ useEffect(() => {
93
+ if (preference && onThemeChange) {onThemeChange(preference, theme.dark);}
94
+ }, [preference, theme.dark, onThemeChange]);
95
+
96
+ return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
97
+ };
98
+
56
99
  export const ReduxWrapper: React.FC<ChatWrapperProps> = React.memo(
57
100
  ({...props}) => {
58
101
  const memoizedConfig = useMemo(() => {
@@ -70,13 +113,11 @@ export const ReduxWrapper: React.FC<ChatWrapperProps> = React.memo(
70
113
  // KeyboardAvoidingView under the same flag).
71
114
  const ownKeyboardHandling = !memoizedConfig?.disableKeyboardAvoidingView;
72
115
 
73
- // Light/dark palette (config.dark + config.darkColors). Styled
74
- // components read it via `({ theme }) => theme.surface`; plain RN
75
- // styles via the `useTheme()` hook.
76
- const theme = useMemo(() => resolveTheme(memoizedConfig), [memoizedConfig]);
77
-
116
+ // Light/dark palette (config.dark + config.darkColors + the Settings
117
+ // choice). Styled components read it via `({ theme }) => theme.surface`;
118
+ // plain RN styles via the `useTheme()` hook.
78
119
  const tree = (
79
- <ThemeProvider theme={theme}>
120
+ <ChatThemeProvider config={memoizedConfig}>
80
121
  <XmppProvider config={memoizedConfig} isVisible={props.isVisible}>
81
122
  <ToastProvider>
82
123
  <MessageNotificationProvider config={memoizedConfig}>
@@ -84,7 +125,7 @@ export const ReduxWrapper: React.FC<ChatWrapperProps> = React.memo(
84
125
  </MessageNotificationProvider>
85
126
  </ToastProvider>
86
127
  </XmppProvider>
87
- </ThemeProvider>
128
+ </ChatThemeProvider>
88
129
  );
89
130
 
90
131
  return (
@@ -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 { setActiveModal } from '../../../roomStore/chatSettingsSlice';
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