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 { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet } from "react-router-dom";
|
||||
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 { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "./utils/theme";
|
||||
|
||||
const App = observer(() => {
|
||||
const { i18n } = useTranslation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const instanceProfile = instanceStore.state.profile;
|
||||
const userGeneralSetting = userStore.state.userGeneralSetting;
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
|
||||
// Apply user preferences reactively
|
||||
useUserLocale();
|
||||
useUserTheme();
|
||||
|
||||
// Clean up expired OAuth states on app initialization
|
||||
useEffect(() => {
|
||||
cleanupExpiredOAuthState();
|
||||
|
|
@ -54,45 +56,6 @@ const App = observer(() => {
|
|||
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
|
||||
}, [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 />;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import i18n from "@/i18n";
|
||||
import { userStore } from "@/store";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_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 { loadTheme } from "@/utils/theme";
|
||||
import LocaleSelect from "../LocaleSelect";
|
||||
|
|
@ -20,8 +19,8 @@ const PreferencesSection = observer(() => {
|
|||
const generalSetting = userStore.state.userGeneralSetting;
|
||||
|
||||
const handleLocaleSelectChange = async (locale: Locale) => {
|
||||
// Apply locale immediately for instant UI feedback
|
||||
i18n.changeLanguage(locale);
|
||||
// Apply locale immediately for instant UI feedback and persist to localStorage
|
||||
loadLocale(locale);
|
||||
// Persist to user settings
|
||||
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@ export * from "./useMemoFilters";
|
|||
export * from "./useMemoSorting";
|
||||
export * from "./useNavigateTo";
|
||||
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
|
||||
import "./store/config";
|
||||
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 "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
|
||||
applyThemeEarly();
|
||||
applyLocaleEarly();
|
||||
|
||||
const Main = observer(() => (
|
||||
<>
|
||||
|
|
@ -29,10 +31,6 @@ const Main = observer(() => (
|
|||
await initialInstanceStore();
|
||||
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 root = createRoot(container as HTMLElement);
|
||||
root.render(<Main />);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { uniqueId } from "lodash-es";
|
||||
import { computed, makeAutoObservable } from "mobx";
|
||||
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/grpcweb";
|
||||
import i18n from "@/i18n";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import {
|
||||
User,
|
||||
|
|
@ -14,8 +13,6 @@ import {
|
|||
UserSetting_WebhooksSetting,
|
||||
UserStats,
|
||||
} 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";
|
||||
|
||||
class LocalState {
|
||||
|
|
@ -284,20 +281,6 @@ const userStore = (() => {
|
|||
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 {
|
||||
state,
|
||||
getOrFetchUserByName,
|
||||
|
|
@ -314,7 +297,6 @@ const userStore = (() => {
|
|||
deleteNotification,
|
||||
fetchUserStats,
|
||||
setStatsStateId,
|
||||
applyUserPreferences,
|
||||
};
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@ import { useTranslation } from "react-i18next";
|
|||
import i18n, { locales, TLocale } from "@/i18n";
|
||||
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 => {
|
||||
if (locales.includes(language as TLocale)) {
|
||||
return language as Locale;
|
||||
|
|
@ -54,17 +73,42 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
|
|||
|
||||
// Gets the locale to use with proper priority:
|
||||
// 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 => {
|
||||
// Priority 1: User setting (if logged in and valid)
|
||||
if (userLocale && isValidateLocale(userLocale)) {
|
||||
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);
|
||||
};
|
||||
|
||||
// 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
|
||||
export const getLocaleDisplayName = (locale: string): string => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue