mirror of https://github.com/usememos/memos.git
refactor(web): improve locale/theme preference initialization
- Extract preference logic into dedicated hooks (useUserLocale, useUserTheme) - Add applyLocaleEarly() for consistent early application - Remove applyUserPreferences() from user store (now redundant) - Simplify App.tsx by moving effects to custom hooks - Make locale/theme handling consistent and reactive - Clean up manual preference calls from sign-in flows Fixes locale not overriding localStorage on user login. Improves maintainability with better separation of concerns.
This commit is contained in:
parent
7479205e21
commit
3dc740c752
|
|
@ -1,19 +1,21 @@
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import useNavigateTo from "./hooks/useNavigateTo";
|
import useNavigateTo from "./hooks/useNavigateTo";
|
||||||
import { instanceStore, userStore } from "./store";
|
import { useUserLocale } from "./hooks/useUserLocale";
|
||||||
|
import { useUserTheme } from "./hooks/useUserTheme";
|
||||||
|
import { instanceStore } from "./store";
|
||||||
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
||||||
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "./utils/theme";
|
|
||||||
|
|
||||||
const App = observer(() => {
|
const App = observer(() => {
|
||||||
const { i18n } = useTranslation();
|
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
const instanceProfile = instanceStore.state.profile;
|
const instanceProfile = instanceStore.state.profile;
|
||||||
const userGeneralSetting = userStore.state.userGeneralSetting;
|
|
||||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||||
|
|
||||||
|
// Apply user preferences reactively
|
||||||
|
useUserLocale();
|
||||||
|
useUserTheme();
|
||||||
|
|
||||||
// Clean up expired OAuth states on app initialization
|
// Clean up expired OAuth states on app initialization
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanupExpiredOAuthState();
|
cleanupExpiredOAuthState();
|
||||||
|
|
@ -54,45 +56,6 @@ const App = observer(() => {
|
||||||
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
|
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
|
||||||
}, [instanceGeneralSetting.customProfile]);
|
}, [instanceGeneralSetting.customProfile]);
|
||||||
|
|
||||||
// Update HTML lang and dir attributes based on current locale
|
|
||||||
useEffect(() => {
|
|
||||||
const currentLocale = i18n.language;
|
|
||||||
document.documentElement.setAttribute("lang", currentLocale);
|
|
||||||
if (["ar", "fa"].includes(currentLocale)) {
|
|
||||||
document.documentElement.setAttribute("dir", "rtl");
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute("dir", "ltr");
|
|
||||||
}
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
// Apply theme when user setting changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userGeneralSetting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const theme = getThemeWithFallback(userGeneralSetting.theme);
|
|
||||||
loadTheme(theme);
|
|
||||||
}, [userGeneralSetting?.theme]);
|
|
||||||
|
|
||||||
// Listen for system theme changes when using "system" theme
|
|
||||||
useEffect(() => {
|
|
||||||
const theme = getThemeWithFallback(userGeneralSetting?.theme);
|
|
||||||
|
|
||||||
// Only set up listener if theme is "system"
|
|
||||||
if (theme !== "system") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up listener for OS theme preference changes
|
|
||||||
const cleanup = setupSystemThemeListener(() => {
|
|
||||||
// Reload theme when system preference changes
|
|
||||||
loadTheme(theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup listener on unmount or when theme changes
|
|
||||||
return cleanup;
|
|
||||||
}, [userGeneralSetting?.theme]);
|
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import i18n from "@/i18n";
|
|
||||||
import { userStore } from "@/store";
|
import { userStore } from "@/store";
|
||||||
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
||||||
import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service";
|
import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { loadLocale, useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
|
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
|
||||||
import { loadTheme } from "@/utils/theme";
|
import { loadTheme } from "@/utils/theme";
|
||||||
import LocaleSelect from "../LocaleSelect";
|
import LocaleSelect from "../LocaleSelect";
|
||||||
|
|
@ -20,8 +19,8 @@ const PreferencesSection = observer(() => {
|
||||||
const generalSetting = userStore.state.userGeneralSetting;
|
const generalSetting = userStore.state.userGeneralSetting;
|
||||||
|
|
||||||
const handleLocaleSelectChange = async (locale: Locale) => {
|
const handleLocaleSelectChange = async (locale: Locale) => {
|
||||||
// Apply locale immediately for instant UI feedback
|
// Apply locale immediately for instant UI feedback and persist to localStorage
|
||||||
i18n.changeLanguage(locale);
|
loadLocale(locale);
|
||||||
// Persist to user settings
|
// Persist to user settings
|
||||||
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
|
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,5 @@ export * from "./useMemoFilters";
|
||||||
export * from "./useMemoSorting";
|
export * from "./useMemoSorting";
|
||||||
export * from "./useNavigateTo";
|
export * from "./useNavigateTo";
|
||||||
export * from "./useResponsiveWidth";
|
export * from "./useResponsiveWidth";
|
||||||
|
export * from "./useUserLocale";
|
||||||
|
export * from "./useUserTheme";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { userStore } from "@/store";
|
||||||
|
import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that reactively applies user locale preference.
|
||||||
|
* Priority: User setting → localStorage → browser language
|
||||||
|
*/
|
||||||
|
export const useUserLocale = () => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const userGeneralSetting = userStore.state.userGeneralSetting;
|
||||||
|
|
||||||
|
// Apply locale when user setting changes or user logs in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userGeneralSetting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const locale = getLocaleWithFallback(userGeneralSetting.locale);
|
||||||
|
loadLocale(locale);
|
||||||
|
}, [userGeneralSetting?.locale]);
|
||||||
|
|
||||||
|
// Update HTML lang and dir attributes based on current locale
|
||||||
|
useEffect(() => {
|
||||||
|
const currentLocale = i18n.language;
|
||||||
|
document.documentElement.setAttribute("lang", currentLocale);
|
||||||
|
|
||||||
|
// RTL languages
|
||||||
|
if (["ar", "fa"].includes(currentLocale)) {
|
||||||
|
document.documentElement.setAttribute("dir", "rtl");
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute("dir", "ltr");
|
||||||
|
}
|
||||||
|
}, [i18n.language]);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { userStore } from "@/store";
|
||||||
|
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/utils/theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that reactively applies user theme preference.
|
||||||
|
* Priority: User setting → localStorage → system preference
|
||||||
|
*/
|
||||||
|
export const useUserTheme = () => {
|
||||||
|
const userGeneralSetting = userStore.state.userGeneralSetting;
|
||||||
|
|
||||||
|
// Apply theme when user setting changes or user logs in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userGeneralSetting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const theme = getThemeWithFallback(userGeneralSetting.theme);
|
||||||
|
loadTheme(theme);
|
||||||
|
}, [userGeneralSetting?.theme]);
|
||||||
|
|
||||||
|
// Listen for system theme changes when using "system" theme
|
||||||
|
useEffect(() => {
|
||||||
|
const theme = getThemeWithFallback(userGeneralSetting?.theme);
|
||||||
|
|
||||||
|
// Only set up listener if theme is "system"
|
||||||
|
if (theme !== "system") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up listener for OS theme preference changes
|
||||||
|
const cleanup = setupSystemThemeListener(() => {
|
||||||
|
loadTheme(theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [userGeneralSetting?.theme]);
|
||||||
|
};
|
||||||
|
|
@ -9,13 +9,15 @@ import router from "./router";
|
||||||
// Configure MobX before importing any stores
|
// Configure MobX before importing any stores
|
||||||
import "./store/config";
|
import "./store/config";
|
||||||
import { initialInstanceStore } from "./store/instance";
|
import { initialInstanceStore } from "./store/instance";
|
||||||
import userStore, { initialUserStore } from "./store/user";
|
import { initialUserStore } from "./store/user";
|
||||||
|
import { applyLocaleEarly } from "./utils/i18n";
|
||||||
import { applyThemeEarly } from "./utils/theme";
|
import { applyThemeEarly } from "./utils/theme";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
// Apply theme early to prevent flash of wrong theme
|
// Apply theme and locale early to prevent flash of wrong theme/language
|
||||||
// This uses localStorage as the source before user settings are loaded
|
// This uses localStorage as the source before user settings are loaded
|
||||||
applyThemeEarly();
|
applyThemeEarly();
|
||||||
|
applyLocaleEarly();
|
||||||
|
|
||||||
const Main = observer(() => (
|
const Main = observer(() => (
|
||||||
<>
|
<>
|
||||||
|
|
@ -29,10 +31,6 @@ const Main = observer(() => (
|
||||||
await initialInstanceStore();
|
await initialInstanceStore();
|
||||||
await initialUserStore();
|
await initialUserStore();
|
||||||
|
|
||||||
// Apply user preferences (theme & locale) after user settings are loaded
|
|
||||||
// This will override the early theme with user's actual preference
|
|
||||||
userStore.applyUserPreferences();
|
|
||||||
|
|
||||||
const container = document.getElementById("root");
|
const container = document.getElementById("root");
|
||||||
const root = createRoot(container as HTMLElement);
|
const root = createRoot(container as HTMLElement);
|
||||||
root.render(<Main />);
|
root.render(<Main />);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { uniqueId } from "lodash-es";
|
import { uniqueId } from "lodash-es";
|
||||||
import { computed, makeAutoObservable } from "mobx";
|
import { computed, makeAutoObservable } from "mobx";
|
||||||
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/grpcweb";
|
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/grpcweb";
|
||||||
import i18n from "@/i18n";
|
|
||||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
|
|
@ -14,8 +13,6 @@ import {
|
||||||
UserSetting_WebhooksSetting,
|
UserSetting_WebhooksSetting,
|
||||||
UserStats,
|
UserStats,
|
||||||
} from "@/types/proto/api/v1/user_service";
|
} from "@/types/proto/api/v1/user_service";
|
||||||
import { getLocaleWithFallback } from "@/utils/i18n";
|
|
||||||
import { getThemeWithFallback, loadTheme } from "@/utils/theme";
|
|
||||||
import { createRequestKey, RequestDeduplicator, StoreError } from "./store-utils";
|
import { createRequestKey, RequestDeduplicator, StoreError } from "./store-utils";
|
||||||
|
|
||||||
class LocalState {
|
class LocalState {
|
||||||
|
|
@ -284,20 +281,6 @@ const userStore = (() => {
|
||||||
state.statsStateId = id;
|
state.statsStateId = id;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Applies user preferences (theme and locale) with proper fallbacks
|
|
||||||
// This should be called after user settings are loaded
|
|
||||||
const applyUserPreferences = () => {
|
|
||||||
const generalSetting = state.userGeneralSetting;
|
|
||||||
|
|
||||||
// Apply theme with fallback: user setting -> localStorage -> system
|
|
||||||
const theme = getThemeWithFallback(generalSetting?.theme);
|
|
||||||
loadTheme(theme);
|
|
||||||
|
|
||||||
// Apply locale with fallback: user setting -> browser language
|
|
||||||
const locale = getLocaleWithFallback(generalSetting?.locale);
|
|
||||||
i18n.changeLanguage(locale);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
getOrFetchUserByName,
|
getOrFetchUserByName,
|
||||||
|
|
@ -314,7 +297,6 @@ const userStore = (() => {
|
||||||
deleteNotification,
|
deleteNotification,
|
||||||
fetchUserStats,
|
fetchUserStats,
|
||||||
setStatsStateId,
|
setStatsStateId,
|
||||||
applyUserPreferences,
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,25 @@ import { useTranslation } from "react-i18next";
|
||||||
import i18n, { locales, TLocale } from "@/i18n";
|
import i18n, { locales, TLocale } from "@/i18n";
|
||||||
import enTranslation from "@/locales/en.json";
|
import enTranslation from "@/locales/en.json";
|
||||||
|
|
||||||
|
const LOCALE_STORAGE_KEY = "memos-locale";
|
||||||
|
|
||||||
|
const getStoredLocale = (): Locale | null => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
return stored && locales.includes(stored) ? (stored as Locale) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStoredLocale = (locale: Locale): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||||
|
} catch {
|
||||||
|
// localStorage might not be available
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const findNearestMatchedLanguage = (language: string): Locale => {
|
export const findNearestMatchedLanguage = (language: string): Locale => {
|
||||||
if (locales.includes(language as TLocale)) {
|
if (locales.includes(language as TLocale)) {
|
||||||
return language as Locale;
|
return language as Locale;
|
||||||
|
|
@ -54,17 +73,42 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
|
||||||
|
|
||||||
// Gets the locale to use with proper priority:
|
// Gets the locale to use with proper priority:
|
||||||
// 1. User setting (if logged in and has preference)
|
// 1. User setting (if logged in and has preference)
|
||||||
// 2. Browser language preference
|
// 2. localStorage (from previous session)
|
||||||
|
// 3. Browser language preference
|
||||||
export const getLocaleWithFallback = (userLocale?: string): Locale => {
|
export const getLocaleWithFallback = (userLocale?: string): Locale => {
|
||||||
// Priority 1: User setting (if logged in and valid)
|
// Priority 1: User setting (if logged in and valid)
|
||||||
if (userLocale && isValidateLocale(userLocale)) {
|
if (userLocale && isValidateLocale(userLocale)) {
|
||||||
return userLocale as Locale;
|
return userLocale as Locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Browser language
|
// Priority 2: localStorage
|
||||||
|
const stored = getStoredLocale();
|
||||||
|
if (stored) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Browser language
|
||||||
return findNearestMatchedLanguage(navigator.language);
|
return findNearestMatchedLanguage(navigator.language);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Applies and persists a locale setting
|
||||||
|
export const loadLocale = (locale: string): Locale => {
|
||||||
|
const validLocale = isValidateLocale(locale) ? (locale as Locale) : findNearestMatchedLanguage(navigator.language);
|
||||||
|
setStoredLocale(validLocale);
|
||||||
|
i18n.changeLanguage(validLocale);
|
||||||
|
return validLocale;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies locale early during initial page load to prevent language flash.
|
||||||
|
* Uses only localStorage and browser language (no user settings yet).
|
||||||
|
*/
|
||||||
|
export const applyLocaleEarly = (): void => {
|
||||||
|
const stored = getStoredLocale();
|
||||||
|
const locale = stored ?? findNearestMatchedLanguage(navigator.language);
|
||||||
|
loadLocale(locale);
|
||||||
|
};
|
||||||
|
|
||||||
// Get the display name for a locale in its native language
|
// Get the display name for a locale in its native language
|
||||||
export const getLocaleDisplayName = (locale: string): string => {
|
export const getLocaleDisplayName = (locale: string): string => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue