@ethora/chat-component-rn 26.8.3 → 26.8.4

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 (61) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/commonjs/components/MainComponents/ReduxWrapper.js +43 -6
  3. package/lib/commonjs/components/MainComponents/ReduxWrapper.js.map +1 -1
  4. package/lib/commonjs/components/Modals/UserSettingsModal/UserSettingsModal.js +136 -3
  5. package/lib/commonjs/components/Modals/UserSettingsModal/UserSettingsModal.js.map +1 -1
  6. package/lib/commonjs/helpers/preferencesStorage.js +44 -0
  7. package/lib/commonjs/helpers/preferencesStorage.js.map +1 -0
  8. package/lib/commonjs/i18n/strings.js +42 -0
  9. package/lib/commonjs/i18n/strings.js.map +1 -1
  10. package/lib/commonjs/main.js +13 -0
  11. package/lib/commonjs/main.js.map +1 -1
  12. package/lib/commonjs/roomStore/chatSettingsSlice.js +17 -4
  13. package/lib/commonjs/roomStore/chatSettingsSlice.js.map +1 -1
  14. package/lib/commonjs/services/pushRegistration.js +13 -2
  15. package/lib/commonjs/services/pushRegistration.js.map +1 -1
  16. package/lib/commonjs/theme/theme.js +28 -4
  17. package/lib/commonjs/theme/theme.js.map +1 -1
  18. package/lib/commonjs/version.js +1 -1
  19. package/lib/module/components/MainComponents/ReduxWrapper.js +47 -10
  20. package/lib/module/components/MainComponents/ReduxWrapper.js.map +1 -1
  21. package/lib/module/components/Modals/UserSettingsModal/UserSettingsModal.js +139 -6
  22. package/lib/module/components/Modals/UserSettingsModal/UserSettingsModal.js.map +1 -1
  23. package/lib/module/helpers/preferencesStorage.js +36 -0
  24. package/lib/module/helpers/preferencesStorage.js.map +1 -0
  25. package/lib/module/i18n/strings.js +42 -0
  26. package/lib/module/i18n/strings.js.map +1 -1
  27. package/lib/module/main.js +1 -0
  28. package/lib/module/main.js.map +1 -1
  29. package/lib/module/roomStore/chatSettingsSlice.js +14 -3
  30. package/lib/module/roomStore/chatSettingsSlice.js.map +1 -1
  31. package/lib/module/services/pushRegistration.js +13 -2
  32. package/lib/module/services/pushRegistration.js.map +1 -1
  33. package/lib/module/theme/theme.js +28 -3
  34. package/lib/module/theme/theme.js.map +1 -1
  35. package/lib/module/version.js +1 -1
  36. package/lib/typescript/components/MainComponents/ReduxWrapper.d.ts.map +1 -1
  37. package/lib/typescript/components/Modals/UserSettingsModal/UserSettingsModal.d.ts.map +1 -1
  38. package/lib/typescript/helpers/preferencesStorage.d.ts +11 -0
  39. package/lib/typescript/helpers/preferencesStorage.d.ts.map +1 -0
  40. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  41. package/lib/typescript/main.d.ts +2 -0
  42. package/lib/typescript/main.d.ts.map +1 -1
  43. package/lib/typescript/roomStore/chatSettingsSlice.d.ts +12 -1
  44. package/lib/typescript/roomStore/chatSettingsSlice.d.ts.map +1 -1
  45. package/lib/typescript/services/pushRegistration.d.ts.map +1 -1
  46. package/lib/typescript/theme/theme.d.ts +17 -3
  47. package/lib/typescript/theme/theme.d.ts.map +1 -1
  48. package/lib/typescript/types/types.d.ts +23 -1
  49. package/lib/typescript/types/types.d.ts.map +1 -1
  50. package/lib/typescript/version.d.ts +1 -1
  51. package/package.json +2 -2
  52. package/src/components/MainComponents/ReduxWrapper.tsx +52 -11
  53. package/src/components/Modals/UserSettingsModal/UserSettingsModal.tsx +157 -4
  54. package/src/helpers/preferencesStorage.ts +40 -0
  55. package/src/i18n/strings.ts +42 -0
  56. package/src/main.ts +2 -0
  57. package/src/roomStore/chatSettingsSlice.ts +24 -2
  58. package/src/services/pushRegistration.ts +15 -2
  59. package/src/theme/theme.ts +39 -5
  60. package/src/types/types.ts +24 -1
  61. package/src/version.ts +1 -1
@@ -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 (
@@ -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
  },
@@ -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';
@@ -12,6 +12,7 @@ import {
12
12
  import { Iso639_1Codes } from '../types/models/language.model';
13
13
  import type { TranslateMode } from '../utils/translateModePolicy';
14
14
  import { secureUserStorage } from '../helpers/secureUserStorage';
15
+ import { applyThemePreference, type ThemePreference } from '../theme/theme';
15
16
 
16
17
  export interface ChatState {
17
18
  user: User;
@@ -29,6 +30,14 @@ export interface ChatState {
29
30
  * See utils/translateModePolicy.
30
31
  */
31
32
  translateMode?: TranslateMode;
33
+ /**
34
+ * Appearance picked in Settings (light / dark / system). Persisted per
35
+ * device; folded into `config.dark` by setConfig / setThemePreference so
36
+ * every consumer of `config` sees the effective value.
37
+ */
38
+ themePreference?: ThemePreference;
39
+ /** Push notifications toggle from Settings. `undefined` = on. */
40
+ pushEnabled?: boolean;
32
41
  }
33
42
 
34
43
  export const unpackAndTransform = (input?: User): User => {
@@ -126,11 +135,22 @@ const reducers = {
126
135
  };
127
136
  }
128
137
  },
138
+ setThemePreference: (
139
+ state: WritableDraft<ChatState>,
140
+ action: PayloadAction<ThemePreference | undefined>
141
+ ) => {
142
+ state.themePreference = action.payload;
143
+ state.config = applyThemePreference(state.config as IConfig | undefined, action.payload) as any;
144
+ },
145
+ setPushEnabled: (state: WritableDraft<ChatState>, action: PayloadAction<boolean>) => {
146
+ state.pushEnabled = action.payload;
147
+ },
129
148
  setConfig: (state: WritableDraft<ChatState>, action: PayloadAction<IConfig | undefined>) => {
130
149
  // Cast away immer's WritableDraft — IConfig contains callable
131
150
  // fields (eventHandlers, customComponent) that confuse the draft
132
- // type inference but are inert at write-time.
133
- state.config = action.payload as any;
151
+ // type inference but are inert at write-time. The user's in-app
152
+ // appearance choice survives a host re-dispatch.
153
+ state.config = applyThemePreference(action.payload, state.themePreference) as any;
134
154
  },
135
155
  setActiveModal: (state: WritableDraft<ChatState>, action: PayloadAction<ModalType | undefined>) => {
136
156
  state.activeModal = action.payload;
@@ -220,6 +240,8 @@ export const {
220
240
  setStoreClient,
221
241
  setLangSource,
222
242
  setTranslateMode,
243
+ setThemePreference,
244
+ setPushEnabled,
223
245
  } = chatSlice.actions;
224
246
 
225
247
  export default chatSlice.reducer;
@@ -70,7 +70,10 @@ const readSession = (): Session => {
70
70
  xmppUsername: String(user?.xmppUsername || ''),
71
71
  projectName: String(config?.projectName || '').trim(),
72
72
  gatewayUrl: String(config?.pushNotifications?.apiUrl || '').trim(),
73
- pushDisabled: config?.pushNotifications?.enabled === false,
73
+ // Off by config (host) or by the user's Settings toggle.
74
+ pushDisabled:
75
+ config?.pushNotifications?.enabled === false ||
76
+ slice?.pushEnabled === false,
74
77
  };
75
78
  };
76
79
 
@@ -233,7 +236,17 @@ const ensureWatching = (): void => {
233
236
  hookIdentity = null;
234
237
  return;
235
238
  }
236
- if (session.pushDisabled || !isReady(session)) {
239
+ if (session.pushDisabled) {
240
+ // Switched off (Settings toggle or config): take the device off the
241
+ // backend but keep the tokens, so switching back on re-registers
242
+ // without the host doing anything. `registered` empties at once,
243
+ // so later store updates do not repeat the release.
244
+ if (registered.size > 0) {
245
+ releasePushRegistration().catch(() => undefined);
246
+ }
247
+ return;
248
+ }
249
+ if (!isReady(session)) {
237
250
  return;
238
251
  }
239
252
  runTokenHook(session);
@@ -1,5 +1,7 @@
1
1
  /** @format */
2
2
 
3
+ import { Appearance } from 'react-native';
4
+
3
5
  /**
4
6
  * Chat theme.
5
7
  *
@@ -161,7 +163,10 @@ export const DARK_THEME: ChatThemeColors = {
161
163
  /** The subset of IConfig the theme depends on (kept structural so the
162
164
  * helpers can be called with partial configs in tests). */
163
165
  export interface ThemeConfigInput {
164
- dark?: boolean;
166
+ /** `true` forces dark, `'system'` follows the OS appearance (pass the
167
+ * current scheme as `systemDark` to resolveTheme / isDarkTheme; omitted,
168
+ * the OS scheme is read directly). */
169
+ dark?: boolean | 'system';
165
170
  darkColors?: ChatThemeOverrides;
166
171
  colors?: {
167
172
  primary?: string;
@@ -179,8 +184,16 @@ export interface ThemeConfigInput {
179
184
  backgroundChat?: { color?: string };
180
185
  }
181
186
 
182
- export const isDarkTheme = (config?: ThemeConfigInput | null): boolean =>
183
- !!config?.dark;
187
+ /** The OS appearance right now — the fallback when a caller has no hook. */
188
+ // Optional chaining on `Appearance` itself: tests that mock react-native
189
+ // partially leave it undefined, and a missing module must read as light.
190
+ const systemIsDark = (): boolean => Appearance?.getColorScheme?.() === 'dark';
191
+
192
+ export const isDarkTheme = (
193
+ config?: ThemeConfigInput | null,
194
+ systemDark: boolean = systemIsDark()
195
+ ): boolean =>
196
+ config?.dark === 'system' ? systemDark : config?.dark === true;
184
197
 
185
198
  const pick = <T>(...values: (T | undefined | null | '')[]): T | undefined =>
186
199
  values.find((v) => v !== undefined && v !== null && v !== '') as
@@ -196,8 +209,11 @@ const stripUndefined = (o: ChatThemeOverrides): ChatThemeOverrides =>
196
209
  * Build the effective palette for a config. Pure; memoize at the call
197
210
  * site (useTheme does).
198
211
  */
199
- export const resolveTheme = (config?: ThemeConfigInput | null): ChatTheme => {
200
- if (isDarkTheme(config)) {
212
+ export const resolveTheme = (
213
+ config?: ThemeConfigInput | null,
214
+ systemDark?: boolean
215
+ ): ChatTheme => {
216
+ if (isDarkTheme(config, systemDark)) {
201
217
  const overrides = stripUndefined(config?.darkColors ?? {});
202
218
  const primary = pick(overrides.primary, DARK_THEME.primary)!;
203
219
  return {
@@ -241,3 +257,21 @@ export const resolveTheme = (config?: ThemeConfigInput | null): ChatTheme => {
241
257
  statusBarStyle: 'dark-content',
242
258
  };
243
259
  };
260
+
261
+ /** In-app appearance choice from Settings; `undefined` = follow the host's config.dark. */
262
+ export type ThemePreference = 'light' | 'dark' | 'system';
263
+
264
+ /**
265
+ * Fold the user's in-app appearance choice into a config: the choice wins
266
+ * over the host's `dark` (a host that wants to lock the theme hides the
267
+ * setting with `settings.hideAppearance`). Returns the same object when
268
+ * nothing changes, so memoised consumers do not re-render.
269
+ */
270
+ export const applyThemePreference = <T extends { dark?: boolean | 'system' }>(
271
+ config: T | undefined,
272
+ preference?: ThemePreference
273
+ ): T | undefined => {
274
+ if (!preference || !config) {return config;}
275
+ const dark = preference === 'system' ? ('system' as const) : preference === 'dark';
276
+ return config.dark === dark ? config : { ...config, dark };
277
+ };
@@ -386,8 +386,10 @@ export interface IConfig {
386
386
  * not applied — pass brand colours via `darkColors` instead.
387
387
  *
388
388
  * config: { dark: true, darkColors: { primary: '#7C3AED', surface: '#111' } }
389
+ *
390
+ * `'system'` follows the device appearance (light/dark) automatically.
389
391
  */
390
- dark?: boolean;
392
+ dark?: boolean | 'system';
391
393
  /** Per-entry overrides of the dark palette; see ChatThemeColors. */
392
394
  darkColors?: ChatThemeOverrides;
393
395
  /** Configurable font family / weights for the chat UI. See TypographyConfig. */
@@ -787,8 +789,29 @@ export interface IConfig {
787
789
  firebaseConfig?: FBConfig;
788
790
  };
789
791
 
792
+ /** Settings screen switches. */
793
+ settings?: {
794
+ /** Hide the Light / Dark / System appearance picker (host locks the theme via `dark`). */
795
+ hideAppearance?: boolean;
796
+ /** Hide the push-notifications toggle. */
797
+ hidePushToggle?: boolean;
798
+ };
799
+
790
800
  // ----- event hooks -----
791
801
  eventHandlers?: {
802
+ /**
803
+ * Fired when the user picks an appearance in Settings (and once on
804
+ * mount with the persisted choice). `isDark` is the resolved value
805
+ * for 'system'. Hosts use it to sync their own chrome (status bar).
806
+ */
807
+ onThemeChange?: (preference: 'light' | 'dark' | 'system', isDark: boolean) => void;
808
+ /**
809
+ * Fired when the user toggles push notifications in Settings. The SDK
810
+ * itself stops delivering: off releases every device-token
811
+ * registration on the backend (tokens are kept), on registers them
812
+ * again. A host that also owns native push should mirror the choice.
813
+ */
814
+ onPushNotificationsToggle?: (enabled: boolean) => void | Promise<void>;
792
815
  onMessageSent?: (event: {
793
816
  message: string;
794
817
  roomJID: string;
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/sync-version.mjs on `npm version`.
2
2
  // Edit package.json's version instead of this file.
3
- export const ETHORA_CHAT_COMPONENT_VERSION = '26.8.2';
3
+ export const ETHORA_CHAT_COMPONENT_VERSION = '26.8.4';