@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
@@ -0,0 +1,170 @@
1
+ import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
2
+ import {
3
+ BackHandler,
4
+ Keyboard,
5
+ StyleSheet,
6
+ useWindowDimensions,
7
+ View,
8
+ } from 'react-native';
9
+ import Animated, {
10
+ interpolate,
11
+ runOnJS,
12
+ useAnimatedStyle,
13
+ useSharedValue,
14
+ withTiming,
15
+ } from 'react-native-reanimated';
16
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
17
+ import {
18
+ COMPLETE_DISTANCE,
19
+ COMPLETE_VELOCITY,
20
+ EDGE_WIDTH,
21
+ LIST_DIM,
22
+ POP,
23
+ PUSH,
24
+ } from '../../MainComponents/RoomStack';
25
+
26
+ interface SwipeBackLayerProps {
27
+ /** Called once the screen has left (swipe, back button, hardware back). */
28
+ onClose: () => void;
29
+ /** Receives the animated close, to hand to the screen's own back button. */
30
+ children: (close: () => void) => ReactNode;
31
+ }
32
+
33
+ /**
34
+ * Full-screen modal layer with the same navigation feel as RoomStack: it
35
+ * slides in from the right, and a swipe from the left edge drags it back
36
+ * out over whatever is underneath (the room or the list).
37
+ */
38
+ export const SwipeBackLayer: React.FC<SwipeBackLayerProps> = ({
39
+ onClose,
40
+ children,
41
+ }) => {
42
+ const { width: windowWidth } = useWindowDimensions();
43
+ const width = useSharedValue(windowWidth);
44
+ const x = useSharedValue(windowWidth);
45
+ const closing = useSharedValue(false);
46
+
47
+ useEffect(() => {
48
+ x.value = withTiming(0, PUSH);
49
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
50
+
51
+ const onCloseRef = useRef(onClose);
52
+ onCloseRef.current = onClose;
53
+ const finish = useCallback(() => onCloseRef.current(), []);
54
+
55
+ const close = useCallback(() => {
56
+ if (closing.value) {
57
+ return;
58
+ }
59
+ closing.value = true;
60
+ Keyboard.dismiss();
61
+ x.value = withTiming(width.value, POP, finished => {
62
+ if (finished) {
63
+ runOnJS(finish)();
64
+ }
65
+ });
66
+ }, [finish]); // eslint-disable-line react-hooks/exhaustive-deps
67
+
68
+ useEffect(() => {
69
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
70
+ close();
71
+ return true;
72
+ });
73
+ return () => sub.remove();
74
+ }, [close]);
75
+
76
+ const dismissKeyboard = useCallback(() => Keyboard.dismiss(), []);
77
+
78
+ const swipeBack = Gesture.Pan()
79
+ .hitSlop({ left: 0, width: EDGE_WIDTH })
80
+ .activeOffsetX(12)
81
+ .failOffsetY([-16, 16])
82
+ .onStart(() => {
83
+ runOnJS(dismissKeyboard)();
84
+ })
85
+ .onUpdate(e => {
86
+ if (closing.value) {
87
+ return;
88
+ }
89
+ x.value = Math.min(Math.max(e.translationX, 0), width.value);
90
+ })
91
+ .onEnd(e => {
92
+ if (closing.value) {
93
+ return;
94
+ }
95
+ const complete =
96
+ x.value > width.value * COMPLETE_DISTANCE ||
97
+ e.velocityX > COMPLETE_VELOCITY;
98
+ if (complete) {
99
+ closing.value = true;
100
+ x.value = withTiming(width.value, POP, finished => {
101
+ if (finished) {
102
+ runOnJS(finish)();
103
+ }
104
+ });
105
+ } else {
106
+ x.value = withTiming(0, POP);
107
+ }
108
+ });
109
+
110
+ const screenStyle = useAnimatedStyle(() => ({
111
+ transform: [{ translateX: x.value }],
112
+ }));
113
+ const dimStyle = useAnimatedStyle(() => ({
114
+ opacity: interpolate(x.value, [0, width.value], [LIST_DIM, 0]),
115
+ }));
116
+
117
+ return (
118
+ <View
119
+ pointerEvents="box-none"
120
+ style={styles.host}
121
+ onLayout={e => {
122
+ const next = e.nativeEvent.layout.width;
123
+ if (next > 0) {
124
+ width.value = next;
125
+ }
126
+ }}
127
+ >
128
+ <Animated.View
129
+ pointerEvents="none"
130
+ style={[styles.fill, styles.dim, dimStyle]}
131
+ />
132
+ <GestureDetector gesture={swipeBack}>
133
+ <Animated.View style={[styles.fill, styles.screen, screenStyle]}>
134
+ {children(close)}
135
+ </Animated.View>
136
+ </GestureDetector>
137
+ </View>
138
+ );
139
+ };
140
+
141
+ const styles = StyleSheet.create({
142
+ host: {
143
+ position: 'absolute',
144
+ top: 0,
145
+ right: 0,
146
+ bottom: 0,
147
+ left: 0,
148
+ zIndex: 1000,
149
+ overflow: 'hidden',
150
+ },
151
+ fill: {
152
+ position: 'absolute',
153
+ top: 0,
154
+ right: 0,
155
+ bottom: 0,
156
+ left: 0,
157
+ },
158
+ dim: {
159
+ backgroundColor: '#000',
160
+ },
161
+ // Same box the shared ModalBackground gives its content.
162
+ screen: {
163
+ justifyContent: 'center',
164
+ alignItems: 'center',
165
+ shadowColor: '#000',
166
+ shadowOffset: { width: -4, height: 0 },
167
+ shadowOpacity: 0.18,
168
+ shadowRadius: 12,
169
+ },
170
+ });
@@ -4,6 +4,7 @@ import React from 'react';
4
4
  import {
5
5
  Animated,
6
6
  Image,
7
+ Platform,
7
8
  StyleProp,
8
9
  StyleSheet,
9
10
  Text,
@@ -39,7 +40,13 @@ const COLLAPSE_RAMP = 48;
39
40
  */
40
41
  const BLUR_INTENSITY = 92;
41
42
  /** Darkening applied on top of the blur (and used alone without it). */
42
- const FROST_DIM = 'rgba(0,0,0,0.28)';
43
+ const FROST_DIM = 'rgba(0,0,0,0.5)';
44
+ /**
45
+ * Blur of the picture itself when expo-blur is not in the binary: RN's own
46
+ * `Image.blurRadius`, so the collapsed header is still frosted rather than
47
+ * just dimmed. Android caps the radius at 25.
48
+ */
49
+ const IMAGE_BLUR_RADIUS = Platform.OS === 'android' ? 25 : 40;
43
50
  /** Height of the readability gradient under the name and buttons. */
44
51
  const SCRIM_HEIGHT = 220;
45
52
  /** Bands the gradient is built from — enough that no step is visible. */
@@ -249,12 +256,25 @@ export const ProfileHero: React.FC<
249
256
  ]}
250
257
  >
251
258
  {onPhoto ? (
252
- <Image
253
- testID={`${testIDPrefix}-hero-image`}
254
- source={{ uri: imageUri as string }}
255
- style={StyleSheet.absoluteFill}
256
- resizeMode="cover"
257
- />
259
+ <>
260
+ <Image
261
+ testID={`${testIDPrefix}-hero-image`}
262
+ source={{ uri: imageUri as string }}
263
+ style={StyleSheet.absoluteFill}
264
+ resizeMode="cover"
265
+ />
266
+ {/* Without expo-blur the frost is a blurred copy of the same
267
+ * picture, inside the same transform so the two line up as
268
+ * one fades into the other. */}
269
+ {!BlurView && (
270
+ <Animated.Image
271
+ source={{ uri: imageUri as string }}
272
+ style={[StyleSheet.absoluteFill, { opacity: collapsed }]}
273
+ resizeMode="cover"
274
+ blurRadius={IMAGE_BLUR_RADIUS}
275
+ />
276
+ )}
277
+ </>
258
278
  ) : (
259
279
  <View style={styles.initialsWrap}>
260
280
  <Text
@@ -395,6 +415,7 @@ export const ProfileTopBar: React.FC<
395
415
  source={{ uri: imageUri as string }}
396
416
  style={StyleSheet.absoluteFill}
397
417
  resizeMode="cover"
418
+ blurRadius={BlurView ? 0 : IMAGE_BLUR_RADIUS}
398
419
  />
399
420
  <FrostLayer />
400
421
  </>
@@ -499,7 +520,7 @@ const styles = StyleSheet.create({
499
520
  backgroundColor: FROST_DIM,
500
521
  },
501
522
  frostFallback: {
502
- backgroundColor: 'rgba(0,0,0,0.35)',
523
+ backgroundColor: FROST_DIM,
503
524
  },
504
525
  // Only over a photo: keeps white text readable without tinting the flat
505
526
  // fallback colour.
@@ -39,7 +39,6 @@ import {
39
39
  setActiveFile,
40
40
  setActiveModal,
41
41
  setLangSource,
42
- setSelectedUser,
43
42
  } from '../../../roomStore/chatSettingsSlice';
44
43
  import { addRoomViaApi, setCurrentRoom } from '../../../roomStore/roomsSlice';
45
44
  import { runLogoutFlow } from '../../Menu/HeaderRoomListMenu';
@@ -183,10 +182,10 @@ const UserProfileModal: React.FC<UserProfileModalProps> = ({
183
182
  };
184
183
  }, [isOwnProfile, user?.token]);
185
184
 
185
+ // The modal host clears `selectedUser` once the page has slid away.
186
186
  const handleBackClick = useCallback(() => {
187
- dispatch(setSelectedUser(undefined));
188
187
  handleCloseModal();
189
- }, [dispatch, handleCloseModal]);
188
+ }, [handleCloseModal]);
190
189
 
191
190
  // Same teardown the room-list menu runs (XMPP, redux, persisted slices,
192
191
  // AsyncStorage), including the host's confirm copy and callbacks.
@@ -473,6 +472,10 @@ const UserProfileModal: React.FC<UserProfileModalProps> = ({
473
472
  ]}
474
473
  onLayout={(e) => setViewportHeight(e.nativeEvent.layout.height)}
475
474
  showsVerticalScrollIndicator={false}
475
+ // Magnet: a header released part-way settles fully open or fully
476
+ // collapsed; past the collapsed point the content scrolls freely.
477
+ snapToOffsets={[0, collapseDistance]}
478
+ snapToEnd={false}
476
479
  scrollEventThrottle={16}
477
480
  onScroll={Animated.event(
478
481
  [{ nativeEvent: { contentOffset: { y: scrollY } } }],
@@ -1,23 +1,37 @@
1
1
  /** @format */
2
2
 
3
3
  import React, { useCallback, useMemo } from 'react';
4
- import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
5
- import { useDispatch } from 'react-redux';
4
+ import {
5
+ StyleSheet,
6
+ Switch,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ } from 'react-native';
11
+ import { useDispatch, useSelector } from 'react-redux';
6
12
  import { ModalContainerFullScreen } from '../styledModalComponents';
7
13
  import ModalHeaderComponent from '../ModalHeaderComponent';
8
14
  import { ArowDownIcon } from '../../../assets/icons';
9
- import { setActiveModal } from '../../../roomStore/chatSettingsSlice';
15
+ import {
16
+ setActiveModal,
17
+ setPushEnabled,
18
+ setThemePreference,
19
+ } from '../../../roomStore/chatSettingsSlice';
20
+ import type { RootState } from '../../../roomStore';
10
21
  import { MODAL_TYPES } from '../../../helpers/constants/MODAL_TYPES';
11
22
  import { useChatSettingState } from '../../../hooks/useChatSettingState';
12
23
  import { chatTextStyle } from '../../../helpers/typography';
24
+ import { savePreferences } from '../../../helpers/preferencesStorage';
13
25
  import { useT } from '../../../i18n/useT';
14
26
  import { useTheme } from '../../../hooks/useTheme';
15
- import type { ChatTheme } from '../../../theme/theme';
27
+ import type { ChatTheme, ThemePreference } from '../../../theme/theme';
16
28
 
17
29
  interface UserSettingsModalProps {
18
30
  handleCloseModal: any;
19
31
  }
20
32
 
33
+ const APPEARANCES: ThemePreference[] = ['light', 'dark', 'system'];
34
+
21
35
  const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
22
36
  handleCloseModal,
23
37
  }) => {
@@ -27,6 +41,28 @@ const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
27
41
  const theme = useTheme();
28
42
  const styles = useMemo(() => createStyles(theme), [theme]);
29
43
 
44
+ const themePreference = useSelector(
45
+ (s: RootState) => s.chatSettingStore.themePreference
46
+ );
47
+ const pushEnabled = useSelector(
48
+ (s: RootState) => s.chatSettingStore.pushEnabled
49
+ );
50
+
51
+ // What the picker shows before the user ever touched it: the host's
52
+ // config.dark, so the selected segment matches what is on screen.
53
+ const appearance: ThemePreference =
54
+ themePreference ??
55
+ (config?.dark === 'system' ? 'system' : config?.dark ? 'dark' : 'light');
56
+
57
+ const appearanceLabels: Record<ThemePreference, string> = useMemo(
58
+ () => ({
59
+ light: t('settings.appearance.light'),
60
+ dark: t('settings.appearance.dark'),
61
+ system: t('settings.appearance.system'),
62
+ }),
63
+ [t]
64
+ );
65
+
30
66
  const options = useMemo(
31
67
  () => [
32
68
  { label: t('settings.manageData.title'), key: MODAL_TYPES.MANAGE_DATA },
@@ -42,6 +78,31 @@ const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
42
78
  [dispatch]
43
79
  );
44
80
 
81
+ const handleAppearance = useCallback(
82
+ (pref: ThemePreference) => {
83
+ dispatch(setThemePreference(pref));
84
+ savePreferences({ theme: pref });
85
+ },
86
+ [dispatch]
87
+ );
88
+
89
+ // The SDK acts on the flag itself (pushRegistration releases / restores
90
+ // the device-token registrations); the host is told so it can mirror
91
+ // the choice on any native push side it owns.
92
+ const onPushNotificationsToggle = config?.eventHandlers?.onPushNotificationsToggle;
93
+ const handlePush = useCallback(
94
+ (enabled: boolean) => {
95
+ dispatch(setPushEnabled(enabled));
96
+ savePreferences({ pushEnabled: enabled });
97
+ if (onPushNotificationsToggle) {
98
+ Promise.resolve(onPushNotificationsToggle(enabled)).catch((err) =>
99
+ console.warn('[settings] onPushNotificationsToggle failed', err)
100
+ );
101
+ }
102
+ },
103
+ [dispatch, onPushNotificationsToggle]
104
+ );
105
+
45
106
  return (
46
107
  <ModalContainerFullScreen style={styles.screen}>
47
108
  <ModalHeaderComponent
@@ -53,6 +114,51 @@ const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
53
114
  * hairline dividers — the rows are separate destinations, and the
54
115
  * design gives each its own surface. */}
55
116
  <View style={styles.body}>
117
+ {!config?.settings?.hideAppearance ? (
118
+ <View style={[styles.card, styles.stackCard]} testID="settings-appearance">
119
+ <Text style={styles.label}>{t('settings.appearance.title')}</Text>
120
+ <View style={styles.segmented} accessibilityRole="radiogroup">
121
+ {APPEARANCES.map((pref) => {
122
+ const selected = pref === appearance;
123
+ return (
124
+ <TouchableOpacity
125
+ key={pref}
126
+ testID={`settings-appearance-${pref}`}
127
+ accessibilityRole="radio"
128
+ accessibilityState={{ selected }}
129
+ activeOpacity={0.7}
130
+ style={[styles.segment, selected && styles.segmentSelected]}
131
+ onPress={() => handleAppearance(pref)}
132
+ >
133
+ <Text
134
+ style={[styles.segmentLabel, selected && styles.segmentLabelSelected]}
135
+ >
136
+ {appearanceLabels[pref]}
137
+ </Text>
138
+ </TouchableOpacity>
139
+ );
140
+ })}
141
+ </View>
142
+ </View>
143
+ ) : null}
144
+
145
+ {!config?.settings?.hidePushToggle ? (
146
+ <View style={styles.card} testID="settings-notifications">
147
+ <View style={styles.switchText}>
148
+ <Text style={styles.label}>{t('settings.notifications.push')}</Text>
149
+ <Text style={styles.hint}>{t('settings.notifications.pushHint')}</Text>
150
+ </View>
151
+ <Switch
152
+ testID="settings-push-switch"
153
+ value={pushEnabled !== false}
154
+ onValueChange={handlePush}
155
+ trackColor={{ true: theme.primary, false: theme.surfaceSecondary }}
156
+ thumbColor={theme.surface}
157
+ ios_backgroundColor={theme.surfaceSecondary}
158
+ />
159
+ </View>
160
+ ) : null}
161
+
56
162
  {options.map((option) => (
57
163
  <TouchableOpacity
58
164
  key={option.key}
@@ -98,11 +204,58 @@ const createStyles = (theme: ChatTheme) => StyleSheet.create({
98
204
  shadowRadius: 12,
99
205
  elevation: 3,
100
206
  },
207
+ // Label above the control instead of beside it: three segments do not
208
+ // fit next to a label on narrow phones.
209
+ stackCard: {
210
+ flexDirection: 'column',
211
+ alignItems: 'stretch',
212
+ gap: 14,
213
+ },
101
214
  label: {
102
215
  fontSize: 17,
103
216
  fontWeight: '500',
104
217
  color: theme.text,
105
218
  },
219
+ hint: {
220
+ marginTop: 4,
221
+ fontSize: 13,
222
+ lineHeight: 18,
223
+ color: theme.textSecondary,
224
+ },
225
+ switchText: {
226
+ flex: 1,
227
+ paddingRight: 16,
228
+ },
229
+ segmented: {
230
+ flexDirection: 'row',
231
+ backgroundColor: theme.surfaceSecondary,
232
+ borderRadius: 12,
233
+ padding: 3,
234
+ gap: 3,
235
+ },
236
+ segment: {
237
+ flex: 1,
238
+ alignItems: 'center',
239
+ justifyContent: 'center',
240
+ paddingVertical: 9,
241
+ borderRadius: 9,
242
+ },
243
+ segmentSelected: {
244
+ backgroundColor: theme.surface,
245
+ shadowColor: theme.shadow,
246
+ shadowOffset: { width: 0, height: 1 },
247
+ shadowOpacity: 0.08,
248
+ shadowRadius: 3,
249
+ elevation: 2,
250
+ },
251
+ segmentLabel: {
252
+ fontSize: 15,
253
+ fontWeight: '500',
254
+ color: theme.textSecondary,
255
+ },
256
+ segmentLabelSelected: {
257
+ color: theme.text,
258
+ },
106
259
  chevron: {
107
260
  transform: [{ rotate: '-90deg' }],
108
261
  },
@@ -6,6 +6,9 @@ type KeyboardLayoutArgs = {
6
6
  };
7
7
 
8
8
  export const ANDROID_INPUT_DOCK_GAP = 12;
9
+ // Bottom padding the dock keeps while the iOS keyboard is open, so the
10
+ // composer is glued to the keyboard without touching its edge.
11
+ export const IOS_KEYBOARD_DOCK_GAP = 4;
9
12
 
10
13
  export const getKeyboardVerticalOffset = ({
11
14
  platform,
@@ -14,6 +17,29 @@ export const getKeyboardVerticalOffset = ({
14
17
  }: KeyboardLayoutArgs): number =>
15
18
  configuredOffset + (platform === 'ios' ? bottomInset : 0);
16
19
 
20
+ // Offset for the KeyboardAvoidingView strategy. Unlike KeyboardStickyView
21
+ // (where a positive `opened` offset pushes the dock DOWN into the keyboard),
22
+ // a positive value here ADDS bottom padding above the keyboard — so the
23
+ // safe-area inset must not be added: the avoiding view already ends exactly
24
+ // at the keyboard's top edge.
25
+ export const getKeyboardAvoidingOffset = ({
26
+ configuredOffset = 0,
27
+ }: Pick<KeyboardLayoutArgs, 'configuredOffset'>): number => configuredOffset;
28
+
29
+ // Dock bottom padding while the keyboard is open (KeyboardAvoidingView
30
+ // strategy). On iOS the safe-area padding collapses to a small gap; Android
31
+ // keeps its fixed gap.
32
+ export const getInputDockKeyboardPadding = ({
33
+ platform,
34
+ inputDockPaddingBottom,
35
+ }: {
36
+ platform: string;
37
+ inputDockPaddingBottom: number;
38
+ }): number =>
39
+ platform === 'ios'
40
+ ? Math.min(inputDockPaddingBottom, IOS_KEYBOARD_DOCK_GAP)
41
+ : inputDockPaddingBottom;
42
+
17
43
  type InputDockPaddingArgs = Omit<KeyboardLayoutArgs, 'configuredOffset'> & {
18
44
  // Explicit `inputDockPaddingBottom` config value, verbatim on both
19
45
  // platforms when provided (0 allowed). Takes priority over every other
@@ -0,0 +1,40 @@
1
+ /** @format */
2
+
3
+ import AsyncStorage from '@react-native-async-storage/async-storage';
4
+ import type { ThemePreference } from '../theme/theme';
5
+
6
+ const KEY = '@ethora/preferences';
7
+
8
+ /** Small, unencrypted, per-device user preferences set from Settings. */
9
+ export interface StoredPreferences {
10
+ theme?: ThemePreference;
11
+ pushEnabled?: boolean;
12
+ }
13
+
14
+ export const loadPreferences = async (): Promise<StoredPreferences> => {
15
+ try {
16
+ const raw = await AsyncStorage.getItem(KEY);
17
+ return raw ? (JSON.parse(raw) as StoredPreferences) : {};
18
+ } catch {
19
+ return {};
20
+ }
21
+ };
22
+
23
+ export const savePreferences = async (
24
+ patch: StoredPreferences
25
+ ): Promise<void> => {
26
+ try {
27
+ const current = await loadPreferences();
28
+ await AsyncStorage.setItem(KEY, JSON.stringify({ ...current, ...patch }));
29
+ } catch (error) {
30
+ console.warn('[preferences] save failed', error);
31
+ }
32
+ };
33
+
34
+ export const clearPreferences = async (): Promise<void> => {
35
+ try {
36
+ await AsyncStorage.removeItem(KEY);
37
+ } catch {
38
+ /* ignore */
39
+ }
40
+ };
@@ -239,6 +239,13 @@ const en: UiStringTable = {
239
239
  'settings.blockedUsers.label': 'Blocked Users',
240
240
  'settings.blockedUsers.description': 'Users you have blocked cannot send you messages or see when you are online.',
241
241
  'settings.blockedUsers.emptyState': "You haven't blocked anyone yet.",
242
+ 'settings.appearance.title': 'Appearance',
243
+ 'settings.appearance.light': 'Light',
244
+ 'settings.appearance.dark': 'Dark',
245
+ 'settings.appearance.system': 'System',
246
+ 'settings.notifications.title': 'Notifications',
247
+ 'settings.notifications.push': 'Push notifications',
248
+ 'settings.notifications.pushHint': 'New messages and calls wake this device even when the app is closed.',
242
249
  'settings.visibility.title': 'Visibility',
243
250
  'settings.visibility.profileLabel': 'Profile Visibility',
244
251
  'settings.visibility.open': 'Open (default)',
@@ -490,6 +497,13 @@ const fr: UiStringTable = {
490
497
  'settings.blockedUsers.label': 'Utilisateurs bloqués',
491
498
  'settings.blockedUsers.description': "Les utilisateurs que vous avez bloqués ne peuvent pas vous envoyer de messages ni voir quand vous êtes en ligne.",
492
499
  'settings.blockedUsers.emptyState': "Vous n'avez encore bloqué personne.",
500
+ 'settings.appearance.title': 'Apparence',
501
+ 'settings.appearance.light': 'Clair',
502
+ 'settings.appearance.dark': 'Sombre',
503
+ 'settings.appearance.system': 'Système',
504
+ 'settings.notifications.title': 'Notifications',
505
+ 'settings.notifications.push': 'Notifications push',
506
+ 'settings.notifications.pushHint': 'Les nouveaux messages et appels réveillent cet appareil même quand l\'application est fermée.',
493
507
  'settings.visibility.title': 'Visibilité',
494
508
  'settings.visibility.profileLabel': 'Visibilité du profil',
495
509
  'settings.visibility.open': 'Ouvert (par défaut)',
@@ -741,6 +755,13 @@ const es: UiStringTable = {
741
755
  'settings.blockedUsers.label': 'Usuarios bloqueados',
742
756
  'settings.blockedUsers.description': 'Los usuarios que hayas bloqueado no podrán enviarte mensajes ni ver cuándo estás en línea.',
743
757
  'settings.blockedUsers.emptyState': 'Todavía no has bloqueado a nadie.',
758
+ 'settings.appearance.title': 'Apariencia',
759
+ 'settings.appearance.light': 'Claro',
760
+ 'settings.appearance.dark': 'Oscuro',
761
+ 'settings.appearance.system': 'Sistema',
762
+ 'settings.notifications.title': 'Notificaciones',
763
+ 'settings.notifications.push': 'Notificaciones push',
764
+ 'settings.notifications.pushHint': 'Los mensajes y llamadas nuevos despiertan este dispositivo incluso con la aplicación cerrada.',
744
765
  'settings.visibility.title': 'Visibilidad',
745
766
  'settings.visibility.profileLabel': 'Visibilidad del perfil',
746
767
  'settings.visibility.open': 'Abierto (predeterminado)',
@@ -992,6 +1013,13 @@ const pt: UiStringTable = {
992
1013
  'settings.blockedUsers.label': 'Utilizadores bloqueados',
993
1014
  'settings.blockedUsers.description': 'Os utilizadores que bloqueou não lhe podem enviar mensagens nem ver quando está online.',
994
1015
  'settings.blockedUsers.emptyState': 'Ainda não bloqueou ninguém.',
1016
+ 'settings.appearance.title': 'Aparência',
1017
+ 'settings.appearance.light': 'Claro',
1018
+ 'settings.appearance.dark': 'Escuro',
1019
+ 'settings.appearance.system': 'Sistema',
1020
+ 'settings.notifications.title': 'Notificações',
1021
+ 'settings.notifications.push': 'Notificações push',
1022
+ 'settings.notifications.pushHint': 'Novas mensagens e chamadas acordam este dispositivo mesmo com o aplicativo fechado.',
995
1023
  'settings.visibility.title': 'Visibilidade',
996
1024
  'settings.visibility.profileLabel': 'Visibilidade do perfil',
997
1025
  'settings.visibility.open': 'Aberto (predefinição)',
@@ -1243,6 +1271,13 @@ const ht: UiStringTable = {
1243
1271
  'settings.blockedUsers.label': 'Itilizatè bloke',
1244
1272
  'settings.blockedUsers.description': 'Itilizatè ou bloke yo pa ka voye mesaj ba ou ni wè lè ou an liy.',
1245
1273
  'settings.blockedUsers.emptyState': 'Ou poko bloke pèsonn.',
1274
+ 'settings.appearance.title': 'Aparans',
1275
+ 'settings.appearance.light': 'Klè',
1276
+ 'settings.appearance.dark': 'Fonse',
1277
+ 'settings.appearance.system': 'Sistèm',
1278
+ 'settings.notifications.title': 'Notifikasyon',
1279
+ 'settings.notifications.push': 'Notifikasyon push',
1280
+ 'settings.notifications.pushHint': 'Nouvo mesaj ak apèl reveye aparèy sa a menm lè aplikasyon an fèmen.',
1246
1281
  'settings.visibility.title': 'Vizibilite',
1247
1282
  'settings.visibility.profileLabel': 'Vizibilite pwofil la',
1248
1283
  'settings.visibility.open': 'Louvri (default)',
@@ -1494,6 +1529,13 @@ const zh: UiStringTable = {
1494
1529
  'settings.blockedUsers.label': '已屏蔽用户',
1495
1530
  'settings.blockedUsers.description': '被您屏蔽的用户无法向您发送消息,也无法看到您何时在线。',
1496
1531
  'settings.blockedUsers.emptyState': '您还没有屏蔽任何人。',
1532
+ 'settings.appearance.title': '外观',
1533
+ 'settings.appearance.light': '浅色',
1534
+ 'settings.appearance.dark': '深色',
1535
+ 'settings.appearance.system': '跟随系统',
1536
+ 'settings.notifications.title': '通知',
1537
+ 'settings.notifications.push': '推送通知',
1538
+ 'settings.notifications.pushHint': '即使应用已关闭,新消息和来电也会唤醒此设备。',
1497
1539
  'settings.visibility.title': '可见性',
1498
1540
  'settings.visibility.profileLabel': '资料可见性',
1499
1541
  'settings.visibility.open': '公开(默认)',
package/src/main.ts CHANGED
@@ -121,3 +121,5 @@ export {
121
121
  useTheme,
122
122
  } from './theme';
123
123
  export type { ChatTheme, ChatThemeColors, ChatThemeOverrides } from './theme';
124
+ export type { ThemePreference } from './theme/theme';
125
+ export { setThemePreference, setPushEnabled } from './roomStore/chatSettingsSlice';