diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 6d911e51..680329d1 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -11,5 +11,5 @@ export interface User { email: string; full_name: string; short_name: string; - language: string; + language?: string; } diff --git a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx deleted file mode 100644 index a11e1ac5..00000000 --- a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { User } from '@/features/auth/api/types'; - -export interface ChangeUserLanguageParams { - userId: User['id']; - language: User['language']; -} - -export const changeUserLanguage = async ({ - userId, - language, -}: ChangeUserLanguageParams): Promise => { - const response = await fetchAPI(`users/${userId}/`, { - method: 'PATCH', - body: JSON.stringify({ - language, - }), - }); - - if (!response.ok) { - throw new APIError( - `Failed to change the user language to ${language}`, - await errorCauses(response, { - value: language, - type: 'language', - }), - ); - } - - return response.json() as Promise; -}; - -export function useChangeUserLanguage() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: changeUserLanguage, - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ['change-user-language'], - }); - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx similarity index 60% rename from src/frontend/apps/impress/src/features/language/LanguagePicker.tsx rename to src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx index fd60e220..7691e8ec 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx @@ -1,4 +1,3 @@ -import { Settings } from 'luxon'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -6,41 +5,29 @@ import { css } from 'styled-components'; import { DropdownMenu, Icon, Text } from '@/components/'; import { useConfig } from '@/core'; import { useAuthQuery } from '@/features/auth'; - -import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer'; -import { getMatchingLocales } from './utils/locale'; +import { + getMatchingLocales, + useSynchronizedLanguage, +} from '@/features/language'; export const LanguagePicker = () => { const { t, i18n } = useTranslation(); const { data: conf } = useConfig(); const { data: user } = useAuthQuery(); - const { synchronizeLanguage } = useLanguageSynchronizer(); - const language = i18n.languages[0]; - Settings.defaultLocale = language; + const { changeLanguageSynchronized } = useSynchronizedLanguage(); + const language = i18n.language; // Compute options for dropdown const optionsPicker = useMemo(() => { const backendOptions = conf?.LANGUAGES ?? [[language, language]]; - return backendOptions.map(([backendLocale, label]) => { - // Determine if the option is selected - const isSelected = - getMatchingLocales([backendLocale], [language]).length > 0; - // Define callback for updating both frontend and backend languages - const callback = () => { - i18n - .changeLanguage(backendLocale) - .then(() => { - if (conf?.LANGUAGES && user) { - synchronizeLanguage(conf.LANGUAGES, user, 'toBackend'); - } - }) - .catch((err) => { - console.error('Error changing language', err); - }); + return backendOptions.map(([backendLocale, backendLabel]) => { + return { + label: backendLabel, + isSelected: getMatchingLocales([backendLocale], [language]).length > 0, + callback: () => changeLanguageSynchronized(backendLocale, user), }; - return { label, isSelected, callback }; }); - }, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]); + }, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]); // Extract current language label for display const currentLanguageLabel = diff --git a/src/frontend/apps/impress/src/features/language/components/index.ts b/src/frontend/apps/impress/src/features/language/components/index.ts new file mode 100644 index 00000000..b5818aa7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/components/index.ts @@ -0,0 +1 @@ +export * from './LanguagePicker'; diff --git a/src/frontend/apps/impress/src/features/language/hooks/index.ts b/src/frontend/apps/impress/src/features/language/hooks/index.ts new file mode 100644 index 00000000..5c6dd71a --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useSynchronizedLanguage'; +export * from './useCustomTranslations'; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts b/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts new file mode 100644 index 00000000..a0f3a7d6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts @@ -0,0 +1,27 @@ +import { Resource } from 'i18next'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useCustomTranslations = () => { + const { i18n } = useTranslation(); + + // Overwrite translations with a resource + const customizeTranslations = useCallback( + (currentCustomTranslations: Resource) => { + Object.entries(currentCustomTranslations).forEach(([lng, namespaces]) => { + Object.entries(namespaces).forEach(([ns, value]) => { + i18n.addResourceBundle(lng, ns, value, true, true); + }); + }); + // trigger re-render + if (Object.entries(currentCustomTranslations).length > 0) { + void i18n.changeLanguage(i18n.language); + } + }, + [i18n], + ); + + return { + customizeTranslations, + }; +}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts deleted file mode 100644 index cded24a7..00000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useCallback, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -import type { ConfigResponse } from '@/core/config/api/useConfig'; -import { User } from '@/features/auth'; -import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage'; -import { getMatchingLocales } from '@/features/language/utils/locale'; -import { availableFrontendLanguages } from '@/i18n/initI18n'; - -export const useLanguageSynchronizer = () => { - const { i18n } = useTranslation(); - const { mutateAsync: changeUserLanguage } = useChangeUserLanguage(); - const languageSynchronizing = useRef(false); - - const synchronizeLanguage = useCallback( - ( - languages: ConfigResponse['LANGUAGES'], - user: User, - direction?: 'toBackend' | 'toFrontend', - ) => { - if (languageSynchronizing.current || !availableFrontendLanguages) { - return; - } - languageSynchronizing.current = true; - - try { - const availableBackendLanguages = languages.map(([locale]) => locale); - const userPreferredLanguages = user.language ? [user.language] : []; - const setOrDetectedLanguages = i18n.languages; - - // Default direction depends on whether a user already has a language preference - direction = - direction ?? - (userPreferredLanguages.length ? 'toFrontend' : 'toBackend'); - - if (direction === 'toBackend') { - const closestBackendLanguage = - getMatchingLocales( - availableBackendLanguages, - setOrDetectedLanguages, - )[0] || availableBackendLanguages[0]; - changeUserLanguage({ - userId: user.id, - language: closestBackendLanguage, - }).catch((error) => { - console.error('Error changing user language', error); - }); - } else { - const closestFrontendLanguage = - getMatchingLocales( - availableFrontendLanguages, - userPreferredLanguages, - )[0] || availableFrontendLanguages[0]; - if (i18n.resolvedLanguage !== closestFrontendLanguage) { - i18n.changeLanguage(closestFrontendLanguage).catch((error) => { - console.error('Error changing frontend language', error); - }); - } - } - } catch (error) { - console.error('Error synchronizing language', error); - } finally { - languageSynchronizing.current = false; - } - }, - [i18n, changeUserLanguage], - ); - - return { synchronizeLanguage }; -}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts b/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts new file mode 100644 index 00000000..997dc8bc --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useUserUpdate } from '@/core/api/useUserUpdate'; +import { useConfig } from '@/core/config/api/useConfig'; +import { User } from '@/features/auth'; +import { getMatchingLocales } from '@/features/language/utils/locale'; + +export const useSynchronizedLanguage = () => { + const { i18n } = useTranslation(); + const { mutateAsync: updateUser } = useUserUpdate(); + const { data: config } = useConfig(); + const isSynchronizingLanguage = useRef(false); + + const availableFrontendLanguages = useMemo( + () => Object.keys(i18n?.options?.resources || { en: '<- fallback' }), + [i18n?.options?.resources], + ); + const availableBackendLanguages = useMemo( + () => config?.LANGUAGES?.map(([locale]) => locale) || [], + [config?.LANGUAGES], + ); + + const changeBackendLanguage = useCallback( + async (language: string, user?: User) => { + const closestBackendLanguage = getMatchingLocales( + availableBackendLanguages, + [language], + )[0]; + + if (user && user.language !== closestBackendLanguage) { + await updateUser({ id: user.id, language: closestBackendLanguage }); + } + }, + [availableBackendLanguages, updateUser], + ); + + const changeFrontendLanguage = useCallback( + async (language: string) => { + const closestFrontendLanguage = getMatchingLocales( + availableFrontendLanguages, + [language], + )[0]; + if ( + i18n.isInitialized && + i18n.resolvedLanguage !== closestFrontendLanguage + ) { + await i18n.changeLanguage(closestFrontendLanguage); + } + }, + [availableFrontendLanguages, i18n], + ); + + const changeLanguageSynchronized = useCallback( + async (language: string, user?: User) => { + if (!isSynchronizingLanguage.current) { + isSynchronizingLanguage.current = true; + await changeFrontendLanguage(language); + await changeBackendLanguage(language, user); + isSynchronizingLanguage.current = false; + } + }, + [changeBackendLanguage, changeFrontendLanguage], + ); + + return { + changeLanguageSynchronized, + changeFrontendLanguage, + changeBackendLanguage, + }; +}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts deleted file mode 100644 index 8ba3eaa7..00000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts +++ /dev/null @@ -1,85 +0,0 @@ -import i18next, { Resource, i18n } from 'i18next'; -import { useCallback, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -import type { ConfigResponse } from '@/core/config/api/useConfig'; -import { safeLocalStorage } from '@/utils/storages'; - -export const useTranslationsCustomizer = () => { - const { i18n } = useTranslation(); - const translationsCustomizing = useRef(false); - - const customizeTranslations = useCallback( - ( - customTranslationsUrl: ConfigResponse['FRONTEND_CUSTOM_TRANSLATIONS_URL'], - cacheKey: string = 'CUSTOM_TRANSLATIONS', - ) => { - if (translationsCustomizing.current) { - return; - } - translationsCustomizing.current = true; - try { - if (!customTranslationsUrl) { - safeLocalStorage.setItem(cacheKey, ''); - } else { - const previousTranslationsString = safeLocalStorage.getItem(cacheKey); - if (previousTranslationsString) { - const previousTranslations = JSON.parse( - previousTranslationsString, - ) as Resource; - try { - applyTranslations(previousTranslations, i18n); - } catch (err: unknown) { - console.error('Error parsing cached translations:', err); - safeLocalStorage.setItem(cacheKey, ''); - } - } - - // Always update in background - fetchAndCacheTranslations(customTranslationsUrl, cacheKey) - .then((updatedTranslations) => { - if ( - updatedTranslations && - JSON.stringify(updatedTranslations) !== - previousTranslationsString - ) { - applyTranslations(updatedTranslations, i18n); - } - }) - .catch((err: unknown) => { - console.error('Error fetching custom translations:', err); - }); - } - } catch (err: unknown) { - console.error('Error updating custom translations:', err); - } finally { - translationsCustomizing.current = false; - } - }, - [i18n], - ); - - const applyTranslations = (translations: Resource, i18n: i18n) => { - Object.entries(translations).forEach(([lng, namespaces]) => { - Object.entries(namespaces).forEach(([ns, value]) => { - i18next.addResourceBundle(lng, ns, value, true, true); - }); - }); - const currentLanguage = i18n.language; - void i18next.changeLanguage(currentLanguage); - }; - - const fetchAndCacheTranslations = (url: string, CACHE_KEY: string) => { - return fetch(url).then((response) => { - if (!response.ok) { - throw new Error('Failed to fetch custom translations'); - } - return response.json().then((customTranslations: Resource) => { - safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(customTranslations)); - return customTranslations; - }); - }); - }; - - return { customizeTranslations }; -}; diff --git a/src/frontend/apps/impress/src/features/language/utils/index.ts b/src/frontend/apps/impress/src/features/language/utils/index.ts new file mode 100644 index 00000000..5501675d --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/utils/index.ts @@ -0,0 +1 @@ +export * from './locale';