layout-settings.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. 'use client';
  2. import { create } from 'zustand';
  3. import { useCallback, useEffect } from 'react';
  4. import { persistedGet, persistedSet } from 'app/lib/util/storage';
  5. import { appSetting } from 'app/lib/util'
  6. import { Appearance, Platform } from 'react-native'
  7. import * as RNLocalize from "react-native-localize";
  8. import { fetcher } from 'app/lib/fetcher'
  9. import { changeI18nLanguage } from 'app/lib/i18n-resources'
  10. import emitter, { EVENTS } from 'app/context/emitter';
  11. import { clearAllPageCache } from 'app/lib/cache/clear-page-cache';
  12. import type { SoundLevel } from 'app/lib/sound/config';
  13. const STORAGE_KEY = 'layout-settings';
  14. const LANG_MODE_COOKIE = 'neo_lang';
  15. const LANG_CODE_COOKIE = 'neo_lang_code';
  16. // Settings use the historical misspelling; keep this key aligned with settings/layout.js.
  17. const AVAILABLE_LANGS_SETTING_KEY = 'avaliable_langs';
  18. /** User layout preferences, persisted locally (defaults from `layout.defaults` setting). */
  19. export type LayoutSettings = {
  20. /** `light` / `dark` / `auto`. */
  21. theme?: 'light' | 'dark' | 'auto' | string;
  22. /** Layout variant name. */
  23. name?: string;
  24. density?: string;
  25. /** Language code or `auto` (device language). */
  26. lang?: string;
  27. /** The user's level of interface sounds; unset follows UNA's settings. `false` (older builds) is `off`. */
  28. sounds?: SoundLevel | boolean;
  29. [key: string]: unknown;
  30. };
  31. const DEFAULT_LAYOUT_SETTINGS: LayoutSettings = appSetting('layout', 'defaults')
  32. const normalizeLangCode = (value: unknown) => String(value || '')
  33. .toLowerCase()
  34. .split(/[-_]/)[0];
  35. const getDefaultLangCode = (): string => {
  36. const langs = appSetting('layout', AVAILABLE_LANGS_SETTING_KEY);
  37. const supportedLangs = Array.isArray(langs)
  38. ? langs.filter((lang: string) => lang && lang !== 'auto')
  39. : [];
  40. const fallback = supportedLangs[0] || 'en';
  41. try {
  42. const localeCodes: string[] = Platform.OS === 'web'
  43. ? (typeof navigator !== 'undefined'
  44. ? [
  45. typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().locale : '',
  46. ...(navigator.languages || []),
  47. navigator.language,
  48. ].filter(Boolean)
  49. : [])
  50. : RNLocalize.getLocales().map((locale) => locale.languageTag || locale.languageCode);
  51. const matchedLang = localeCodes
  52. .map(normalizeLangCode)
  53. .find((langCode) => supportedLangs.includes(langCode));
  54. return matchedLang || fallback;
  55. } catch (e) {
  56. console.warn('Failed to get locales:', e);
  57. return fallback;
  58. }
  59. };
  60. /**
  61. * Device language for `auto`, read once: every `useLayoutSettings` consumer
  62. * resolves it on each render, and the locale lookup (Intl / RNLocalize) is not
  63. * free. Not cached during SSR, where there is no device.
  64. */
  65. let deviceLangCode: string | null = null;
  66. const getDeviceLangCode = (): string => {
  67. if (Platform.OS === 'web' && typeof window === 'undefined') return getDefaultLangCode();
  68. if (deviceLangCode === null) deviceLangCode = getDefaultLangCode();
  69. return deviceLangCode;
  70. };
  71. const resolveLangCode = (value: string | undefined): string => value && value != 'auto' ? value : getDeviceLangCode();
  72. const getCookieValue = (name: string): string => {
  73. if (Platform.OS !== 'web' || typeof document === 'undefined') return '';
  74. return document.cookie
  75. .split('; ')
  76. .find((row) => row.startsWith(name + '='))
  77. ?.split('=')
  78. .slice(1)
  79. .join('=') || '';
  80. };
  81. const decodeCookieValue = (value = '') => {
  82. try {
  83. return decodeURIComponent(value);
  84. } catch {
  85. return '';
  86. }
  87. };
  88. const setCookieValue = (name: string, value: string | undefined) => {
  89. if (Platform.OS !== 'web' || typeof document === 'undefined') return;
  90. document.cookie = `${name}=${encodeURIComponent(value || '')}; path=/; max-age=31536000; SameSite=Lax`;
  91. };
  92. const syncLanguageCookies = (mode: string | undefined, langCode: string) => {
  93. setCookieValue(LANG_MODE_COOKIE, mode || 'auto');
  94. setCookieValue(LANG_CODE_COOKIE, langCode);
  95. };
  96. const syncRemoteLang = async (langCode: string) => {
  97. await fetcher(
  98. '/api.php?r=system/get_page_by_request/TemplServicePages&params[]=home&lang=' +
  99. encodeURIComponent(langCode)
  100. );
  101. };
  102. const syncAutoLangCookies = (langCode: string) => {
  103. if (Platform.OS !== 'web') return;
  104. const currentCookieLang = decodeCookieValue(getCookieValue(LANG_CODE_COOKIE));
  105. if (currentCookieLang === langCode) return;
  106. syncLanguageCookies('auto', langCode);
  107. };
  108. type LayoutSettingsStore = {
  109. layoutSettings: LayoutSettings;
  110. hydrated: boolean;
  111. setLayoutSettings: (value: LayoutSettings) => Promise<void>;
  112. updateLayoutSettings: (partial: Partial<LayoutSettings>) => Promise<void>;
  113. /** Load persisted settings, apply the language, sync language cookies (web). */
  114. hydrate: () => Promise<void>;
  115. };
  116. export const useLayoutSettingsStore = create<LayoutSettingsStore>()((set, get) => ({
  117. layoutSettings: DEFAULT_LAYOUT_SETTINGS,
  118. hydrated: false,
  119. setLayoutSettings: async (value) => {
  120. await persistedSet(STORAGE_KEY, value);
  121. set({ layoutSettings: value });
  122. },
  123. updateLayoutSettings: async (partial) => {
  124. const current = get().layoutSettings || {};
  125. const updated = { ...current, ...partial };
  126. await persistedSet(STORAGE_KEY, updated);
  127. set({ layoutSettings: updated });
  128. },
  129. hydrate: async () => {
  130. try {
  131. const stored = await persistedGet(STORAGE_KEY);
  132. const parsed: LayoutSettings = stored && typeof stored === 'object' ? stored : DEFAULT_LAYOUT_SETTINGS;
  133. const resolvedLangCode = resolveLangCode(parsed?.lang);
  134. await changeI18nLanguage(resolvedLangCode);
  135. set({ layoutSettings: parsed, hydrated: true });
  136. if (parsed?.lang === 'auto') {
  137. syncAutoLangCookies(resolvedLangCode);
  138. } else {
  139. syncLanguageCookies(parsed?.lang, resolvedLangCode);
  140. }
  141. } catch (e) {
  142. console.error('Hydratation error layoutSettings:', e);
  143. const fallbackLangCode = resolveLangCode(DEFAULT_LAYOUT_SETTINGS?.lang);
  144. await changeI18nLanguage(fallbackLangCode);
  145. syncLanguageCookies(DEFAULT_LAYOUT_SETTINGS?.lang, fallbackLangCode);
  146. set({ layoutSettings: DEFAULT_LAYOUT_SETTINGS, hydrated: true });
  147. }
  148. },
  149. }));
  150. export const useLayoutSettings = () => {
  151. const isWeb = Platform.OS == 'web'
  152. const layoutSettings = useLayoutSettingsStore((state) => state.layoutSettings);
  153. const hydrated = useLayoutSettingsStore((state) => state.hydrated);
  154. const setLayoutSettings = useCallback(
  155. async (value: any) => {
  156. await useLayoutSettingsStore.getState().setLayoutSettings(value);
  157. },
  158. []
  159. );
  160. const updateLayoutSettings = useCallback(
  161. async (partial: Partial<LayoutSettings>) => {
  162. await useLayoutSettingsStore.getState().updateLayoutSettings(partial);
  163. },
  164. []
  165. );
  166. const setThemeName = useCallback(
  167. async (value: any) => {
  168. if (!isWeb) {
  169. const nativeValue = value == 'auto' ? null : value;
  170. Appearance.setColorScheme(nativeValue);
  171. }
  172. await useLayoutSettingsStore.getState().updateLayoutSettings({ theme: value });
  173. }, [isWeb]
  174. );
  175. const setLayoutName = useCallback(
  176. async (value: any) => {
  177. await useLayoutSettingsStore.getState().updateLayoutSettings({ name: value });
  178. }, []
  179. );
  180. const setSoundLevel = useCallback(
  181. async (value: SoundLevel) => {
  182. await useLayoutSettingsStore.getState().updateLayoutSettings({ sounds: value });
  183. }, []
  184. );
  185. const setDensity = useCallback(
  186. async (value: any) => {
  187. await useLayoutSettingsStore.getState().updateLayoutSettings({ density: value });
  188. }, []
  189. );
  190. const setLang = useCallback(
  191. async (value: any) => {
  192. const v2 = resolveLangCode(value);
  193. await changeI18nLanguage(v2)
  194. await useLayoutSettingsStore.getState().updateLayoutSettings({ lang: value });
  195. syncLanguageCookies(value, v2);
  196. try {
  197. await syncRemoteLang(v2)
  198. } catch (e) {
  199. console.warn('Failed to update remote language:', e);
  200. }
  201. if (isWeb && typeof window !== 'undefined') {
  202. window.location.reload();
  203. } else {
  204. clearAllPageCache();
  205. emitter.emit(EVENTS.page, { action: 'reload' });
  206. }
  207. }, [isWeb]
  208. );
  209. useEffect(() => {
  210. if (!hydrated) {
  211. useLayoutSettingsStore.getState().hydrate();
  212. }
  213. }, [hydrated]);
  214. const { density, name: layoutName, theme: themeName, lang } = layoutSettings ?? {};
  215. const langCode = resolveLangCode(lang);
  216. const storedSounds = layoutSettings?.sounds;
  217. const ownSoundLevel: SoundLevel | undefined = storedSounds === false ? 'off' : typeof storedSounds === 'string' ? storedSounds : undefined;
  218. return { layoutSettings, setLayoutSettings, updateLayoutSettings, hydrated, density, layoutName, themeName, setThemeName, setLayoutName, setDensity, setLang, lang, langCode, ownSoundLevel, setSoundLevel };
  219. };