Browse Source

Locale changes (#1650)

Manav Rathi 1 year ago
parent
commit
372a9c979e

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

@@ -10,9 +10,10 @@ import {
 import EnteSpinner from '@ente/shared/components/EnteSpinner';
 import AppNavbar from '@ente/shared/components/Navbar/app';
 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 { LS_KEYS, getData } from '@ente/shared/storage/localStorage';
+import { getUserLocaleString } from '@ente/shared/storage/localStorage/helpers';
 import { getTheme } from '@ente/shared/themes';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 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);
 
     useEffect(() => {
-        setupI18n().finally(() => setIsI18nReady(true));
+        setupI18n(getUserLocaleString()).finally(() => setIsI18nReady(true));
     }, []);
 
     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 { THEME_COLOR } from '@ente/shared/themes/constants';
 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 { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
 import { getTheme } from '@ente/shared/themes';
 import '../../public/css/global.css';
+import { getUserLocaleString } from '@ente/shared/storage/localStorage/helpers';
 
 type AppContextType = {
     showNavBar: (show: boolean) => void;
@@ -80,7 +81,7 @@ export default function App(props: EnteAppProps) {
 
     useEffect(() => {
         //setup i18n
-        setupI18n().finally(() => setIsI18nReady(true));
+        setupI18n(getUserLocaleString()).finally(() => setIsI18nReady(true));
         // set client package name in headers
         HTTPService.setHeaders({
             '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",
         "file-type": "^16.5.4",
         "formik": "^2.1.5",
-        "get-user-locale": "^2.1.3",
         "hdbscan": "0.0.1-alpha.5",
         "heic-convert": "^2.0.0",
         "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 { Language } from '@ente/shared/i18n/locale';
 import { useLocalState } from '@ente/shared/hooks/useLocalState';
 import { t } from 'i18next';
 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 { 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';
-        case Language.fr:
+        case 'fr':
             return 'Français';
-        case Language.zh:
+        case 'zh':
             return '中文';
-        case Language.nl:
+        case 'nl':
             return 'Nederlands';
-        case Language.es:
+        case 'es':
             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 = () => {
     const [userLocale, setUserLocale] = useLocalState(
         LS_KEYS.LOCALE,
-        getBestPossibleUserLocale()
+        closestSupportedLocale(getUserLocaleString())
     );
 
     const router = useRouter();
 
-    const updateCurrentLocale = (newLocale: Language) => {
+    const updateCurrentLocale = (newLocale: SupportedLocale) => {
         setUserLocale(newLocale);
         router.reload();
     };

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

@@ -66,6 +66,7 @@ import { REDIRECTS } from 'constants/redirects';
 import {
     getLocalMapEnabled,
     getToken,
+    getUserLocaleString,
     setLocalMapEnabled,
 } from '@ente/shared/storage/localStorage/helpers';
 import { isExportInProgress } from 'utils/export';
@@ -73,7 +74,7 @@ import { EnteAppProps } from '@ente/shared/apps/types';
 import createEmotionCache from '@ente/shared/themes/createEmotionCache';
 import { THEME_COLOR } from '@ente/shared/themes/constants';
 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 { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
 import { getTheme } from '@ente/shared/themes';
@@ -162,7 +163,7 @@ export default function App(props: EnteAppProps) {
 
     useEffect(() => {
         //setup i18n
-        setupI18n().finally(() => setIsI18nReady(true));
+        setupI18n(getUserLocaleString()).finally(() => setIsI18nReady(true));
         // set client package name in headers
         HTTPService.setHeaders({
             '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
   the package that handles the transformation of JSX into JS (via the
   `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"

+ 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 { Language } from '@ente/shared/i18n/locale';
 
 export const getToken = (): string => {
     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 });
 }
 
-export function getUserLocale(): Language {
+export function getUserLocaleString(): string {
     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",
         "@mui/icons-material": "^5.15",
         "@mui/material": "^5.15",
+        "get-user-locale": "^2.3.1",
         "i18next": "^23.10.0",
         "i18next-http-backend": "^2.5.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:
     resolve-pkg-maps "^1.0.0"
 
-get-user-locale@^2.1.3:
+get-user-locale@^2.3.1:
   version "2.3.1"
   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==