diff --git a/ts/components/basic/I18n.tsx b/ts/components/basic/I18n.tsx index 022ba1737..14f9109c5 100644 --- a/ts/components/basic/I18n.tsx +++ b/ts/components/basic/I18n.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import type { GetMessageArgs, I18nProps, @@ -6,10 +5,16 @@ import type { LocalizerToken, } from '../../types/Localizer'; -import { useSelector } from 'react-redux'; +import { Fragment } from 'react'; import styled from 'styled-components'; -import { isDarkTheme } from '../../state/selectors/theme'; +import { useIsDarkTheme } from '../../state/selectors/theme'; import { SessionHtmlRenderer } from './SessionHTMLRenderer'; +import { + type CustomTag, + CustomTagProps, + SessionCustomTagRenderer, + supportedCustomTags, +} from './SessionCustomTagRenderer'; /** An array of supported html tags to render if found in a string */ const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span']; @@ -20,25 +25,22 @@ const formattingTagRegex = new RegExp( 'g' ); -const supportedCustomTags = ['emoji']; +const customTagRegex = new RegExp(`<(${supportedCustomTags.join('|')})/>`, 'g'); -const customTagRegex = new RegExp( - `<(?:${supportedFormattingTags.join('|')})>.*?`, - 'g' -); - -const StyledHtmlRenderer = styled.span<{ darkMode: boolean }>` +const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>` span { - color: ${props => (props.darkMode ? 'var(--primary-color)' : 'var(--text-primary-color)')}; + color: ${props => (props.isDarkTheme ? 'var(--primary-color)' : 'var(--text-primary-color)')}; } `; /** * Retrieve a localized message string, substituting dynamic parts where necessary and formatting it as HTML if necessary. * - * @param token - The token identifying the message to retrieve and an optional record of substitution variables and their replacement values. - * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic parts. - * @param 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. + * @param props.token - The token identifying the message to retrieve and an optional record of substitution variables and their replacement values. + * @param props.args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic parts. + * @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. + * @param props.startTagProps - An optional object of props to pass to the start tag. + * @param props.endTagProps - An optional object of props to pass to the end tag. * * @returns The localized message string with substitutions and formatting applied. * @@ -50,7 +52,8 @@ const StyledHtmlRenderer = styled.span<{ darkMode: boolean }>` * ``` */ export const I18n = (props: I18nProps) => { - const darkMode = useSelector(isDarkTheme); + const isDarkTheme = useIsDarkTheme(); + const i18nArgs = 'args' in props ? props.args : undefined; const i18nString = window.i18n( @@ -58,18 +61,59 @@ export const I18n = (props: I18nProps) => { ); const containsFormattingTag = i18nString.match(formattingTagRegex); - const containsCustomTag = i18nString.match(customTagRegex); - /** If the string contains a relevant formatting tag, render it as HTML */ - if (containsFormattingTag || containsCustomTag) { - return ( - - - - ); - } + let startTag: CustomTag | null = null; + let endTag: CustomTag | null = null; - const Comp = props.as ?? React.Fragment; + /** + * @param match - The entire match, including the custom tag. + * @param group - The custom tag, without the angle brackets. + * @param index - The index of the match in the string. + */ + i18nString.replace(customTagRegex, (match: string, group: CustomTag, index: number) => { + if (index === 0) { + startTag = group; + } else if (index === i18nString.length - match.length) { + endTag = group; + } else { + /** + * If the match is not at the start or end of the string, throw an error. + * NOTE: This should never happen as this rule is enforced when the dictionary is generated. + */ + throw new Error( + `Custom tag ${group} (${match}) is not at the start or end (i=${index}) of the string: ${i18nString}` + ); + } - return {i18nString}; + return ''; + }); + + const content = containsFormattingTag ? ( + /** If the string contains a relevant formatting tag, render it as HTML */ + + + + ) : ( + i18nString + ); + + const Comp = props.as ?? Fragment; + + return ( + + {startTag ? ( + } + /> + ) : null} + {content} + {endTag ? ( + } + /> + ) : null} + + ); }; diff --git a/ts/components/basic/SessionCustomTagRenderer.tsx b/ts/components/basic/SessionCustomTagRenderer.tsx index 10704e06e..7ae662a6d 100644 --- a/ts/components/basic/SessionCustomTagRenderer.tsx +++ b/ts/components/basic/SessionCustomTagRenderer.tsx @@ -1,30 +1,40 @@ -import React from 'react'; -import { nativeEmojiData } from '../../util/emoji'; import styled from 'styled-components'; +import { nativeEmojiData } from '../../util/emoji'; const StyledEmoji = styled.span` font-size: 36px; margin-left: 8px; `; +export const supportedCustomTags = ['emoji'] as const; + +export type CustomTag = (typeof supportedCustomTags)[number]; + +/** + * A dictionary of custom tags and their rendering functions. + */ export const customTag = { emoji: ({ emoji }: { emoji: string }) => ( {emoji} ), -}; +} as const; + +export type CustomTagProps = Parameters<(typeof customTag)[Tag]>[0]; -export const SessionCustomTag = ({ +/** + * Render a custom tag with its props. + * + * @param tag - The custom tag to render. + * @param tagProps - The props to pass to the custom tag. + */ +export const SessionCustomTagRenderer = ({ tag, - props, + tagProps, }: { tag: Tag; - props: Parameters<(typeof customTag)[Tag]>[0]; + tagProps: CustomTagProps; }) => { - return customTag[tag](props); -}; - -export const SessionCustomTagRenderer = ({ str }: { str: string }) => { - const splitString = str.split(); + return customTag[tag](tagProps); }; diff --git a/ts/types/Localizer.ts b/ts/types/Localizer.ts index 66f96f667..12f0f9562 100644 --- a/ts/types/Localizer.ts +++ b/ts/types/Localizer.ts @@ -1,6 +1,7 @@ import type { ElementType } from 'react'; import type { Dictionary } from '../localization/locales'; import { LOCALE_DEFAULTS } from '../session/constants'; +import { CustomTag, CustomTagProps } from '../components/basic/SessionCustomTagRenderer'; /** A localization dictionary key */ type Token = keyof Dictionary; @@ -23,6 +24,7 @@ type DynamicArgs = ? PluralVar | DynamicArgs | DynamicArgs : /** If a string segment has follows the variable form parse its variable name and recursively * check for more dynamic args */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- We dont care about _Pre TODO: see if we can remove this infer LocalizedString extends `${infer _Pre}{${infer Var}}${infer Rest}` ? Var | DynamicArgs : never; @@ -42,7 +44,13 @@ export type GetMessageArgs = T extends Token : never; /** Basic props for all calls of the I18n component */ -type I18nBaseProps = { token: T; as?: ElementType }; +type I18nBaseProps = { + token: T; + as?: ElementType; + // TODO: investigate making these required when required and not required when not required + startTagProps?: CustomStartTagProps; + endTagProps?: CustomEndTagProps; +}; /** The props for the localization component */ export type I18nProps = T extends Token @@ -53,6 +61,46 @@ export type I18nProps = T extends Token : I18nBaseProps & { args: ArgsRecordExcludingDefaults } : never; +/** The props for custom tags at the start of an i18n strings */ +export type CustomStartTagProps = T extends Token + ? Dictionary[T] extends `<${infer Tag}/>${string}` + ? Tag extends CustomTag + ? CustomTagProps + : never + : never + : never; + +/** + * This is used to find the end tag. TypeScript navigates from outwards to inwards when doing magic + * with strings. This means we need a recursive type to find the actual end tag. + * + * @example For the string `{name} reacted with ` + * The first iteration will find `Tag` as `emoji` because it grabs the first `<` and the last `/>` + * Because it doesn't contain a `<` it will return the Tag. + * + * @example For the string `You, {name} & 1 other reacted with ` + * The first iteration will find `Tag` as `span>1 other reacted with , so we then check if Tag contains a `<`: + * - If it doesn't then we have found it; + * - If it does then we need to run it through the same process again to search deeper. + */ +type CustomEndTag = + LocalizedString extends `${string}<${infer Tag}/>${string}` ? FindCustomTag : never; + +type FindCustomTag = S extends CustomTag + ? S + : S extends `${string}<${infer Tag}` + ? Tag extends CustomTag + ? Tag + : FindCustomTag + : never; + +/** The props for custom tags at the end of an i18n strings */ +type CustomEndTagProps = + CustomEndTag extends CustomTag + ? CustomTagProps> + : never; + /** The dictionary of localized strings */ export type LocalizerDictionary = Dictionary;