diff --git a/about_preload.js b/about_preload.js index 7c6bd8e3d5..6a6ff11d74 100644 --- a/about_preload.js +++ b/about_preload.js @@ -7,12 +7,11 @@ const os = require('os'); const { setupI18n } = require('./ts/util/i18n/i18n'); const config = url.parse(window.location.toString(), true).query; -const { dictionary, crowdinLocale } = ipcRenderer.sendSync('locale-data'); +const { crowdinLocale } = ipcRenderer.sendSync('locale-data'); window.theme = config.theme; window.i18n = setupI18n({ crowdinLocale, - translationDictionary: dictionary, }); window.getOSRelease = () => diff --git a/password_preload.js b/password_preload.js index d8dac4ad3b..39e87de082 100644 --- a/password_preload.js +++ b/password_preload.js @@ -6,7 +6,7 @@ const url = require('url'); const { setupI18n } = require('./ts/util/i18n/i18n'); const config = url.parse(window.location.toString(), true).query; -const { dictionary, crowdinLocale } = ipcRenderer.sendSync('locale-data'); +const { crowdinLocale } = ipcRenderer.sendSync('locale-data'); // If the app is locked we can't access the database to check the theme. window.theme = 'classic-dark'; @@ -14,7 +14,6 @@ window.primaryColor = 'green'; window.i18n = setupI18n({ crowdinLocale, - translationDictionary: dictionary, }); window.getEnvironment = () => config.environment; diff --git a/preload.js b/preload.js index 7857da8462..fb7ecdbaab 100644 --- a/preload.js +++ b/preload.js @@ -11,12 +11,12 @@ const _ = require('lodash'); const { setupI18n } = require('./ts/util/i18n/i18n'); -const { dictionary, crowdinLocale } = ipc.sendSync('locale-data'); +const { crowdinLocale } = ipc.sendSync('locale-data'); const config = url.parse(window.location.toString(), true).query; const configAny = config; -window.i18n = setupI18n({ crowdinLocale, translationDictionary: dictionary }); +window.i18n = setupI18n({ crowdinLocale }); let title = config.name; if (config.environment !== 'production') { diff --git a/tools/localization/localeTypes.py b/tools/localization/localeTypes.py index 7fee807479..a1d3cf2b72 100644 --- a/tools/localization/localeTypes.py +++ b/tools/localization/localeTypes.py @@ -93,6 +93,7 @@ def generate_type_object(locales): str: A string representation of the JavaScript object. """ js_object = "{\n" + js_plural_object_container = "{\n" plural_pattern = r"(zero|one|two|few|many|other)\s*\[([^\]]+)\]" for key, value_en in locales['en'].items(): @@ -113,7 +114,7 @@ def generate_type_object(locales): for plural in plurals_other: js_plural_object = "" - locale_key = plural[0] # 'lo', 'th', .... + locale_key = plural[0].replace("_","-") # 'lo', 'th', 'zh-CN', .... plural_str = plural[1].replace('#', '{count}') plurals_with_token = re.findall(plural_pattern, plural_str) @@ -139,7 +140,7 @@ def generate_type_object(locales): js_plural_object += "\n }," all_locales_plurals.append(js_plural_object) - js_object += f" {wrapValue(key)}: {{\n{"\n".join(all_locales_plurals)}\n args: {f"{as_record_type_en} as const," if as_record_type_en else 'undefined,'}\n }},\n" + js_plural_object_container += f" {wrapValue(key)}: {{\n{"\n".join(all_locales_plurals)}\n args: {as_record_type_en if as_record_type_en else 'undefined,'}\n }},\n" else: replaced_en = replace_static_strings(value_en) @@ -150,15 +151,16 @@ def generate_type_object(locales): all_locales_strings = [] for locale, replaced_val in other_locales_replaced_values: if replaced_val: - all_locales_strings.append(f"{locale}: \"{replaced_val.replace("\n", "\\n")}\"") + all_locales_strings.append(f"{wrapValue(locale.replace("_","-"))}: \"{replaced_val.replace("\n", "\\n")}\"") else: - all_locales_strings.append(f"{locale}: \"{replaced_en.replace("\n", "\\n")}\"") + all_locales_strings.append(f"{wrapValue(locale.replace("_","-"))}: \"{replaced_en.replace("\n", "\\n")}\"") # print('key',key, " other_locales_replaced_values:", other_locales_replaced_values) - js_object += f" {wrapValue(key)}: {{\n {",\n ".join(all_locales_strings)},\n args: {f"{as_record_type_en} as const," if as_record_type_en else 'undefined,'}\n }},\n" + js_object += f" {wrapValue(key)}: {{\n {",\n ".join(all_locales_strings)},\n args: {as_record_type_en if as_record_type_en else 'undefined,'}\n }},\n" js_object += "}" - return js_object + js_plural_object_container += "}" + return js_object,js_plural_object_container DISCLAIMER = """ @@ -205,9 +207,17 @@ def generateLocalesMergedType(locales): f"{DISCLAIMER}" ) - ts_file.write( - f"export const dictionary = {generate_type_object(locales)};\n\nexport type Dictionary = typeof dictionary;\n" - ) + dicts = generate_type_object(locales) + + dictVar = "simpleDictionary" + pluralDictVar = "pluralsDictionary" + + + ts_file.write(f""" +export const {dictVar} = {dicts[0]} as const; + +export const {pluralDictVar} = {dicts[1]} as const; +""") return f"Locales generated at: {OUTPUT_FILE}" diff --git a/ts/components/basic/Localizer.tsx b/ts/components/basic/Localizer.tsx index de3e65da53..2497558340 100644 --- a/ts/components/basic/Localizer.tsx +++ b/ts/components/basic/Localizer.tsx @@ -1,12 +1,12 @@ import styled from 'styled-components'; -import type { - ArgsRecord, - GetMessageArgs, - LocalizerComponentProps, - LocalizerDictionary, - LocalizerToken, -} from '../../types/localizer'; +import type { LocalizerComponentProps } from '../../types/localizer'; import { SessionHtmlRenderer } from './SessionHTMLRenderer'; +import { + GetMessageArgs, + MergedLocalizerTokens, + sanitizeArgs, +} from '../../localization/localeTools'; +import { getCrowdinLocale } from '../../util/i18n/shared'; /** An array of supported html tags to render if found in a string */ export const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span']; @@ -20,58 +20,6 @@ function createSupportedFormattingTagsRegex() { ); } -/** - * Replaces all html tag identifiers with their escaped equivalents - * @param str The string to sanitize - * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. - * @returns The sanitized string - */ -export function sanitizeHtmlTags(str: string, identifier: string = ''): string { - if (identifier && /[a-zA-Z0-9>/g, `${identifier}>${identifier}`); -} - -/** - * Replaces all sanitized html tags with their real equivalents - * @param str The string to de-sanitize - * @param identifier The identifier used when the args were sanitized - * @returns The de-sanitized string - */ -export function deSanitizeHtmlTags(str: string, identifier: string): string { - if (!identifier || /[a-zA-Z0-9>'); -} - -/** - * Sanitizes the args to be used in the i18n function - * @param args The args to sanitize - * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. - * @returns The sanitized args - */ -export function sanitizeArgs( - args: Record, - identifier?: string -): Record { - return Object.fromEntries( - Object.entries(args).map(([key, value]) => [ - key, - typeof value === 'string' ? sanitizeHtmlTags(value, identifier) : value, - ]) - ); -} - const StyledHtmlRenderer = styled.span` * > span { color: var(--renderer-span-primary-color); @@ -86,18 +34,12 @@ const StyledHtmlRenderer = styled.span` * @param props.as - An optional HTML tag to render the component as. Defaults to a fragment, unless the string contains html tags. In that case, it will render as HTML in a div tag. * * @returns The localized message string with substitutions and formatting applied. - * - * @example - * ```tsx - * - * - * - * ``` */ -export const Localizer = (props: LocalizerComponentProps) => { +export const Localizer = (props: LocalizerComponentProps) => { const args = 'args' in props ? props.args : undefined; - const rawString = window.i18n.getRawMessage( + const rawString = window.i18n.getRawMessage( + getCrowdinLocale(), ...([props.token, args] as GetMessageArgs) ); @@ -105,8 +47,8 @@ export const Localizer = (props: LocalizerComponentPro const cleanArgs = args && containsFormattingTags ? sanitizeArgs(args) : args; const i18nString = window.i18n.formatMessageWithArgs( - rawString as LocalizerDictionary[T], - cleanArgs as ArgsRecord + rawString, + cleanArgs as GetMessageArgs[1] ); return containsFormattingTags ? ( diff --git a/ts/components/basic/StyledI18nSubText.tsx b/ts/components/basic/StyledI18nSubText.tsx index 56a28c0b78..1bb6dcd528 100644 --- a/ts/components/basic/StyledI18nSubText.tsx +++ b/ts/components/basic/StyledI18nSubText.tsx @@ -1,7 +1,7 @@ import styled from 'styled-components'; import { forwardRef } from 'react'; import { Localizer } from './Localizer'; -import type { LocalizerComponentProps, LocalizerToken } from '../../types/localizer'; +import type { LocalizerComponentPropsObject } from '../../types/localizer'; const StyledI18nSubTextContainer = styled('div')` font-size: var(--font-size-md); @@ -15,13 +15,12 @@ const StyledI18nSubTextContainer = styled('div')` padding-inline: var(--margins-lg); `; -export const StyledI18nSubText = forwardRef< - HTMLSpanElement, - LocalizerComponentProps ->(({ className, ...props }) => { - return ( - - - - ); -}); +export const StyledI18nSubText = forwardRef( + ({ className, ...props }) => { + return ( + + + + ); + } +); diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index c768245dbd..522cd5de6d 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -17,11 +17,12 @@ import { ReleasedFeatures } from '../../util/releaseFeature'; import { Flex } from '../basic/Flex'; import { SpacerMD, TextWithChildren } from '../basic/Text'; import { ExpirableReadableMessage } from './message/message-item/ExpirableReadableMessage'; +import { LocalizerComponentPropsObject } from '../../types/localizer'; + // eslint-disable-next-line import/order import { ConversationInteraction } from '../../interactions'; import { ConvoHub } from '../../session/conversations'; import { updateConfirmModal } from '../../state/ducks/modalDialog'; -import type { LocalizerComponentProps, LocalizerToken } from '../../types/localizer'; import { Localizer } from '../basic/Localizer'; import { SessionButtonColor } from '../basic/SessionButton'; import { SessionIcon } from '../icon'; @@ -47,7 +48,7 @@ function useFollowSettingsButtonClick( ? window.i18n('disappearingMessagesTypeRead') : window.i18n('disappearingMessagesTypeSent'); - const i18nMessage: LocalizerComponentProps = props.disabled + const i18nMessage: LocalizerComponentPropsObject = props.disabled ? { token: 'disappearingMessagesFollowSettingOff', } diff --git a/ts/components/conversation/media-gallery/AttachmentSection.tsx b/ts/components/conversation/media-gallery/AttachmentSection.tsx index c40f2f2aaf..b0c4735e87 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.tsx @@ -38,7 +38,7 @@ const Items = (props: Props): JSX.Element => { /> ); default: - return missingCaseError(type); + throw missingCaseError(type); } })} diff --git a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx index 003c1c3f97..dd89a98dae 100644 --- a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx +++ b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx @@ -1,15 +1,15 @@ import { CallNotificationType, PropsForCallNotification } from '../../../../../state/ducks/types'; import { useSelectedNicknameOrProfileNameOrShortenedPubkey } from '../../../../../state/selectors/selectedConversation'; -import type { LocalizerToken } from '../../../../../types/localizer'; import { SessionIconType } from '../../../../icon'; import { ExpirableReadableMessage } from '../ExpirableReadableMessage'; import { NotificationBubble } from './NotificationBubble'; import { Localizer } from '../../../../basic/Localizer'; +import { MergedLocalizerTokens } from '../../../../../localization/localeTools'; type StyleType = Record< CallNotificationType, - { notificationTextKey: LocalizerToken; iconType: SessionIconType; iconColor: string } + { notificationTextKey: MergedLocalizerTokens; iconType: SessionIconType; iconColor: string } >; const style = { diff --git a/ts/components/menu/Menu.tsx b/ts/components/menu/Menu.tsx index b032375483..d49c0a90d3 100644 --- a/ts/components/menu/Menu.tsx +++ b/ts/components/menu/Menu.tsx @@ -53,11 +53,11 @@ import { getIsMessageSection, } from '../../state/selectors/section'; import { useSelectedConversationKey } from '../../state/selectors/selectedConversation'; -import type { LocalizerToken } from '../../types/localizer'; import { SessionButtonColor } from '../basic/SessionButton'; import { ItemWithDataTestId } from './items/MenuItemWithDataTestId'; import { useLibGroupDestroyed } from '../../state/selectors/userGroups'; import { NetworkTime } from '../../util/NetworkTime'; +import { MergedLocalizerTokens } from '../../localization/localeTools'; /** Menu items standardized */ @@ -516,7 +516,7 @@ export const NotificationForConvoMenuItem = (): JSX.Element | null => { isPrivate ? n !== 'mentions_only' : true ).map((n: ConversationNotificationSettingType) => { // do this separately so typescript's compiler likes it - const keyToUse: LocalizerToken = + const keyToUse: MergedLocalizerTokens = n === 'all' || !n ? 'notificationsAllMessages' : n === 'disabled' diff --git a/ts/components/registration/components/BackButton.tsx b/ts/components/registration/components/BackButton.tsx index 2391974e78..fcc36e6ac7 100644 --- a/ts/components/registration/components/BackButton.tsx +++ b/ts/components/registration/components/BackButton.tsx @@ -17,7 +17,7 @@ import { deleteDbLocally } from '../../../util/accountManager'; import { Flex } from '../../basic/Flex'; import { SessionButtonColor } from '../../basic/SessionButton'; import { SessionIconButton } from '../../icon'; -import type { LocalizerComponentProps, LocalizerToken } from '../../../types/localizer'; +import type { LocalizerComponentPropsObject } from '../../../types/localizer'; /** Min height should match the onboarding step with the largest height this prevents the loading spinner from jumping around while still keeping things centered */ const StyledBackButtonContainer = styled(Flex)` @@ -38,7 +38,7 @@ export const BackButtonWithinContainer = ({ callback?: () => void; onQuitVisible?: () => void; shouldQuitOnClick?: boolean; - quitI18nMessageArgs: LocalizerComponentProps; + quitI18nMessageArgs: LocalizerComponentPropsObject; }) => { return ( void; onQuitVisible?: () => void; shouldQuitOnClick?: boolean; - quitI18nMessageArgs: LocalizerComponentProps; + quitI18nMessageArgs: LocalizerComponentPropsObject; }) => { const step = useOnboardStep(); const restorationStep = useOnboardAccountRestorationStep(); diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index cb1734caf6..a2fab258e4 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -50,7 +50,7 @@ import { Storage, setLastProfileUpdateTimestamp } from '../util/storage'; import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; import { ConversationInteractionStatus, ConversationInteractionType } from './types'; import { BlockedNumberController } from '../util'; -import type { LocalizerComponentProps, LocalizerToken } from '../types/localizer'; +import type { LocalizerComponentPropsObject } from '../types/localizer'; import { sendInviteResponseToGroup } from '../session/sending/group/GroupInviteResponse'; import { NetworkTime } from '../util/NetworkTime'; import { ClosedGroup } from '../session'; @@ -270,7 +270,7 @@ export const declineConversationWithConfirm = ({ const convoName = ConvoHub.use().get(conversationId)?.getNicknameOrRealUsernameOrPlaceholder(); - const i18nMessage: LocalizerComponentProps = isGroupV2 + const i18nMessage: LocalizerComponentPropsObject = isGroupV2 ? alsoBlock && originNameToBlock ? { token: 'blockDescription', args: { name: originNameToBlock } } // groupv2, and blocking by sender name : { token: 'groupInviteDelete' } // groupv2, and no info about the sender, falling back to delete only diff --git a/ts/localization/localeTools.ts b/ts/localization/localeTools.ts new file mode 100644 index 0000000000..1ad8b74ab0 --- /dev/null +++ b/ts/localization/localeTools.ts @@ -0,0 +1,284 @@ +import { isEmpty } from 'lodash'; +import { CrowdinLocale } from './constants'; +import { getMessage } from '../util/i18n/functions/getMessage'; +import { pluralsDictionary, simpleDictionary } from './locales'; + +export type SimpleDictionary = typeof simpleDictionary; +export type PluralDictionary = typeof pluralsDictionary; + +export type SimpleLocalizerTokens = keyof SimpleDictionary; +export type PluralLocalizerTokens = keyof PluralDictionary; + +export type MergedLocalizerTokens = SimpleLocalizerTokens | PluralLocalizerTokens; + +type Logger = (message: string) => void; +let logger: Logger | undefined; + +export function setLogger(cb: Logger) { + if (logger) { + // eslint-disable-next-line no-console + console.log('logger already initialized'); + } + logger = cb; +} + +function log(message: Parameters[0]) { + logger?.(message); +} + +export function isSimpleToken(token: string): token is SimpleLocalizerTokens { + return token in simpleDictionary; +} + +export function isPluralToken(token: string): token is PluralLocalizerTokens { + return token in pluralsDictionary; +} + +type TokenWithArgs = { + [K in keyof D]: D[K] extends { args: undefined } | { args: never } ? never : K; +}[keyof D]; + +type MergedTokenWithArgs = TokenWithArgs | TokenWithArgs; + +export function isTokenWithArgs(token: string): token is MergedTokenWithArgs { + return ( + (isSimpleToken(token) && !isEmpty(simpleDictionary[token]?.args)) || + (isPluralToken(token) && !isEmpty(pluralsDictionary[token]?.args)) + ); +} + +type DynamicArgStr = 'string' | 'number'; + +export type LocalizerDictionary = SimpleDictionary; + +type ArgsTypeStrToTypes = T extends 'string' + ? string + : T extends 'number' + ? number + : never; + +// those are still a string of the type "string" | "number" and not the typescript types themselves +type ArgsFromTokenStr = + T extends SimpleLocalizerTokens + ? SimpleDictionary[T] extends { args: infer A } + ? A extends Record + ? A + : never + : never + : T extends PluralLocalizerTokens + ? PluralDictionary[T] extends { args: infer A } + ? A extends Record + ? A + : never + : never + : never; + +export type ArgsFromToken = MappedToTsTypes>; + +/** The arguments for retrieving a localized message */ +export type GetMessageArgs = T extends MergedLocalizerTokens + ? T extends MergedTokenWithArgs + ? [T, ArgsFromToken] + : [T] + : never; + +type MappedToTsTypes> = { + [K in keyof T]: ArgsTypeStrToTypes; +}; + +/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.inEnglish } and {@link window.i18n.inEnglish } */ +/** + * Retrieves a message string in the {@link en} locale, substituting variables where necessary. + * + * NOTE: This does not work for plural strings. This function should only be used for debug and + * non-user-facing strings. Plural string support can be added splitting out the logic for + * {@link setupI18n.formatMessageWithArgs} and creating a new getMessageFromDictionary, which + * specifies takes a dictionary as an argument. This is left as an exercise for the reader. + * @deprecated this will eventually be replaced by LocalizedStringBuilder + * + * @param token - The token identifying the message to retrieve. + * @param args - An optional record of substitution variables and their replacement values. This is equired if the string has dynamic variables. + */ +export function inEnglish([token, args]: GetMessageArgs) { + if (!isSimpleToken(token)) { + throw new Error('inEnglish only supports simple strings for now'); + } + const rawMessage = simpleDictionary[token].en; + + if (!rawMessage) { + log(`Attempted to get forced en string for nonexistent key: '${token}' in fallback dictionary`); + return token; + } + return formatMessageWithArgs(rawMessage, args); +} + +/** + * Retrieves a localized message string, substituting variables where necessary. Then strips the message of any HTML and custom tags. + * + * @deprecated This will eventually be replaced altogether by LocalizedStringBuilder + * + * @param token - The token identifying the message to retrieve. + * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. + * + * @returns The localized message string with substitutions applied. Any HTML and custom tags are removed. + */ + +export function stripped( + ...[token, args]: GetMessageArgs +): string { + const sanitizedArgs = args ? sanitizeArgs(args, '\u200B') : undefined; + + const i18nString = getMessage(...([token, sanitizedArgs] as GetMessageArgs)); + + const strippedString = i18nString.replaceAll(/<[^>]*>/g, ''); + + return deSanitizeHtmlTags(strippedString, '\u200B'); +} + +/** + * Sanitizes the args to be used in the i18n function + * @param args The args to sanitize + * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. + * @returns The sanitized args + */ +export function sanitizeArgs( + args: Record, + identifier?: string +): Record { + return Object.fromEntries( + Object.entries(args).map(([key, value]) => [ + key, + typeof value === 'string' ? sanitizeHtmlTags(value, identifier) : value, + ]) + ); +} + +/** + * Formats a localized message string with arguments and returns the formatted string. + * @param rawMessage - The raw message string to format. After using @see {@link getRawMessage} to get the raw string. + * @param args - An optional record of substitution variables and their replacement values. This + * is required if the string has dynamic variables. This can be optional as a strings args may be defined in @see {@link LOCALE_DEFAULTS} + * + * @returns The formatted message string. + * + * @deprecated + * + */ +export function formatMessageWithArgs( + rawMessage: string, + args?: ArgsFromToken +): string { + /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */ + return rawMessage.replace(/\{(\w+)\}/g, (match: any, arg: string) => { + const matchedArg = args ? args[arg as keyof typeof args] : undefined; + + return matchedArg?.toString() ?? match; + }); +} + +/** + * Retrieves a localized message string, without substituting any variables. This resolves any plural forms using the given args + * @param token - The token identifying the message to retrieve. + * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. + * + * @returns The localized message string with substitutions applied. + * + * NOTE: This is intended to be used to get the raw string then format it with {@link formatMessageWithArgs} + */ +export function getRawMessage( + crowdinLocale: CrowdinLocale, + ...[token, args]: GetMessageArgs +): string { + try { + if ( + typeof window !== 'undefined' && + window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys + ) { + return token as T; + } + + if (isSimpleToken(token)) { + return simpleDictionary[token][crowdinLocale]; + } + if (!isPluralToken(token)) { + throw new Error('invalid token, neither simple nor plural'); + } + const pluralsObjects = pluralsDictionary[token]; + const localePluralsObject = pluralsObjects[crowdinLocale]; + + if (!localePluralsObject || isEmpty(localePluralsObject)) { + log(`Attempted to get translation for nonexistent key: '${token}'`); + return token; + } + + const num = args && 'count' in args ? args.count : 0; + + const cardinalRule = new Intl.PluralRules(crowdinLocale).select(num); + + const pluralString = getStringForRule({ + dictionary: pluralsDictionary, + crowdinLocale, + cardinalRule, + token, + }); + + if (!pluralString) { + log(`Plural string not found for cardinal '${cardinalRule}': '${pluralString}'`); + return token as T; + } + + return pluralString.replaceAll('#', `${num}`); + } catch (error) { + log(error.message); + return token as T; + } +} + +export function getStringForRule({ + dictionary, + token, + crowdinLocale, + cardinalRule, +}: { + dictionary: PluralDictionary; + token: PluralLocalizerTokens; + crowdinLocale: CrowdinLocale; + cardinalRule: Intl.LDMLPluralRule; +}) { + const dictForLocale = dictionary[token][crowdinLocale]; + return cardinalRule in dictForLocale ? ((dictForLocale as any)[cardinalRule] as string) : token; +} + +/** + * Replaces all html tag identifiers with their escaped equivalents + * @param str The string to sanitize + * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. + * @returns The sanitized string + */ +export function sanitizeHtmlTags(str: string, identifier: string = ''): string { + if (identifier && /[a-zA-Z0-9>/g, `${identifier}>${identifier}`); +} + +/** + * Replaces all sanitized html tags with their real equivalents + * @param str The string to de-sanitize + * @param identifier The identifier used when the args were sanitized + * @returns The de-sanitized string + */ +export function deSanitizeHtmlTags(str: string, identifier: string): string { + if (!identifier || /[a-zA-Z0-9>'); +} diff --git a/ts/mains/main_node.ts b/ts/mains/main_node.ts index 243203b01e..158f1bb6c8 100644 --- a/ts/mains/main_node.ts +++ b/ts/mains/main_node.ts @@ -163,12 +163,9 @@ import { isDevProd, isTestIntegration } from '../shared/env_vars'; import { classicDark } from '../themes'; import type { SetupI18nReturnType } from '../types/localizer'; -import { - isSessionLocaleSet, - getTranslationDictionary, - getCrowdinLocale, -} from '../util/i18n/shared'; +import { isSessionLocaleSet, getCrowdinLocale } from '../util/i18n/shared'; import { loadLocalizedDictionary } from '../node/locale'; +import { simpleDictionary } from '../localization/locales'; // Both of these will be set after app fires the 'ready' event let logger: Logger | null = null; @@ -910,7 +907,6 @@ app.on('web-contents-created', (_createEvent, contents) => { ipc.on('locale-data', event => { // eslint-disable-next-line no-param-reassign event.returnValue = { - dictionary: getTranslationDictionary(), crowdinLocale: getCrowdinLocale(), }; }); @@ -984,7 +980,7 @@ ipc.on('password-recovery-phrase', async (event, passPhrase) => { // no issues. send back undefined, meaning OK sendResponse(undefined); } catch (e) { - const localisedError = getTranslationDictionary().passwordIncorrect; + const localisedError = simpleDictionary.passwordIncorrect[getCrowdinLocale()]; // send back the error sendResponse(localisedError); } diff --git a/ts/models/message.ts b/ts/models/message.ts index 0beff6b158..1eb4812551 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -88,7 +88,6 @@ import { ConversationModel } from './conversation'; import { READ_MESSAGE_STATE } from './conversationAttributes'; import { ConversationInteractionStatus, ConversationInteractionType } from '../interactions/types'; import { LastMessageStatusType } from '../state/ducks/types'; -import type { GetMessageArgs, LocalizerToken } from '../types/localizer'; import { getGroupDisplayPictureChangeStr, getGroupNameChangeStr, @@ -273,23 +272,21 @@ export class MessageModel extends Backbone.Model { // @ts-expect-error -- TODO: Fix by using new i18n builder const { token, args } = getLeftGroupUpdateChangeStr(groupUpdate.left, groupName); // TODO: clean up this typing - return window.i18n.stripped(...([token, args] as GetMessageArgs)); + return window.i18n.stripped(...[token, args]); } if (groupUpdate.name) { const result = getGroupNameChangeStr(groupUpdate.name); if ('args' in result) { - return window.i18n.stripped( - ...([result.token, result.args] as GetMessageArgs) - ); + return window.i18n.stripped(...[result.token, result.args]); } - return window.i18n.stripped(...([result.token] as GetMessageArgs)); + return window.i18n.stripped(...[result.token]); } if (groupUpdate.avatarChange) { const result = getGroupDisplayPictureChangeStr(); - return window.i18n.stripped(...([result.token] as GetMessageArgs)); + return window.i18n.stripped(...[result.token]); } if (groupUpdate.joined?.length) { @@ -301,7 +298,7 @@ export class MessageModel extends Backbone.Model { groupName ); // TODO: clean up this typing - return window.i18n.stripped(...([token, args] as GetMessageArgs)); + return window.i18n.stripped(...[token, args]); } if (groupUpdate.joinedWithHistory?.length) { @@ -313,20 +310,20 @@ export class MessageModel extends Backbone.Model { groupName ); // TODO: clean up this typing - return window.i18n.stripped(...([token, args] as GetMessageArgs)); + return window.i18n.stripped(...[token, args]); } if (groupUpdate.kicked?.length) { // @ts-expect-error -- TODO: Fix by using new i18n builder const { token, args } = getKickedGroupUpdateStr(groupUpdate.kicked, groupName); // TODO: clean up this typing - return window.i18n.stripped(...([token, args] as GetMessageArgs)); + return window.i18n.stripped(...[token, args]); } if (groupUpdate.promoted?.length) { // @ts-expect-error -- TODO: Fix by using new i18n builder const { token, args } = getPromotedGroupUpdateChangeStr(groupUpdate.promoted, groupName); // TODO: clean up this typing - return window.i18n.stripped(...([token, args] as GetMessageArgs)); + return window.i18n.stripped(...[token, args]); } window.log.warn('did not build a specific change for getDescription of ', groupUpdate); @@ -433,11 +430,9 @@ export class MessageModel extends Backbone.Model { }); if ('args' in i18nProps) { - return window.i18n.stripped( - ...([i18nProps.token, i18nProps.args] as GetMessageArgs) - ); + return window.i18n.stripped(...[i18nProps.token, i18nProps.args]); } - return window.i18n.stripped(...([i18nProps.token] as GetMessageArgs)); + return window.i18n.stripped(...[i18nProps.token]); } const body = this.get('body'); if (body) { diff --git a/ts/node/locale.ts b/ts/node/locale.ts index 9363b67965..1a28c81dc4 100644 --- a/ts/node/locale.ts +++ b/ts/node/locale.ts @@ -1,9 +1,4 @@ -import fs from 'fs'; -import path from 'path'; -import { isEmpty } from 'lodash'; -import type { LocalizerDictionary, SetupI18nReturnType } from '../types/localizer'; -import { getAppRootPath } from './getRootPath'; -import { en } from '../localization/locales'; +import type { SetupI18nReturnType } from '../types/localizer'; import { setupI18n } from '../util/i18n/i18n'; import { CrowdinLocale } from '../localization/constants'; @@ -32,18 +27,6 @@ export function normalizeLocaleName(locale: string) { return dashedLocale; } -function getLocaleMessages(locale: string): LocalizerDictionary { - if (locale.includes('_')) { - throw new Error( - "getLocaleMessages: expected locale to not have a '_' in it. Those should have been replaced to -" - ); - } - - const targetFile = path.join(getAppRootPath(), '_locales', locale, 'messages.json'); - - return JSON.parse(fs.readFileSync(targetFile, 'utf-8')); -} - export function loadLocalizedDictionary({ appLocale, logger, @@ -67,24 +50,10 @@ export function loadLocalizedDictionary({ // // possible locales: // https://github.com/electron/electron/blob/master/docs/api/locales.md - let crowdinLocale = normalizeLocaleName(appLocale) as CrowdinLocale; - let translationDictionary; - - try { - translationDictionary = getLocaleMessages(crowdinLocale); - } catch (e) { - logger.error(`Problem loading messages for locale ${crowdinLocale} ${e.stack}`); - logger.error('Falling back to en locale'); - } - - if (!translationDictionary || isEmpty(translationDictionary)) { - translationDictionary = en; - crowdinLocale = 'en'; - } + const crowdinLocale = normalizeLocaleName(appLocale) as CrowdinLocale; const i18n = setupI18n({ crowdinLocale, - translationDictionary, }); return { diff --git a/ts/test/session/unit/utils/i18n/util.ts b/ts/test/session/unit/utils/i18n/util.ts index 356a37c22a..2b5b13dd44 100644 --- a/ts/test/session/unit/utils/i18n/util.ts +++ b/ts/test/session/unit/utils/i18n/util.ts @@ -1,5 +1,3 @@ -import { en } from '../../../../../localization/locales'; -import type { LocalizerDictionary } from '../../../../../types/localizer'; import { setupI18n } from '../../../../../util/i18n/i18n'; export const testDictionary = { @@ -11,10 +9,9 @@ export const testDictionary = { argInTag: 'Hello, {name}! Welcome, {arg}!', } as const; -export function initI18n(dictionary: Record = en) { +export function initI18n() { return setupI18n({ // testing crowdinLocale: 'en', - translationDictionary: dictionary as LocalizerDictionary, // testing }); } diff --git a/ts/types/localizer.d.ts b/ts/types/localizer.d.ts index 965675b8c1..6864c95026 100644 --- a/ts/types/localizer.d.ts +++ b/ts/types/localizer.d.ts @@ -1,90 +1,46 @@ import type { ElementType } from 'react'; -import type { Dictionary } from '../localization/locales'; - -/** The dictionary of localized strings */ -export type LocalizerDictionary = Dictionary; - -/** A localization dictionary key */ -export type LocalizerToken = keyof Dictionary; - -/** A dynamic argument that can be used in a localized string */ -type DynamicArg = string | number; -type DynamicArgStr = 'string' | 'number'; - -/** A record of dynamic arguments for a specific key in the localization dictionary */ -export type ArgsRecord = Record, DynamicArg>; - -// TODO: create a proper type for this -export type DictionaryWithoutPluralStrings = Dictionary; -export type PluralKey = 'count'; -export type PluralString = `{${string}, plural, one [${string}] other [${string}]}`; - -type ArgsTypeStrToTypes = T extends 'string' - ? string - : T extends 'number' - ? number - : never; - -// those are still a string of the type "string" | "number" and not the typescript types themselves -type ArgsFromTokenStr = Dictionary[T]['args'] extends undefined - ? never - : Dictionary[T]['args']; - -type ArgsFromToken = MappedToTsTypes>; -type IsTokenWithCountArgs = 'count' extends keyof ArgsFromToken - ? true - : false; - -/** The arguments for retrieving a localized message */ -export type GetMessageArgs = T extends LocalizerToken - ? ArgsFromToken extends never - ? [T] - : [T, ArgsFromToken] - : never; - -type MappedToTsTypes> = { - [K in keyof T]: ArgsTypeStrToTypes; -}; +import type { ArgsFromToken, MergedLocalizerTokens } from '../localization/localeTools'; +import { CrowdinLocale } from '../localization/constants'; /** Basic props for all calls of the Localizer component */ -type LocalizerComponentBaseProps = { +type LocalizerComponentBaseProps = { token: T; asTag?: ElementType; className?: string; }; /** The props for the localization component */ -export type LocalizerComponentProps = T extends LocalizerToken - ? ArgsFromToken extends never - ? LocalizerComponentBaseProps - : ArgsFromToken extends Record +export type LocalizerComponentProps = + T extends MergedLocalizerTokens + ? ArgsFromToken extends never ? LocalizerComponentBaseProps - : LocalizerComponentBaseProps & { args: ArgsFromToken } - : never; + : ArgsFromToken extends Record + ? LocalizerComponentBaseProps + : LocalizerComponentBaseProps & { args: ArgsFromToken } + : never; -export type LocalizerComponentPropsObject = LocalizerComponentProps; +export type LocalizerComponentPropsObject = LocalizerComponentProps; export type I18nMethods = { /** @see {@link window.i18n.stripped} */ - stripped: ( + stripped: ( ...[token, args]: GetMessageArgs ) => R | T; /** @see {@link window.i18n.inEnglish} */ - inEnglish: ( + inEnglish: ( ...[token, args]: GetMessageArgs ) => R | T; /** @see {@link window.i18n.formatMessageWithArgs */ - getRawMessage: ( + getRawMessage: ( + crowdinLocale: CrowdinLocale, ...[token, args]: GetMessageArgs - ) => R | T; + ) => string; /** @see {@link window.i18n.formatMessageWithArgs} */ - formatMessageWithArgs: ( - rawMessage: R, - args?: ArgsRecord - ) => R; + formatMessageWithArgs: ( + rawMessage: string, + args?: ArgsFromToken + ) => string; }; export type SetupI18nReturnType = I18nMethods & - (( - ...[token, args]: GetMessageArgs - ) => R); + ((...[token, args]: GetMessageArgs) => string); diff --git a/ts/util/i18n/functions/formatMessageWithArgs.ts b/ts/util/i18n/functions/formatMessageWithArgs.ts deleted file mode 100644 index 5890d798b0..0000000000 --- a/ts/util/i18n/functions/formatMessageWithArgs.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.formatMessageWithArgs } and {@link window.i18n.formatMessageWithArgs } */ - -import { LOCALE_DEFAULTS } from '../../../localization/constants'; -import type { - ArgsRecord, - DictionaryWithoutPluralStrings, - LocalizerToken, -} from '../../../types/localizer'; - -/** - * Formats a localized message string with arguments and returns the formatted string. - * @param rawMessage - The raw message string to format. After using @see {@link getRawMessage} to get the raw string. - * @param args - An optional record of substitution variables and their replacement values. This - * is required if the string has dynamic variables. This can be optional as a strings args may be defined in @see {@link LOCALE_DEFAULTS} - * - * @returns The formatted message string. - * - * @deprecated - * - * @example - * // The string greeting is 'Hello, {name}!' in the current locale - * window.i18n.getRawMessage('greeting', { name: 'Alice' }); - * // => 'Hello, {name}!' - * window.i18n.formatMessageWithArgs('Hello, {name}!', { name: 'Alice' }); - * // => 'Hello, Alice!' - * - * // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale - * window.i18n.getRawMessage('search', { count: 1, found_count: 1 }); - * // => '{found_count} of {count} match' - * window.i18n.formatMessageWithArgs('{found_count} of {count} match', { count: 1, found_count: 1 }); - * // => '1 of 1 match' - */ -export function formatMessageWithArgs< - T extends LocalizerToken, - R extends DictionaryWithoutPluralStrings[T], ->(rawMessage: R, args?: ArgsRecord): R { - /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */ - // TODO: remove the type casting once we have a proper DictionaryWithoutPluralStrings type - return (rawMessage as `${string}{${string}}${string}`).replace( - /\{(\w+)\}/g, - (match, arg: string) => { - const matchedArg = args ? args[arg as keyof typeof args] : undefined; - - return ( - matchedArg?.toString() ?? LOCALE_DEFAULTS[arg as keyof typeof LOCALE_DEFAULTS] ?? match - ); - } - ) as R; -} diff --git a/ts/util/i18n/functions/getMessage.ts b/ts/util/i18n/functions/getMessage.ts index db3c432cdc..6c95764616 100644 --- a/ts/util/i18n/functions/getMessage.ts +++ b/ts/util/i18n/functions/getMessage.ts @@ -1,17 +1,17 @@ /** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.getMessage } and {@link window.i18n } */ -import type { - LocalizerToken, - LocalizerDictionary, - GetMessageArgs, - SetupI18nReturnType, -} from '../../../types/localizer'; +import type { SetupI18nReturnType } from '../../../types/localizer'; import { i18nLog } from '../shared'; -import { formatMessageWithArgs } from './formatMessageWithArgs'; -import { getRawMessage } from './getRawMessage'; -import { inEnglish } from './inEnglish'; -import { stripped } from './stripped'; -import { localizeFromOld, type StringArgsRecord } from '../localizedString'; +import { localizeFromOld } from '../localizedString'; +import { + ArgsFromToken, + formatMessageWithArgs, + GetMessageArgs, + getRawMessage, + inEnglish, + MergedLocalizerTokens, + stripped, +} from '../../../localization/localeTools'; /** * Retrieves a localized message string, substituting variables where necessary. @@ -20,24 +20,15 @@ import { localizeFromOld, type StringArgsRecord } from '../localizedString'; * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. * * @returns The localized message string with substitutions applied. - * - * @example - * // The string greeting is 'Hello, {name}!' in the current locale - * window.i18n('greeting', { name: 'Alice' }); - * // => 'Hello, Alice!' - * - * // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale - * window.i18n('search', { count: 1, found_count: 1 }); - * // => '1 of 1 match' */ -function getMessageDefault( - ...[token, args]: GetMessageArgs -): R | T { +function getMessageDefault(...props: GetMessageArgs): string { + const token = props[0]; + try { - return localizeFromOld(token as T, args as StringArgsRecord).toString() as T | R; + return localizeFromOld(props[0], props[1] as ArgsFromToken).toString(); } catch (error) { i18nLog(error.message); - return token as R; + return token; } } diff --git a/ts/util/i18n/functions/getRawMessage.ts b/ts/util/i18n/functions/getRawMessage.ts deleted file mode 100644 index bd04d549eb..0000000000 --- a/ts/util/i18n/functions/getRawMessage.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.getRawMessage } and {@link window.i18n.getRawMessage } */ - -import { en } from '../../../localization/locales'; -import type { - DictionaryWithoutPluralStrings, - GetMessageArgs, - LocalizerToken, - PluralKey, - PluralString, -} from '../../../types/localizer'; -import { - getTranslationDictionary, - getStringForCardinalRule, - i18nLog, - getCrowdinLocale, -} from '../shared'; - -function getPluralKey(string: PluralString): R { - const match = /{(\w+), plural, one \[.+\] other \[.+\]}/g.exec(string); - return (match?.[1] ?? undefined) as R; -} - -const isPluralForm = (localizedString: string): localizedString is PluralString => - /{\w+, plural, one \[.+\] other \[.+\]}/g.test(localizedString); - -/** - * Retrieves a localized message string, without substituting any variables. This resolves any plural forms using the given args - * @param token - The token identifying the message to retrieve. - * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. - * - * @returns The localized message string with substitutions applied. - * - * NOTE: This is intended to be used to get the raw string then format it with {@link formatMessageWithArgs} - * - * @example - * // The string greeting is 'Hello, {name}!' in the current locale - * window.i18n.getRawMessage('greeting', { name: 'Alice' }); - * // => 'Hello, {name}!' - * - * // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale - * window.i18n.getRawMessage('search', { count: 1, found_count: 1 }); - * // => '{found_count} of {count} match' - */ -export function getRawMessage< - T extends LocalizerToken, - R extends DictionaryWithoutPluralStrings[T], ->(...[token, args]: GetMessageArgs): R | T { - try { - if ( - typeof window !== 'undefined' && - window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys - ) { - return token as T; - } - - const localizedDictionary = getTranslationDictionary(); - - let localizedString = localizedDictionary[token] as R; - - if (!localizedString) { - i18nLog(`Attempted to get translation for nonexistent key: '${token}'`); - - localizedString = en[token] as R; - - if (!localizedString) { - i18nLog( - `Attempted to get translation for nonexistent key: '${token}' in fallback dictionary` - ); - return token as T; - } - } - - /** If a localized string does not have any arguments to substitute it is returned with no - * changes. We also need to check if the string contains a curly bracket as if it does - * there might be a default arg */ - if (!args && !localizedString.includes('{')) { - return localizedString; - } - - if (isPluralForm(localizedString)) { - const pluralKey = getPluralKey(localizedString); - - if (!pluralKey) { - i18nLog(`Attempted to nonexistent pluralKey for plural form string '${localizedString}'`); - } else { - const num = args?.[pluralKey as keyof typeof args] ?? 0; - - const currentLocale = getCrowdinLocale(); - const cardinalRule = new Intl.PluralRules(currentLocale).select(num); - - const pluralString = getStringForCardinalRule(localizedString, cardinalRule); - - if (!pluralString) { - i18nLog(`Plural string not found for cardinal '${cardinalRule}': '${localizedString}'`); - return token as T; - } - - localizedString = pluralString.replaceAll('#', `${num}`) as R; - } - } - return localizedString; - } catch (error) { - i18nLog(error.message); - return token as T; - } -} diff --git a/ts/util/i18n/functions/inEnglish.ts b/ts/util/i18n/functions/inEnglish.ts deleted file mode 100644 index 24447c10d2..0000000000 --- a/ts/util/i18n/functions/inEnglish.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.inEnglish } and {@link window.i18n.inEnglish } */ - -import { en } from '../../../localization/locales'; -import type { - LocalizerToken, - LocalizerDictionary, - GetMessageArgs, - ArgsRecord, -} from '../../../types/localizer'; -import { i18nLog } from '../shared'; -import { formatMessageWithArgs } from './formatMessageWithArgs'; - -/** - * Retrieves a message string in the {@link en} locale, substituting variables where necessary. - * - * NOTE: This does not work for plural strings. This function should only be used for debug and - * non-user-facing strings. Plural string support can be added splitting out the logic for - * {@link setupI18n.formatMessageWithArgs} and creating a new getMessageFromDictionary, which - * specifies takes a dictionary as an argument. This is left as an exercise for the reader. - * @deprecated this will eventually be replaced by LocalizedStringBuilder - * - * @param token - The token identifying the message to retrieve. - * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. - */ -export function inEnglish( - ...[token, args]: GetMessageArgs -): R | T { - const rawMessage = en[token] as R; - if (!rawMessage) { - i18nLog( - `Attempted to get forced en string for nonexistent key: '${token}' in fallback dictionary` - ); - return token as T; - } - return formatMessageWithArgs(rawMessage, args as ArgsRecord); -} diff --git a/ts/util/i18n/functions/stripped.ts b/ts/util/i18n/functions/stripped.ts deleted file mode 100644 index 1c490b81c2..0000000000 --- a/ts/util/i18n/functions/stripped.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.stripped } and {@link window.i18n.stripped } */ - -import { deSanitizeHtmlTags, sanitizeArgs } from '../../../components/basic/Localizer'; -import type { GetMessageArgs, LocalizerDictionary, LocalizerToken } from '../../../types/localizer'; -import { getMessage } from './getMessage'; - -/** - * Retrieves a localized message string, substituting variables where necessary. Then strips the message of any HTML and custom tags. - * - * @deprecated This will eventually be replaced altogether by LocalizedStringBuilder - * - * @param token - The token identifying the message to retrieve. - * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. - * - * @returns The localized message string with substitutions applied. Any HTML and custom tags are removed. - * - * @example - * // The string greeting is 'Hello, {name}! Welcome!' in the current locale - * window.i18n.stripped('greeting', { name: 'Alice' }); - * // => 'Hello, Alice! Welcome!' - */ -export function stripped( - ...[token, args]: GetMessageArgs -): R | T { - const sanitizedArgs = args ? sanitizeArgs(args, '\u200B') : undefined; - - const i18nString = getMessage( - ...([token, sanitizedArgs] as GetMessageArgs) - ); - - const strippedString = i18nString.replaceAll(/<[^>]*>/g, ''); - - return deSanitizeHtmlTags(strippedString, '\u200B') as R; -} diff --git a/ts/util/i18n/i18n.ts b/ts/util/i18n/i18n.ts index ccef5390ca..677b9e0579 100644 --- a/ts/util/i18n/i18n.ts +++ b/ts/util/i18n/i18n.ts @@ -1,39 +1,33 @@ // this file is a weird one as it is used by both sides of electron at the same time -import { isEmpty } from 'lodash'; -import type { LocalizerDictionary, SetupI18nReturnType } from '../../types/localizer'; +import type { SetupI18nReturnType } from '../../types/localizer'; import { getMessage } from './functions/getMessage'; import { i18nLog, setInitialLocale } from './shared'; import { CrowdinLocale } from '../../localization/constants'; +import { setLogger } from '../../localization/localeTools'; /** * Sets up the i18n function with the provided locale and messages. * * @param params - An object containing optional parameters. * @param params.crowdinLocale - The locale to use for translations (crowdin) - * @param params.translationDictionary - A dictionary of localized messages * * @returns A function that retrieves a localized message string, substituting variables where necessary. */ export const setupI18n = ({ crowdinLocale, - translationDictionary, }: { crowdinLocale: CrowdinLocale; - translationDictionary: LocalizerDictionary; }): SetupI18nReturnType => { if (!crowdinLocale) { throw new Error(`crowdinLocale not provided in i18n setup`); } - if (!translationDictionary || isEmpty(translationDictionary)) { - throw new Error('translationDictionary was not provided'); - } - console.warn('translationDictionary', translationDictionary); - - setInitialLocale(crowdinLocale, translationDictionary); + setInitialLocale(crowdinLocale); i18nLog(`Setup Complete with crowdinLocale: ${crowdinLocale}`); + // eslint-disable-next-line no-console + setLogger(i18nLog); return getMessage; }; diff --git a/ts/util/i18n/localizedString.ts b/ts/util/i18n/localizedString.ts index ed5dbc44be..6f173b0fec 100644 --- a/ts/util/i18n/localizedString.ts +++ b/ts/util/i18n/localizedString.ts @@ -1,101 +1,17 @@ -import { en } from '../../localization/locales'; +import { pluralsDictionary, simpleDictionary } from '../../localization/locales'; import { - getStringForCardinalRule, - getFallbackDictionary, - getTranslationDictionary, - i18nLog, - getCrowdinLocale, -} from './shared'; -import { LOCALE_DEFAULTS } from '../../localization/constants'; -import { deSanitizeHtmlTags, sanitizeArgs } from '../../components/basic/Localizer'; -import type { LocalizerDictionary } from '../../types/localizer'; - -type PluralKey = 'count'; + ArgsFromToken, + deSanitizeHtmlTags, + getStringForRule, + isPluralToken, + isSimpleToken, + MergedLocalizerTokens, + sanitizeArgs, +} from '../../localization/localeTools'; +import { i18nLog, getCrowdinLocale } from './shared'; +import { CrowdinLocale, LOCALE_DEFAULTS } from '../../localization/constants'; type ArgString = `${string}{${string}}${string}`; -type RawString = ArgString | string; - -type PluralString = `{${PluralKey}, plural, one [${RawString}] other [${RawString}]}`; - -type GenericLocalizedDictionary = Record; - -type TokenString = keyof Dict extends string - ? keyof Dict - : never; - -/** The dynamic arguments in a localized string */ -type StringArgs = - /** If a string follows the plural format use its plural variable name and recursively check for - * dynamic args inside all plural forms */ - T extends `{${infer PluralVar}, plural, one [${infer PluralOne}] other [${infer PluralOther}]}` - ? PluralVar | StringArgs | StringArgs - : /** If a string segment follows the variable form parse its variable name and recursively - * check for more dynamic args */ - T extends `${string}{${infer Var}}${infer Rest}` - ? Var | StringArgs - : never; - -export type StringArgsRecord = Record, string | number>; - -// TODO: move this to a test file -// -// const stringArgsTestStrings = { -// none: 'test', -// one: 'test{count}', -// two: 'test {count} second {another}', -// three: 'test {count} second {another} third {third}', -// four: 'test {count} second {another} third {third} fourth {fourth}', -// five: 'test {count} second {another} third {third} fourth {fourth} fifth {fifth}', -// twoConnected: 'test {count}{another}', -// threeConnected: '{count}{another}{third}', -// } as const; -// -// const stringArgsTestResults = { -// one: { count: 'count' }, -// two: { count: 'count', another: 'another' }, -// three: { count: 'count', another: 'another', third: 'third' }, -// four: { count: 'count', another: 'another', third: 'third', fourth: 'fourth' }, -// five: { count: 'count', another: 'another', third: 'third', fourth: 'fourth', fifth: 'fifth' }, -// twoConnected: { count: 'count', another: 'another' }, -// threeConnected: { count: 'count', another: 'another', third: 'third' }, -// } as const; -// -// let st0: Record, string>; -// const st1: Record, string> = stringArgsTestResults.one; -// const st2: Record, string> = stringArgsTestResults.two; -// const st3: Record< -// StringArgs, -// string -// > = stringArgsTestResults.three; -// const st4: Record< -// StringArgs, -// string -// > = stringArgsTestResults.four; -// const st5: Record< -// StringArgs, -// string -// > = stringArgsTestResults.five; -// const st6: Record< -// StringArgs, -// string -// > = stringArgsTestResults.twoConnected; -// const st7: Record< -// StringArgs, -// string -// > = stringArgsTestResults.threeConnected; -// -// const results = [st0, st1, st2, st3, st4, st5, st6, st7]; - -// Above is testing stuff - -function getPluralKey(string: PluralString): R { - const match = /{(\w+), plural, (zero|one|two|few|many|other) \[.+\]}/g.exec(string); - return match?.[1] as R; -} - -// TODO This regex is only going to work for the one/other case what about other langs where we can have one/two/other for example -const isPluralForm = (localizedString: string): localizedString is PluralString => - /{(\w+), plural, (zero|one|two|few|many|other) \[.+\]}/g.test(localizedString); /** * Checks if a string contains a dynamic variable. @@ -108,12 +24,9 @@ const isStringWithArgs = (localizedString: string): localizedString is ArgString const isReplaceLocalizedStringsWithKeysEnabled = () => !!(typeof window !== 'undefined' && window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys); -export class LocalizedStringBuilder< - Dict extends GenericLocalizedDictionary, - T extends TokenString, -> extends String { +export class LocalizedStringBuilder extends String { private readonly token: T; - private args?: StringArgsRecord; + private args?: ArgsFromToken; private isStripped = false; private isEnglishForced = false; @@ -144,7 +57,7 @@ export class LocalizedStringBuilder< } } - withArgs(args: StringArgsRecord): Omit { + withArgs(args: ArgsFromToken): Omit { this.args = args; return this; } @@ -157,7 +70,7 @@ export class LocalizedStringBuilder< strip(): Omit { const sanitizedArgs = this.args ? sanitizeArgs(this.args, '\u200B') : undefined; if (sanitizedArgs) { - this.args = sanitizedArgs as StringArgsRecord; + this.args = sanitizedArgs as ArgsFromToken; } this.isStripped = true; @@ -169,52 +82,35 @@ export class LocalizedStringBuilder< return deSanitizeHtmlTags(strippedString, '\u200B'); } - private getRawString(): RawString | TokenString { + private localeToTarget(): CrowdinLocale { + return this.isEnglishForced ? 'en' : getCrowdinLocale(); + } + + private getRawString(): string { try { if (this.renderStringAsToken) { return this.token; } - const dict: GenericLocalizedDictionary = this.isEnglishForced - ? en - : getTranslationDictionary(); - - let localizedString = dict[this.token]; - - if (!localizedString) { - i18nLog(`Attempted to get translation for nonexistent key: '${this.token}'`); - - localizedString = (getFallbackDictionary() as GenericLocalizedDictionary)[this.token]; + if (isSimpleToken(this.token)) { + return simpleDictionary[this.token][this.localeToTarget()]; + } - if (!localizedString) { - i18nLog( - `Attempted to get translation for nonexistent key: '${this.token}' in fallback dictionary` - ); - return this.token; - } + if (!isPluralToken(this.token)) { + throw new Error('invalid token provided'); } - return isPluralForm(localizedString) - ? this.resolvePluralString(localizedString) - : localizedString; + return this.resolvePluralString(); } catch (error) { i18nLog(error.message); return this.token; } } - private resolvePluralString(str: PluralString): string { - const pluralKey = getPluralKey(str); + private resolvePluralString(): string { + const pluralKey = 'count' as const; - // This should not be possible, but we need to handle it in case it does happen - if (!pluralKey) { - i18nLog( - `Attempted to get nonexistent pluralKey for plural form string '${str}' for token '${this.token}'` - ); - return this.token; - } - - let num = this.args?.[pluralKey as keyof StringArgsRecord]; + let num: number | string | undefined = this.args?.[pluralKey as keyof ArgsFromToken]; if (num === undefined) { i18nLog( @@ -236,20 +132,34 @@ export class LocalizedStringBuilder< } } - const currentLocale = getCrowdinLocale(); - const cardinalRule = new Intl.PluralRules(currentLocale).select(num); + const localeToTarget = this.localeToTarget(); + const cardinalRule = new Intl.PluralRules(localeToTarget).select(num); + + if (!isPluralToken(this.token)) { + throw new Error('resolvePluralString can only be called with a plural string'); + } - let pluralString = getStringForCardinalRule(str, cardinalRule); + let pluralString = getStringForRule({ + cardinalRule, + crowdinLocale: localeToTarget, + dictionary: pluralsDictionary, + token: this.token, + }); if (!pluralString) { i18nLog( - `Plural string not found for cardinal '${cardinalRule}': '${str}' Falling back to 'other' cardinal` + `Plural string not found for cardinal '${cardinalRule}': '${this.token}' Falling back to 'other' cardinal` ); - pluralString = getStringForCardinalRule(str, 'other'); + pluralString = getStringForRule({ + cardinalRule: 'other', + crowdinLocale: localeToTarget, + dictionary: pluralsDictionary, + token: this.token, + }); if (!pluralString) { - i18nLog(`Plural string not found for fallback cardinal 'other': '${str}'`); + i18nLog(`Plural string not found for fallback cardinal 'other': '${this.token}'`); return this.token; } @@ -262,7 +172,7 @@ export class LocalizedStringBuilder< /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */ return str.replace(/\{(\w+)\}/g, (match, arg: string) => { const matchedArg = this.args - ? this.args[arg as keyof StringArgsRecord]?.toString() + ? this.args[arg as keyof ArgsFromToken]?.toString() : undefined; return matchedArg ?? LOCALE_DEFAULTS[arg as keyof typeof LOCALE_DEFAULTS] ?? match; @@ -270,13 +180,10 @@ export class LocalizedStringBuilder< } } -export function localize>(token: T) { - return new LocalizedStringBuilder(token); +export function localize(token: T) { + return new LocalizedStringBuilder(token); } -export function localizeFromOld>( - token: T, - args: StringArgsRecord -) { - return new LocalizedStringBuilder(token).withArgs(args); +export function localizeFromOld(token: T, args: ArgsFromToken) { + return new LocalizedStringBuilder(token).withArgs(args); } diff --git a/ts/util/i18n/shared.ts b/ts/util/i18n/shared.ts index cffdc3da7e..736809ba94 100644 --- a/ts/util/i18n/shared.ts +++ b/ts/util/i18n/shared.ts @@ -1,37 +1,17 @@ import { Locale } from 'date-fns'; import { CrowdinLocale } from '../../localization/constants'; -import { en } from '../../localization/locales'; -import type { LocalizerDictionary } from '../../types/localizer'; import { timeLocaleMap } from './timeLocaleMap'; let mappedBrowserLocaleDisplayed = false; let crowdinLocale: CrowdinLocale | undefined; -let translationDictionary: LocalizerDictionary | undefined; /** * Only exported for testing, reset the dictionary to use for translations and the locale set */ export function resetLocaleAndTranslationDict() { - translationDictionary = undefined; crowdinLocale = undefined; } -/** - * Returns the current dictionary to use for translations. - */ -export function getTranslationDictionary(): LocalizerDictionary { - if (translationDictionary) { - return translationDictionary; - } - - i18nLog('getTranslationDictionary: dictionary not init yet. Using en.'); - return en; -} - -export function getFallbackDictionary(): LocalizerDictionary { - return en; -} - /** * Logs an i18n message to the console. * @param message - The message to log. @@ -106,34 +86,13 @@ export function getBrowserLocale() { } } -export function setInitialLocale(crowdinLocaleArg: CrowdinLocale, dictionary: LocalizerDictionary) { - if (translationDictionary || crowdinLocale) { - throw new Error('setInitialLocale: translationDictionary or crowdinLocale is already init'); +export function setInitialLocale(crowdinLocaleArg: CrowdinLocale) { + if (crowdinLocale) { + throw new Error('setInitialLocale: crowdinLocale is already init'); } - translationDictionary = dictionary; crowdinLocale = crowdinLocaleArg; } export function isSessionLocaleSet() { return !!crowdinLocale; } - -export function getStringForCardinalRule( - localizedString: string, - cardinalRule: Intl.LDMLPluralRule -): string | undefined { - // TODO: investigate if this is the best way to handle regex like this - // We need block scoped regex to avoid running into this issue: - // https://stackoverflow.com/questions/18462784/why-is-javascript-regex-matching-every-second-time - const cardinalPluralRegex: Record = { - zero: /zero \[(.*?)\]/g, - one: /one \[(.*?)\]/g, - two: /two \[(.*?)\]/g, - few: /few \[(.*?)\]/g, - many: /many \[(.*?)\]/g, - other: /other \[(.*?)\]/g, - }; - const regex = cardinalPluralRegex[cardinalRule]; - const match = regex.exec(localizedString); - return match?.[1] ?? undefined; -} diff --git a/ts/util/notifications.ts b/ts/util/notifications.ts index 3bc30f5778..8ece1d3472 100644 --- a/ts/util/notifications.ts +++ b/ts/util/notifications.ts @@ -221,7 +221,7 @@ function update(forceRefresh = false) { if (shouldHideExpiringMessageBody) { message = window.i18n('messageNew', { count: messagesNotificationCount }); } - + window.drawAttention(); if (status.shouldPlayNotificationSound) { if (!sound) { diff --git a/ts/window.d.ts b/ts/window.d.ts index f7c98ec609..8abb44aba3 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -5,7 +5,8 @@ import { Store } from '@reduxjs/toolkit'; import { Persistor } from 'redux-persist/es/types'; import { PrimaryColorStateType, ThemeStateType } from './themes/constants/colors'; -import type { GetMessageArgs, I18nMethods, LocalizerToken } from './types/localizer'; +import type { GetMessageArgs, I18nMethods } from './types/localizer'; +import { MergedLocalizerTokens } from './localization/localeTools'; export interface LibTextsecure { messaging: boolean; @@ -35,19 +36,8 @@ declare global { * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. * * @returns The localized message string with substitutions applied. - * - * @example - * // The string greeting is 'Hello, {name}!' in the current locale - * window.i18n('greeting', { name: 'Alice' }); - * // => 'Hello, Alice!' - * - * // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale - * window.i18n('search', { count: 1, found_count: 1 }); - * // => '1 of 1 match' */ - i18n: (( - ...[token, args]: GetMessageArgs - ) => R) & { + i18n: ((...[token, args]: GetMessageArgs) => string) & { /** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.getRawMessage } and {@link window.i18n.getRawMessage } */ /** * Retrieves a localized message string, without substituting any variables. This resolves any plural forms using the given args @@ -59,15 +49,6 @@ declare global { * @deprecated * * NOTE: This is intended to be used to get the raw string then format it with {@link formatMessageWithArgs} - * - * @example - * // The string greeting is 'Hello, {name}!' in the current locale - * window.i18n.getRawMessage('greeting', { name: 'Alice' }); - * // => 'Hello, {name}!' - * - * // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale - * window.i18n.getRawMessage('search', { count: 1, found_count: 1 }); - * // => '{found_count} of {count} match' */ getRawMessage: I18nMethods['getRawMessage']; @@ -81,19 +62,6 @@ declare global { * @returns The formatted message string. * * @deprecated - * - * @example - * // The string greeting is 'Hello, {name}!' in the current locale - * window.i18n.getRawMessage('greeting', { name: 'Alice' }); - * // => 'Hello, {name}!' - * window.i18n.formatMessageWithArgs('Hello, {name}!', { name: 'Alice' }); - * // => 'Hello, Alice!' - * - * // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale - * window.i18n.getRawMessage('search', { count: 1, found_count: 1 }); - * // => '{found_count} of {count} match' - * window.i18n.formatMessageWithArgs('{found_count} of {count} match', { count: 1, found_count: 1 }); - * // => '1 of 1 match' */ formatMessageWithArgs: I18nMethods['formatMessageWithArgs']; @@ -107,11 +75,6 @@ declare global { * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. * * @returns The localized message string with substitutions applied. Any HTML and custom tags are removed. - * - * @example - * // The string greeting is 'Hello, {name}! Welcome!' in the current locale - * window.i18n.stripped('greeting', { name: 'Alice' }); - * // => 'Hello, Alice! Welcome!' */ stripped: I18nMethods['stripped'];