diff --git a/apps/accounts/src/pages/_app.tsx b/apps/accounts/src/pages/_app.tsx index bf0d57b61..5131bed29 100644 --- a/apps/accounts/src/pages/_app.tsx +++ b/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 = () => { diff --git a/apps/auth/src/pages/_app.tsx b/apps/auth/src/pages/_app.tsx index 2594dfa1f..e7f611a16 100644 --- a/apps/auth/src/pages/_app.tsx +++ b/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), diff --git a/apps/photos/package.json b/apps/photos/package.json index c3a3827c7..4a2f28087 100644 --- a/apps/photos/package.json +++ b/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", diff --git a/apps/photos/src/components/Sidebar/Preferences/LanguageSelector.tsx b/apps/photos/src/components/Sidebar/Preferences/LanguageSelector.tsx index 583912b33..5ec39f726 100644 --- a/apps/photos/src/components/Sidebar/Preferences/LanguageSelector.tsx +++ b/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[] => { - return Object.values(Language).map((lang) => ({ - label: getLocaleDisplayName(lang), - value: lang, +const getLanguageOptions = (): DropdownOption[] => { + 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(); }; diff --git a/apps/photos/src/pages/_app.tsx b/apps/photos/src/pages/_app.tsx index 0b2e795ca..6341e1017 100644 --- a/apps/photos/src/pages/_app.tsx +++ b/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), diff --git a/docs/dependencies.md b/docs/dependencies.md index 2826b753c..0bfd6ff52 100644 --- a/docs/dependencies.md +++ b/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" diff --git a/packages/shared/i18n/index.ts b/packages/shared/i18n/index.ts deleted file mode 100644 index 7441d59d2..000000000 --- a/packages/shared/i18n/index.ts +++ /dev/null @@ -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'; -}; diff --git a/packages/shared/i18n/locale.ts b/packages/shared/i18n/locale.ts deleted file mode 100644 index 201800480..000000000 --- a/packages/shared/i18n/locale.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Enums of supported locale */ -export enum Language { - en = 'en', - fr = 'fr', - zh = 'zh', - nl = 'nl', - es = 'es', -} diff --git a/packages/shared/i18n/utils.ts b/packages/shared/i18n/utils.ts deleted file mode 100644 index a9f3f835e..000000000 --- a/packages/shared/i18n/utils.ts +++ /dev/null @@ -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; -} diff --git a/packages/shared/storage/localStorage/helpers.ts b/packages/shared/storage/localStorage/helpers.ts index cd5e006d1..d9c48865b 100644 --- a/packages/shared/storage/localStorage/helpers.ts +++ b/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; } diff --git a/packages/ui/i18n.ts b/packages/ui/i18n.ts new file mode 100644 index 000000000..35ae7ca3d --- /dev/null +++ b/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"; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 01462ce78..3d80e1e3e 100644 --- a/packages/ui/package.json +++ b/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", diff --git a/packages/utils/type-guards.ts b/packages/utils/type-guards.ts new file mode 100644 index 000000000..1309a5bea --- /dev/null +++ b/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(us: readonly U[], t: T): t is U { + // @ts-expect-error @typescript-eslint/no-unsafe-argument + return us.includes(t); +} diff --git a/yarn.lock b/yarn.lock index 3270ee007..3d57841f1 100644 --- a/yarn.lock +++ b/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==