diff --git a/src/account/i18n/i18n.tsx b/src/account/i18n/i18n.tsx index 807c9697..b05d19b4 100644 --- a/src/account/i18n/i18n.tsx +++ b/src/account/i18n/i18n.tsx @@ -1,8 +1,10 @@ import "keycloakify/tools/Object.fromEntries"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useMemo } from "react"; +import { useConst } from "keycloakify/tools/useConst"; +import { id } from "tsafe/id"; +import { assert } from "tsafe/assert"; import fallbackMessages from "./baseMessages/en"; import { getMessages } from "./baseMessages"; -import { assert } from "tsafe/assert"; import type { KcContext } from "../KcContext"; import { Reflect } from "tsafe/Reflect"; @@ -86,159 +88,211 @@ export type I18n = GenericI18n; export function createUseI18n(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string }; }) { - function useI18n(params: { kcContext: KcContextLike }): GenericI18n | null { + type I18n = GenericI18n; + + function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } { const { kcContext } = params; - const [i18n, setI18n] = useState | undefined>(undefined); + const partialI18n = useMemo( + (): Pick => ({ + currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag, + getChangeLocalUrl: newLanguageTag => { + const { locale } = kcContext; - const refHasStartedFetching = useRef(false); + assert(locale !== undefined, "Internationalization not enabled"); + + const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); + + assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); + + return targetSupportedLocale.url; + }, + labelBySupportedLanguageTag: Object.fromEntries( + (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) + ) + }), + [] + ); + + const { createI18nTranslationFunctions } = useMemo( + () => + createI18nTranslationFunctionsFactory({ + fallbackMessages, + extraFallbackMessages: extraMessages[fallbackLanguageTag], + extraMessages: extraMessages[partialI18n.currentLanguageTag] + }), + [] + ); + + const [i18n, setI18n] = useState(undefined); + + const refHasStartedFetching = useConst(() => ({ current: false })); useEffect(() => { if (refHasStartedFetching.current) { return; } + let isActive = true; + refHasStartedFetching.current = true; (async () => { - const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {}; + const messages = await getMessages(partialI18n.currentLanguageTag); + + if (!isActive) { + return; + } setI18n({ - ...createI18nTranslationFunctions({ - fallbackMessages: { - ...fallbackMessages, - ...(extraMessages[fallbackLanguageTag] ?? {}) - } as any, - messages: { - ...(await getMessages(currentLanguageTag)), - ...(extraMessages[currentLanguageTag] ?? {}) - } as any - }), - currentLanguageTag, - getChangeLocalUrl: newLanguageTag => { - const { locale } = kcContext; - - assert(locale !== undefined, "Internationalization not enabled"); - - const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); - - assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); - - return targetSupportedLocale.url; - }, - labelBySupportedLanguageTag: Object.fromEntries( - (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) - ) + ...partialI18n, + ...createI18nTranslationFunctions({ messages }) }); })(); + + return () => { + isActive = false; + }; }, []); - return i18n ?? null; + const pendingI18n = useMemo(() => { + if (i18n !== undefined) { + return undefined; + } + + return id({ + ...partialI18n, + ...createI18nTranslationFunctions({ messages: undefined }) + }); + }, []); + + return { + i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n), + isTranslationsDownloadOngoing: i18n === undefined + }; } return { useI18n, - ofTypeI18n: Reflect>() + ofTypeI18n: Reflect() }; } -function createI18nTranslationFunctions(params: { +/** Note exported only for hypothetical usage in non react framework */ +export function createI18nTranslationFunctionsFactory(params: { fallbackMessages: Record; - messages: Record; -}): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { - const { fallbackMessages, messages } = params; + extraFallbackMessages: Record | undefined; + extraMessages: Partial> | undefined; +}) { + const { extraMessages } = params; - function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { - const { key, args, doRenderAsHtml } = props; + const fallbackMessages = { + ...params.fallbackMessages, + ...params.extraFallbackMessages + }; - const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; + function createI18nTranslationFunctions(params: { + messages: Partial> | undefined; + }): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { + const messages = { + ...params.messages, + ...extraMessages + }; - if (messageOrUndefined === undefined) { - return undefined; - } + function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { + const { key, args, doRenderAsHtml } = props; - const message = messageOrUndefined; + const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; - const messageWithArgsInjectedIfAny = (() => { - const startIndex = message - .match(/{[0-9]+}/g) - ?.map(g => g.match(/{([0-9]+)}/)![1]) - .map(indexStr => parseInt(indexStr)) - .sort((a, b) => a - b)[0]; - - if (startIndex === undefined) { - // No {0} in message (no arguments expected) - return message; + if (messageOrUndefined === undefined) { + return undefined; } - let messageWithArgsInjected = message; + const message = messageOrUndefined; - args.forEach((arg, i) => { - if (arg === undefined) { - return; + const messageWithArgsInjectedIfAny = (() => { + const startIndex = message + .match(/{[0-9]+}/g) + ?.map(g => g.match(/{([0-9]+)}/)![1]) + .map(indexStr => parseInt(indexStr)) + .sort((a, b) => a - b)[0]; + + if (startIndex === undefined) { + // No {0} in message (no arguments expected) + return message; } - messageWithArgsInjected = messageWithArgsInjected.replace( - new RegExp(`\\{${i + startIndex}\\}`, "g"), - arg.replace(//g, ">") - ); - }); + let messageWithArgsInjected = message; - return messageWithArgsInjected; - })(); + args.forEach((arg, i) => { + if (arg === undefined) { + return; + } - return doRenderAsHtml ? ( - - ) : ( - messageWithArgsInjectedIfAny - ); - } + messageWithArgsInjected = messageWithArgsInjected.replace( + new RegExp(`\\{${i + startIndex}\\}`, "g"), + arg.replace(//g, ">") + ); + }); - function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { - const { key, args, doRenderAsHtml } = props; + return messageWithArgsInjected; + })(); - if (!/\$\{[^}]+\}/.test(key)) { - const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml }); - - if (resolvedMessage === undefined) { - return doRenderAsHtml ? : key; - } - - return resolvedMessage; + return doRenderAsHtml ? ( + + ) : ( + messageWithArgsInjectedIfAny + ); } - let isFirstMatch = true; + function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { + const { key, args, doRenderAsHtml } = props; - const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => { - const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i; + if (!/\$\{[^}]+\}/.test(key)) { + const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml }); - isFirstMatch = false; + if (resolvedMessage === undefined) { + return doRenderAsHtml ? : key; + } - return replaceBy; - }); + return resolvedMessage; + } - return doRenderAsHtml ? : resolvedComplexMessage; + let isFirstMatch = true; + + const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => { + const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i; + + isFirstMatch = false; + + return replaceBy; + }); + + return doRenderAsHtml ? : resolvedComplexMessage; + } + + return { + msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string, + msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element, + advancedMsg: (key, ...args) => + resolveMsgAdvanced({ + key, + args, + doRenderAsHtml: true + }) as JSX.Element, + advancedMsgStr: (key, ...args) => + resolveMsgAdvanced({ + key, + args, + doRenderAsHtml: false + }) as string + }; } - return { - msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string, - msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element, - advancedMsg: (key, ...args) => - resolveMsgAdvanced({ - key, - args, - doRenderAsHtml: true - }) as JSX.Element, - advancedMsgStr: (key, ...args) => - resolveMsgAdvanced({ - key, - args, - doRenderAsHtml: false - }) as string - }; + return { createI18nTranslationFunctions }; } diff --git a/src/login/i18n/i18n.tsx b/src/login/i18n/i18n.tsx index 97ca9fd0..1450624c 100644 --- a/src/login/i18n/i18n.tsx +++ b/src/login/i18n/i18n.tsx @@ -1,5 +1,7 @@ import "keycloakify/tools/Object.fromEntries"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useMemo } from "react"; +import { useConst } from "keycloakify/tools/useConst"; +import { id } from "tsafe/id"; import { assert } from "tsafe/assert"; import fallbackMessages from "./baseMessages/en"; import { getMessages } from "./baseMessages"; @@ -87,176 +89,228 @@ export type I18n = GenericI18n; export function createUseI18n(extraMessages: { [languageTag: string]: { [key in ExtraMessageKey]: string }; }) { - function useI18n(params: { kcContext: KcContextLike }): GenericI18n | null { + type I18n = GenericI18n; + + function useI18n(params: { kcContext: KcContextLike }): { i18n: I18n; isTranslationsDownloadOngoing: boolean } { const { kcContext } = params; - const [i18n, setI18n] = useState | undefined>(undefined); + const partialI18n = useMemo( + (): Pick => ({ + currentLanguageTag: kcContext.locale?.currentLanguageTag ?? fallbackLanguageTag, + getChangeLocalUrl: newLanguageTag => { + const { locale } = kcContext; - const refHasStartedFetching = useRef(false); + assert(locale !== undefined, "Internationalization not enabled"); + + const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); + + assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); + + return targetSupportedLocale.url; + }, + labelBySupportedLanguageTag: Object.fromEntries( + (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) + ) + }), + [] + ); + + const { createI18nTranslationFunctions } = useMemo( + () => + createI18nTranslationFunctionsFactory({ + fallbackMessages, + extraFallbackMessages: extraMessages[fallbackLanguageTag], + extraMessages: extraMessages[partialI18n.currentLanguageTag], + __localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile + }), + [] + ); + + const [i18n, setI18n] = useState(undefined); + + const refHasStartedFetching = useConst(() => ({ current: false })); useEffect(() => { if (refHasStartedFetching.current) { return; } + let isActive = true; + refHasStartedFetching.current = true; (async () => { - const { currentLanguageTag = fallbackLanguageTag } = kcContext.locale ?? {}; + const messages = await getMessages(partialI18n.currentLanguageTag); + + if (!isActive) { + return; + } setI18n({ - ...createI18nTranslationFunctions({ - fallbackMessages: { - ...fallbackMessages, - ...(extraMessages[fallbackLanguageTag] ?? {}) - } as any, - messages: { - ...(await getMessages(currentLanguageTag)), - ...(extraMessages[currentLanguageTag] ?? {}) - } as any, - __localizationRealmOverridesUserProfile: kcContext.__localizationRealmOverridesUserProfile - }), - currentLanguageTag, - getChangeLocalUrl: newLanguageTag => { - const { locale } = kcContext; - - assert(locale !== undefined, "Internationalization not enabled"); - - const targetSupportedLocale = locale.supported.find(({ languageTag }) => languageTag === newLanguageTag); - - assert(targetSupportedLocale !== undefined, `${newLanguageTag} need to be enabled in Keycloak admin`); - - return targetSupportedLocale.url; - }, - labelBySupportedLanguageTag: Object.fromEntries( - (kcContext.locale?.supported ?? []).map(({ languageTag, label }) => [languageTag, label]) - ) + ...partialI18n, + ...createI18nTranslationFunctions({ messages }) }); })(); + + return () => { + isActive = false; + }; }, []); - return i18n ?? null; + const pendingI18n = useMemo(() => { + if (i18n !== undefined) { + return undefined; + } + + return id({ + ...partialI18n, + ...createI18nTranslationFunctions({ messages: undefined }) + }); + }, []); + + return { + i18n: i18n ?? (assert(pendingI18n !== undefined), pendingI18n), + isTranslationsDownloadOngoing: i18n === undefined + }; } return { useI18n, - ofTypeI18n: Reflect>() + ofTypeI18n: Reflect() }; } -function createI18nTranslationFunctions(params: { +/** Note exported only for hypothetical usage in non react framework */ +export function createI18nTranslationFunctionsFactory(params: { fallbackMessages: Record; - messages: Record; + extraFallbackMessages: Record | undefined; + extraMessages: Partial> | undefined; __localizationRealmOverridesUserProfile: Record | undefined; -}): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { - const { fallbackMessages, messages, __localizationRealmOverridesUserProfile } = params; +}) { + const { __localizationRealmOverridesUserProfile, extraMessages } = params; - function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { - const { key, args, doRenderAsHtml } = props; + const fallbackMessages = { + ...params.fallbackMessages, + ...params.extraFallbackMessages + }; - const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; + function createI18nTranslationFunctions(params: { + messages: Partial> | undefined; + }): Pick, "msg" | "msgStr" | "advancedMsg" | "advancedMsgStr"> { + const messages = { + ...params.messages, + ...extraMessages + }; - if (messageOrUndefined === undefined) { - return undefined; - } + function resolveMsg(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): string | JSX.Element | undefined { + const { key, args, doRenderAsHtml } = props; - const message = messageOrUndefined; + const messageOrUndefined: string | undefined = (messages as any)[key] ?? (fallbackMessages as any)[key]; - const messageWithArgsInjectedIfAny = (() => { - const startIndex = message - .match(/{[0-9]+}/g) - ?.map(g => g.match(/{([0-9]+)}/)![1]) - .map(indexStr => parseInt(indexStr)) - .sort((a, b) => a - b)[0]; - - if (startIndex === undefined) { - // No {0} in message (no arguments expected) - return message; + if (messageOrUndefined === undefined) { + return undefined; } - let messageWithArgsInjected = message; + const message = messageOrUndefined; - args.forEach((arg, i) => { - if (arg === undefined) { - return; + const messageWithArgsInjectedIfAny = (() => { + const startIndex = message + .match(/{[0-9]+}/g) + ?.map(g => g.match(/{([0-9]+)}/)![1]) + .map(indexStr => parseInt(indexStr)) + .sort((a, b) => a - b)[0]; + + if (startIndex === undefined) { + // No {0} in message (no arguments expected) + return message; } - messageWithArgsInjected = messageWithArgsInjected.replace( - new RegExp(`\\{${i + startIndex}\\}`, "g"), - arg.replace(//g, ">") - ); - }); + let messageWithArgsInjected = message; - return messageWithArgsInjected; - })(); + args.forEach((arg, i) => { + if (arg === undefined) { + return; + } - return doRenderAsHtml ? ( - - ) : ( - messageWithArgsInjectedIfAny - ); - } + messageWithArgsInjected = messageWithArgsInjected.replace( + new RegExp(`\\{${i + startIndex}\\}`, "g"), + arg.replace(//g, ">") + ); + }); - function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { - const { key, args, doRenderAsHtml } = props; - - if (__localizationRealmOverridesUserProfile !== undefined && key in __localizationRealmOverridesUserProfile) { - const resolvedMessage = __localizationRealmOverridesUserProfile[key]; + return messageWithArgsInjected; + })(); return doRenderAsHtml ? ( ) : ( - resolvedMessage + messageWithArgsInjectedIfAny ); } - if (!/\$\{[^}]+\}/.test(key)) { - const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml }); + function resolveMsgAdvanced(props: { key: string; args: (string | undefined)[]; doRenderAsHtml: boolean }): JSX.Element | string { + const { key, args, doRenderAsHtml } = props; - if (resolvedMessage === undefined) { - return doRenderAsHtml ? : key; + if (__localizationRealmOverridesUserProfile !== undefined && key in __localizationRealmOverridesUserProfile) { + const resolvedMessage = __localizationRealmOverridesUserProfile[key]; + + return doRenderAsHtml ? ( + + ) : ( + resolvedMessage + ); } - return resolvedMessage; + if (!/\$\{[^}]+\}/.test(key)) { + const resolvedMessage = resolveMsg({ key, args, doRenderAsHtml }); + + if (resolvedMessage === undefined) { + return doRenderAsHtml ? : key; + } + + return resolvedMessage; + } + + let isFirstMatch = true; + + const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => { + const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i; + + isFirstMatch = false; + + return replaceBy; + }); + + return doRenderAsHtml ? : resolvedComplexMessage; } - let isFirstMatch = true; - - const resolvedComplexMessage = key.replace(/\$\{([^}]+)\}/g, (...[, key_i]) => { - const replaceBy = resolveMsg({ key: key_i, args: isFirstMatch ? args : [], doRenderAsHtml: false }) ?? key_i; - - isFirstMatch = false; - - return replaceBy; - }); - - return doRenderAsHtml ? : resolvedComplexMessage; + return { + msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string, + msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element, + advancedMsg: (key, ...args) => + resolveMsgAdvanced({ + key, + args, + doRenderAsHtml: true + }) as JSX.Element, + advancedMsgStr: (key, ...args) => + resolveMsgAdvanced({ + key, + args, + doRenderAsHtml: false + }) as string + }; } - return { - msgStr: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: false }) as string, - msg: (key, ...args) => resolveMsg({ key, args, doRenderAsHtml: true }) as JSX.Element, - advancedMsg: (key, ...args) => - resolveMsgAdvanced({ - key, - args, - doRenderAsHtml: true - }) as JSX.Element, - advancedMsgStr: (key, ...args) => - resolveMsgAdvanced({ - key, - args, - doRenderAsHtml: false - }) as string - }; + return { createI18nTranslationFunctions }; }