فهرست منبع

Locale changes (#1650)

Manav Rathi 1 سال پیش
والد
کامیت
372a9c979e

+ 3 - 2
apps/accounts/src/pages/_app.tsx

@@ -10,9 +10,10 @@ import {
 import EnteSpinner from '@ente/shared/components/EnteSpinner';
 import EnteSpinner from '@ente/shared/components/EnteSpinner';
 import AppNavbar from '@ente/shared/components/Navbar/app';
 import AppNavbar from '@ente/shared/components/Navbar/app';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
-import { setupI18n } from '@ente/shared/i18n';
+import { setupI18n } from '@/ui/i18n';
 import HTTPService from '@ente/shared/network/HTTPService';
 import HTTPService from '@ente/shared/network/HTTPService';
 import { LS_KEYS, getData } from '@ente/shared/storage/localStorage';
 import { LS_KEYS, getData } from '@ente/shared/storage/localStorage';
+import { getUserLocaleString } from '@ente/shared/storage/localStorage/helpers';
 import { getTheme } from '@ente/shared/themes';
 import { getTheme } from '@ente/shared/themes';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 import createEmotionCache from '@ente/shared/themes/createEmotionCache';
 import createEmotionCache from '@ente/shared/themes/createEmotionCache';
@@ -63,7 +64,7 @@ export default function App(props: EnteAppProps) {
     const [themeColor] = useLocalState(LS_KEYS.THEME, THEME_COLOR.DARK);
     const [themeColor] = useLocalState(LS_KEYS.THEME, THEME_COLOR.DARK);
 
 
     useEffect(() => {
     useEffect(() => {
-        setupI18n().finally(() => setIsI18nReady(true));
+        setupI18n(getUserLocaleString()).finally(() => setIsI18nReady(true));
     }, []);
     }, []);
 
 
     const setupPackageName = () => {
     const setupPackageName = () => {

+ 3 - 2
apps/auth/src/pages/_app.tsx

@@ -32,11 +32,12 @@ import { EnteAppProps } from '@ente/shared/apps/types';
 import createEmotionCache from '@ente/shared/themes/createEmotionCache';
 import createEmotionCache from '@ente/shared/themes/createEmotionCache';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 import { SetTheme } from '@ente/shared/themes/types';
 import { SetTheme } from '@ente/shared/themes/types';
-import { setupI18n } from '@ente/shared/i18n';
+import { setupI18n } from '@/ui/i18n';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
 import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
 import { getTheme } from '@ente/shared/themes';
 import { getTheme } from '@ente/shared/themes';
 import '../../public/css/global.css';
 import '../../public/css/global.css';
+import { getUserLocaleString } from '@ente/shared/storage/localStorage/helpers';
 
 
 type AppContextType = {
 type AppContextType = {
     showNavBar: (show: boolean) => void;
     showNavBar: (show: boolean) => void;
@@ -80,7 +81,7 @@ export default function App(props: EnteAppProps) {
 
 
     useEffect(() => {
     useEffect(() => {
         //setup i18n
         //setup i18n
-        setupI18n().finally(() => setIsI18nReady(true));
+        setupI18n(getUserLocaleString()).finally(() => setIsI18nReady(true));
         // set client package name in headers
         // set client package name in headers
         HTTPService.setHeaders({
         HTTPService.setHeaders({
             'X-Client-Package': CLIENT_PACKAGE_NAMES.get(APPS.AUTH),
             'X-Client-Package': CLIENT_PACKAGE_NAMES.get(APPS.AUTH),

+ 0 - 1
apps/photos/package.json

@@ -34,7 +34,6 @@
         "ffmpeg-wasm": "file:./thirdparty/ffmpeg-wasm",
         "ffmpeg-wasm": "file:./thirdparty/ffmpeg-wasm",
         "file-type": "^16.5.4",
         "file-type": "^16.5.4",
         "formik": "^2.1.5",
         "formik": "^2.1.5",
-        "get-user-locale": "^2.1.3",
         "hdbscan": "0.0.1-alpha.5",
         "hdbscan": "0.0.1-alpha.5",
         "heic-convert": "^2.0.0",
         "heic-convert": "^2.0.0",
         "idb": "^7.1.1",
         "idb": "^7.1.1",

+ 24 - 15
apps/photos/src/components/Sidebar/Preferences/LanguageSelector.tsx

@@ -1,42 +1,51 @@
 import DropdownInput, { DropdownOption } from 'components/DropdownInput';
 import DropdownInput, { DropdownOption } from 'components/DropdownInput';
-import { Language } from '@ente/shared/i18n/locale';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { t } from 'i18next';
 import { t } from 'i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
-import { getBestPossibleUserLocale } from '@ente/shared/i18n/utils';
+import {
+    type SupportedLocale,
+    supportedLocales,
+    closestSupportedLocale,
+} from '@/ui/i18n';
 import { LS_KEYS } from '@ente/shared/storage/localStorage';
 import { LS_KEYS } from '@ente/shared/storage/localStorage';
+import { getUserLocaleString } from '@ente/shared/storage/localStorage/helpers';
 
 
-const getLocaleDisplayName = (l: Language) => {
-    switch (l) {
-        case Language.en:
+/**
+ * Human readable name for each supported locale
+ *
+ * TODO (MR): This names themselves should be localized.
+ */
+export const localeName = (locale: SupportedLocale) => {
+    switch (locale) {
+        case 'en':
             return 'English';
             return 'English';
-        case Language.fr:
+        case 'fr':
             return 'Français';
             return 'Français';
-        case Language.zh:
+        case 'zh':
             return '中文';
             return '中文';
-        case Language.nl:
+        case 'nl':
             return 'Nederlands';
             return 'Nederlands';
-        case Language.es:
+        case 'es':
             return 'Español';
             return 'Español';
     }
     }
 };
 };
 
 
-const getLanguageOptions = (): DropdownOption<Language>[] => {
-    return Object.values(Language).map((lang) => ({
-        label: getLocaleDisplayName(lang),
-        value: lang,
+const getLanguageOptions = (): DropdownOption<SupportedLocale>[] => {
+    return supportedLocales.map((locale) => ({
+        label: localeName(locale),
+        value: locale,
     }));
     }));
 };
 };
 
 
 export const LanguageSelector = () => {
 export const LanguageSelector = () => {
     const [userLocale, setUserLocale] = useLocalState(
     const [userLocale, setUserLocale] = useLocalState(
         LS_KEYS.LOCALE,
         LS_KEYS.LOCALE,
-        getBestPossibleUserLocale()
+        closestSupportedLocale(getUserLocaleString())
     );
     );
 
 
     const router = useRouter();
     const router = useRouter();
 
 
-    const updateCurrentLocale = (newLocale: Language) => {
+    const updateCurrentLocale = (newLocale: SupportedLocale) => {
         setUserLocale(newLocale);
         setUserLocale(newLocale);
         router.reload();
         router.reload();
     };
     };

+ 3 - 2
apps/photos/src/pages/_app.tsx

@@ -66,6 +66,7 @@ import { REDIRECTS } from 'constants/redirects';
 import {
 import {
     getLocalMapEnabled,
     getLocalMapEnabled,
     getToken,
     getToken,
+    getUserLocaleString,
     setLocalMapEnabled,
     setLocalMapEnabled,
 } from '@ente/shared/storage/localStorage/helpers';
 } from '@ente/shared/storage/localStorage/helpers';
 import { isExportInProgress } from 'utils/export';
 import { isExportInProgress } from 'utils/export';
@@ -73,7 +74,7 @@ import { EnteAppProps } from '@ente/shared/apps/types';
 import createEmotionCache from '@ente/shared/themes/createEmotionCache';
 import createEmotionCache from '@ente/shared/themes/createEmotionCache';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 import { SetTheme } from '@ente/shared/themes/types';
 import { SetTheme } from '@ente/shared/themes/types';
-import { setupI18n } from '@ente/shared/i18n';
+import { setupI18n } from '@/ui/i18n';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
 import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
 import { getTheme } from '@ente/shared/themes';
 import { getTheme } from '@ente/shared/themes';
@@ -162,7 +163,7 @@ export default function App(props: EnteAppProps) {
 
 
     useEffect(() => {
     useEffect(() => {
         //setup i18n
         //setup i18n
-        setupI18n().finally(() => setIsI18nReady(true));
+        setupI18n(getUserLocaleString()).finally(() => setIsI18nReady(true));
         // set client package name in headers
         // set client package name in headers
         HTTPService.setHeaders({
         HTTPService.setHeaders({
             'X-Client-Package': CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS),
             'X-Client-Package': CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS),

+ 10 - 1
docs/dependencies.md

@@ -33,5 +33,14 @@ Emotion itself comes in many parts, of which we need the following three:
 * "@emotion/react" - React interface to Emotion. In particular, we set this as
 * "@emotion/react" - React interface to Emotion. In particular, we set this as
   the package that handles the transformation of JSX into JS (via the
   the package that handles the transformation of JSX into JS (via the
   `jsxImportSource` property in `tsconfig.json`).
   `jsxImportSource` property in `tsconfig.json`).
-* "@emotion/styled"
+
+* "@emotion/styled" - Provides the `styled` utility, a la styled-components. We
+  don't use it directly, instead we import it from `@mui/material`. However, MUI
+  docs
+  [mention](https://mui.com/material-ui/integrations/interoperability/#styled-components)
+  that
+
+  > Keep `@emotion/styled` as a dependency of your project. Even if you never
+  > use it explicitly, it's a peer dependency of `@mui/material`.
+
 * "@emotion/server"
 * "@emotion/server"

+ 0 - 106
packages/shared/i18n/index.ts

@@ -1,106 +0,0 @@
-import i18n from 'i18next';
-import { initReactI18next } from 'react-i18next';
-import Backend from 'i18next-http-backend';
-import { getBestPossibleUserLocale } from './utils';
-import { isDevBuild } from '@/utils/env';
-
-/**
- * Load translations.
- *
- * Localization and related concerns (aka "internationalization", or "i18n") for
- * our apps is handled by i18n framework.
- *
- * In addition to the base i18next package, we use two of its plugins:
- *
- * - i18next-http-backend, for loading the JSON files containin the translations
- *   at runtime, and
- *
- * - react-i18next, which adds React specific APIs
- */
-export const setupI18n = async () => {
-    // https://www.i18next.com/overview/api
-    await i18n
-        // i18next-http-backend: Asynchronously loads translations over HTTP
-        // https://github.com/i18next/i18next-http-backend
-        .use(Backend)
-        // react-i18next: React support
-        // Pass the i18n instance to react-i18next.
-        .use(initReactI18next)
-        // Initialize i18next
-        // Option docs: https://www.i18next.com/overview/configuration-options
-        .init({
-            debug: isDevBuild,
-            returnEmptyString: false,
-            fallbackLng: 'en',
-            lng: getBestPossibleUserLocale(),
-            interpolation: {
-                escapeValue: false, // not needed for react as it escapes by default
-            },
-            react: {
-                useSuspense: false,
-                transKeepBasicHtmlNodesFor: [
-                    'div',
-                    'strong',
-                    'h2',
-                    'span',
-                    'code',
-                    'p',
-                    'br',
-                ],
-            },
-            load: 'languageOnly',
-        });
-
-    i18n.services.formatter.add('dateTime', (value, lng) => {
-        return new Date(value / 1000).toLocaleDateString(lng, {
-            year: 'numeric',
-            month: 'long',
-            day: 'numeric',
-        });
-    });
-};
-
-/**
- * Locales are combinations of a language code, and an optional region code.
- *
- * For example, "en", "en-US", "en-IN" (Indian English), "pt" (Portuguese),
- * "pt-BR" (Brazilian Portuguese).
- *
- * In our Crowdin Project, we have work-in-progress translations into quite a
- * few languages. When a translation reaches a high enough coverage, say 90%,
- * then we manually add it to this list of supported languages.
- */
-export type SupportedLocale = 'en' | 'fr' | 'zh' | 'nl' | 'es';
-
-/**
- * List of all {@link SupportedLocale}s.
- */
-export const supportedLocales: SupportedLocale[] = [
-    'en',
-    'fr',
-    'zh',
-    'nl',
-    'es',
-];
-
-/**
- * A type guard that returns true if the given string is a
- * {@link SupportedLocale}.
- */
-export const isSupportedLocale = (s: string): s is SupportedLocale => {
-    // The `as any` here is needed to work around current TS limitations
-    // https://github.com/microsoft/TypeScript/issues/48247
-    return supportedLocales.includes(s as any);
-};
-
-/**
- * Return the current locale in which our user interface is being shown.
- *
- * Note that this may be different from the user's locale. For example, the
- * browser might be set to en-GB, but since we don't support that specific
- * variant of English, this value will be (say) en-US.
- */
-export const currentLocale = () => {
-    const locale = i18n.resolvedLanguage;
-    return isSupportedLocale(locale) ? locale : 'en';
-};

+ 0 - 8
packages/shared/i18n/locale.ts

@@ -1,8 +0,0 @@
-/** Enums of supported locale */
-export enum Language {
-    en = 'en',
-    fr = 'fr',
-    zh = 'zh',
-    nl = 'nl',
-    es = 'es',
-}

+ 0 - 26
packages/shared/i18n/utils.ts

@@ -1,26 +0,0 @@
-import { Language } from './locale';
-
-import { getUserLocales } from 'get-user-locale';
-import { getUserLocale } from '@ente/shared/storage/localStorage/helpers';
-
-export function getBestPossibleUserLocale(): Language {
-    const locale = getUserLocale();
-    if (locale) {
-        return locale;
-    }
-    const userLocales = getUserLocales();
-    for (const lc of userLocales) {
-        if (lc.startsWith('en')) {
-            return Language.en;
-        } else if (lc.startsWith('fr')) {
-            return Language.fr;
-        } else if (lc.startsWith('zh')) {
-            return Language.zh;
-        } else if (lc.startsWith('nl')) {
-            return Language.nl;
-        } else if (lc.startsWith('es')) {
-            return Language.es;
-        }
-    }
-    return Language.en;
-}

+ 1 - 2
packages/shared/storage/localStorage/helpers.ts

@@ -1,5 +1,4 @@
 import { LS_KEYS, getData, setData } from '.';
 import { LS_KEYS, getData, setData } from '.';
-import { Language } from '@ente/shared/i18n/locale';
 
 
 export const getToken = (): string => {
 export const getToken = (): string => {
     const token = getData(LS_KEYS.USER)?.token;
     const token = getData(LS_KEYS.USER)?.token;
@@ -30,7 +29,7 @@ export function setLivePhotoInfoShownCount(count: boolean) {
     setData(LS_KEYS.LIVE_PHOTO_INFO_SHOWN_COUNT, { count });
     setData(LS_KEYS.LIVE_PHOTO_INFO_SHOWN_COUNT, { count });
 }
 }
 
 
-export function getUserLocale(): Language {
+export function getUserLocaleString(): string {
     return getData(LS_KEYS.LOCALE)?.value;
     return getData(LS_KEYS.LOCALE)?.value;
 }
 }
 
 

+ 147 - 0
packages/ui/i18n.ts

@@ -0,0 +1,147 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import Backend from "i18next-http-backend";
+import { isDevBuild } from "@/utils/env";
+import { getUserLocales } from "get-user-locale";
+import { includes } from "@/utils/type-guards";
+
+/**
+ * List of all {@link SupportedLocale}s.
+ *
+ * Locales are combinations of a language code, and an optional region code.
+ *
+ * For example, "en", "en-US", "en-IN" (Indian English), "pt" (Portuguese),
+ * "pt-BR" (Brazilian Portuguese).
+ *
+ * In our Crowdin Project, we have work-in-progress translations into more
+ * languages than this. When a translation reaches a high enough coverage, say
+ * 90%, then we manually add it to this list of supported languages.
+ */
+export const supportedLocales = ["en", "fr", "zh", "nl", "es"] as const;
+/** The type of  {@link supportedLocale}s. */
+export type SupportedLocale = (typeof supportedLocales)[number];
+
+/**
+ * Load translations.
+ *
+ * Localization and related concerns (aka "internationalization", or "i18n") for
+ * our apps is handled by i18n framework.
+ *
+ * In addition to the base i18next package, we use two of its plugins:
+ *
+ * - i18next-http-backend, for loading the JSON files containin the translations
+ *   at runtime, and
+ *
+ * - react-i18next, which adds React specific APIs
+ */
+export const setupI18n = async (savedLocaleString?: string) => {
+    const locale = closestSupportedLocale(savedLocaleString);
+
+    // https://www.i18next.com/overview/api
+    await i18n
+        // i18next-http-backend: Asynchronously loads translations over HTTP
+        // https://github.com/i18next/i18next-http-backend
+        .use(Backend)
+        // react-i18next: React support
+        // Pass the i18n instance to react-i18next.
+        .use(initReactI18next)
+        // Initialize i18next
+        // Option docs: https://www.i18next.com/overview/configuration-options
+        .init({
+            debug: isDevBuild,
+            returnEmptyString: false,
+            fallbackLng: "en",
+            // i18next calls it language, but it really is the locale
+            lng: locale,
+            interpolation: {
+                escapeValue: false, // not needed for react as it escapes by default
+            },
+            react: {
+                useSuspense: false,
+                transKeepBasicHtmlNodesFor: [
+                    "div",
+                    "strong",
+                    "h2",
+                    "span",
+                    "code",
+                    "p",
+                    "br",
+                ],
+            },
+            load: "languageOnly",
+        });
+
+    i18n.services.formatter?.add("dateTime", (value, lng) => {
+        return new Date(value / 1000).toLocaleDateString(lng, {
+            year: "numeric",
+            month: "long",
+            day: "numeric",
+        });
+    });
+};
+
+/**
+ * Return the current locale in which our user interface is being shown.
+ *
+ * Note that this may be different from the user's locale. For example, the
+ * browser might be set to en-GB, but since we don't support that specific
+ * variant of English, this value will be (say) en-US.
+ */
+export const currentLocale = () => {
+    const locale = i18n.resolvedLanguage;
+    return locale && includes(supportedLocales, locale) ? locale : "en";
+};
+
+/**
+ * Return the closest / best matching {@link SupportedLocale}.
+ *
+ * It takes as input a {@link savedLocaleString}, which denotes the user's
+ * explicitly chosen preference (which we then persist in local storage).
+ * Subsequently, we use this to (usually literally) return the supported locale
+ * that it represents.
+ *
+ * If {@link savedLocaleString} is `undefined`, it tries to deduce the closest
+ * {@link SupportedLocale} that matches the browser's locale.
+ */
+export function closestSupportedLocale(
+    savedLocaleString?: string,
+): SupportedLocale {
+    const ss = savedLocaleString;
+    if (ss && includes(supportedLocales, ss)) return ss;
+
+    /*
+    switch (savedLocaleString) {
+        case "en":
+            return Language.en;
+        case "fr":
+            return Language.fr;
+        case "zh":
+            return Language.zh;
+        case "nl":
+            return Language.nl;
+        case "es":
+            return Language.es;
+    }
+    */
+
+    for (const us of getUserLocales()) {
+        // Exact match
+        if (us && includes(supportedLocales, us)) return us;
+
+        // Language match
+        if (us.startsWith("en")) {
+            return "en";
+        } else if (us.startsWith("fr")) {
+            return "fr";
+        } else if (us.startsWith("zh")) {
+            return "zh";
+        } else if (us.startsWith("nl")) {
+            return "nl";
+        } else if (us.startsWith("es")) {
+            return "es";
+        }
+    }
+
+    // Fallback
+    return "en";
+}

+ 1 - 0
packages/ui/package.json

@@ -11,6 +11,7 @@
         "@emotion/styled": "^11.11",
         "@emotion/styled": "^11.11",
         "@mui/icons-material": "^5.15",
         "@mui/icons-material": "^5.15",
         "@mui/material": "^5.15",
         "@mui/material": "^5.15",
+        "get-user-locale": "^2.3.1",
         "i18next": "^23.10.0",
         "i18next": "^23.10.0",
         "i18next-http-backend": "^2.5.0",
         "i18next-http-backend": "^2.5.0",
         "react": "18.2.0",
         "react": "18.2.0",

+ 15 - 0
packages/utils/type-guards.ts

@@ -0,0 +1,15 @@
+/**
+ * A variant of Array.includes that allows us to use it as a type guard.
+ *
+ * It allows us to narrow the type of an arbitrary T to U if it is one of U[].
+ *
+ * TypeScript currently doesn't allow us to use the standard Array.includes as a
+ * type guard for checking if an arbitrary string is one of the known set of
+ * values. This issue and this workaround is mentioned here:
+ * - https://github.com/microsoft/TypeScript/issues/48247
+ * - https://github.com/microsoft/TypeScript/issues/26255#issuecomment-502899689
+ */
+export function includes<T, U extends T>(us: readonly U[], t: T): t is U {
+    // @ts-expect-error @typescript-eslint/no-unsafe-argument
+    return us.includes(t);
+}

+ 1 - 1
yarn.lock

@@ -2322,7 +2322,7 @@ get-tsconfig@^4.5.0:
   dependencies:
   dependencies:
     resolve-pkg-maps "^1.0.0"
     resolve-pkg-maps "^1.0.0"
 
 
-get-user-locale@^2.1.3:
+get-user-locale@^2.3.1:
   version "2.3.1"
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/get-user-locale/-/get-user-locale-2.3.1.tgz#fc7319429c8a70fac01b3b2a0b08b0c71c1d3fe2"
   resolved "https://registry.yarnpkg.com/get-user-locale/-/get-user-locale-2.3.1.tgz#fc7319429c8a70fac01b3b2a0b08b0c71c1d3fe2"
   integrity sha512-VEvcsqKYx7zhZYC1CjecrDC5ziPSpl1gSm0qFFJhHSGDrSC+x4+p1KojWC/83QX//j476gFhkVXP/kNUc9q+bQ==
   integrity sha512-VEvcsqKYx7zhZYC1CjecrDC5ziPSpl1gSm0qFFJhHSGDrSC+x4+p1KojWC/83QX//j476gFhkVXP/kNUc9q+bQ==