feat: remove custom tag renderer and refactor hover emojis

pull/3206/head
Ryan Miller 8 months ago
parent ba32668110
commit 2f2c420026

@ -1,21 +1,13 @@
import styled from 'styled-components'; import styled from 'styled-components';
import { Fragment } from 'react'; import type {
import {
ArgsRecord, ArgsRecord,
GetMessageArgs, GetMessageArgs,
I18nProps, I18nProps,
LocalizerDictionary, LocalizerDictionary,
LocalizerToken, LocalizerToken,
} from '../../types/Localizer'; } from '../../types/Localizer';
import { useIsDarkTheme } from '../../state/selectors/theme'; import { useIsDarkTheme } from '../../state/selectors/theme';
import { SessionHtmlRenderer } from './SessionHTMLRenderer'; 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 */ /** An array of supported html tags to render if found in a string */
export const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span']; export const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span'];
@ -29,10 +21,6 @@ function createSupportedFormattingTagsRegex() {
); );
} }
function createSupportedCustomTagsRegex() {
return new RegExp(`<(${supportedCustomTags.join('|')})/>`, 'g');
}
/** /**
* Replaces all html tag identifiers with their escaped equivalents * Replaces all html tag identifiers with their escaped equivalents
* @param str The string to sanitize * @param str The string to sanitize
@ -121,41 +109,12 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
const containsFormattingTags = createSupportedFormattingTagsRegex().test(rawString); const containsFormattingTags = createSupportedFormattingTagsRegex().test(rawString);
const cleanArgs = args && containsFormattingTags ? sanitizeArgs(args) : args; const cleanArgs = args && containsFormattingTags ? sanitizeArgs(args) : args;
let i18nString = window.i18n.formatMessageWithArgs<T, LocalizerDictionary[T]>( const i18nString = window.i18n.formatMessageWithArgs(
rawString as LocalizerDictionary[T], rawString as LocalizerDictionary[T],
cleanArgs as ArgsRecord<T> cleanArgs as ArgsRecord<T>
) as string;
let startTag: CustomTag | null = null;
let endTag: CustomTag | null = null;
i18nString = i18nString.replace(
createSupportedCustomTagsRegex(),
/**
* @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.
*/
(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 '';
}
); );
const content = containsFormattingTags ? ( return containsFormattingTags ? (
/** If the string contains a relevant formatting tag, render it as HTML */ /** If the string contains a relevant formatting tag, render it as HTML */
<StyledHtmlRenderer isDarkTheme={isDarkMode}> <StyledHtmlRenderer isDarkTheme={isDarkMode}>
<SessionHtmlRenderer tag={props.asTag} html={i18nString} className={props.className} /> <SessionHtmlRenderer tag={props.asTag} html={i18nString} className={props.className} />
@ -163,22 +122,4 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
) : ( ) : (
i18nString i18nString
); );
return (
<Fragment>
{startTag ? (
<SessionCustomTagRenderer
tag={startTag}
tagProps={props.startTagProps as CustomTagProps<typeof startTag>}
/>
) : null}
{content}
{endTag ? (
<SessionCustomTagRenderer
tag={endTag}
tagProps={props.endTagProps as CustomTagProps<typeof endTag>}
/>
) : null}
</Fragment>
);
}; };

@ -1,40 +0,0 @@
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 }) => (
<StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}>
{emoji}
</StyledEmoji>
),
} as const;
export type CustomTagProps<Tag extends CustomTag> = Parameters<(typeof customTag)[Tag]>[0];
/**
* 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 extends CustomTag>({
tag,
tagProps,
}: {
tag: Tag;
tagProps: CustomTagProps<Tag>;
}) => {
return customTag[tag](tagProps);
};

@ -4,9 +4,12 @@ import { findAndFormatContact } from '../../../../models/message';
import { PubKey } from '../../../../session/types/PubKey'; import { PubKey } from '../../../../session/types/PubKey';
import { I18n } from '../../../basic/I18n'; import { I18n } from '../../../basic/I18n';
import { nativeEmojiData } from '../../../../util/emoji';
import { I18nProps, LocalizerToken } from '../../../../types/Localizer';
export type TipPosition = 'center' | 'left' | 'right'; export type TipPosition = 'center' | 'left' | 'right';
// TODO: Look into adjusting the width to match the new strings better
export const POPUP_WIDTH = 216; // px export const POPUP_WIDTH = 216; // px
export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>` export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>`
@ -52,6 +55,11 @@ export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>
} }
`; `;
const StyledEmoji = styled.span`
font-size: 36px;
margin-block-start: 8px;
`;
const generateContactsString = ( const generateContactsString = (
senders: Array<string> senders: Array<string>
): { contacts: Array<string>; numberOfReactors: number; hasMe: boolean } => { ): { contacts: Array<string>; numberOfReactors: number; hasMe: boolean } => {
@ -72,46 +80,31 @@ const generateContactsString = (
return { contacts, hasMe, numberOfReactors }; return { contacts, hasMe, numberOfReactors };
}; };
const getI18nComponent = ( const getI18nComponentProps = (
isYou: boolean, isYou: boolean,
contacts: Array<string>, contacts: Array<string>,
numberOfReactors: number, numberOfReactors: number,
emoji: string emoji: string,
) => { emojiName?: string
): I18nProps<LocalizerToken> => {
const name = contacts[0]; const name = contacts[0];
const other_name = contacts[1]; const other_name = contacts[1];
const emoji_name = emojiName ? `:${emojiName}:` : emoji;
const count = numberOfReactors - 1;
switch (numberOfReactors) { switch (numberOfReactors) {
case 1: case 1:
return isYou ? ( return isYou
<I18n token="emojiReactsHoverYouNameDesktop" endTagProps={{ emoji }} /> ? { token: 'emojiReactsHoverYouNameDesktop', args: { emoji_name } }
) : ( : { token: 'emojiReactsHoverNameDesktop', args: { name, emoji_name } };
<I18n token="emojiReactsHoverNameDesktop" args={{ name }} endTagProps={{ emoji }} />
);
case 2: case 2:
return isYou ? ( return isYou
<I18n token="emojiReactsHoverYouNameTwoDesktop" args={{ name }} endTagProps={{ emoji }} /> ? { token: 'emojiReactsHoverYouNameTwoDesktop', args: { name, emoji_name } }
) : ( : { token: 'emojiReactsHoverNameTwoDesktop', args: { name, other_name, emoji_name } };
<I18n
token="emojiReactsHoverNameTwoDesktop"
args={{ name, other_name }}
endTagProps={{ emoji }}
/>
);
default: default:
return isYou ? ( return isYou
<I18n ? { token: 'emojiReactsHoverYouNameMultipleDesktop', args: { count, emoji_name } }
token="emojiReactsHoverYouNameMultipleDesktop" : { token: 'emojiReactsHoverTwoNameMultipleDesktop', args: { name, count, emoji_name } };
args={{ name, count: numberOfReactors - 1 }}
endTagProps={{ emoji }}
/>
) : (
<I18n
token="emojiReactsHoverTwoNameMultipleDesktop"
args={{ name, count: numberOfReactors - 1 }}
endTagProps={{ emoji }}
/>
);
} }
}; };
@ -127,19 +120,30 @@ type Props = {
export const ReactionPopup = (props: Props) => { export const ReactionPopup = (props: Props) => {
const { emoji, senders, tooltipPosition = 'center', onClick } = props; const { emoji, senders, tooltipPosition = 'center', onClick } = props;
const { emojiName, emojiAriaLabel } = useMemo(
() => ({
emojiName: nativeEmojiData?.ids?.[emoji],
emojiAriaLabel: nativeEmojiData?.ariaLabels?.[emoji],
}),
[emoji]
);
const { contacts, hasMe, numberOfReactors } = useMemo( const { contacts, hasMe, numberOfReactors } = useMemo(
() => generateContactsString(senders), () => generateContactsString(senders),
[senders] [senders]
); );
const content = useMemo( const i18nProps = useMemo(
() => getI18nComponent(hasMe, contacts, numberOfReactors, emoji), () => getI18nComponentProps(hasMe, contacts, numberOfReactors, emoji, emojiName),
[hasMe, contacts, numberOfReactors, emoji] [hasMe, contacts, numberOfReactors, emoji, emojiName]
); );
return ( return (
<StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}> <StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}>
{content} <I18n {...i18nProps} />
<StyledEmoji role={'img'} aria-label={emojiAriaLabel}>
{emoji}
</StyledEmoji>
</StyledPopupContainer> </StyledPopupContainer>
); );
}; };

@ -1,24 +1,24 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import type { ElementType } from 'react'; import type { ElementType } from 'react';
import type { Dictionary } from '../localization/locales'; import type { Dictionary } from '../localization/locales';
import { CustomTag, CustomTagProps } from '../components/basic/SessionCustomTagRenderer'; import type { LOCALE_DEFAULTS } from '../localization/constants';
import { LOCALE_DEFAULTS } from '../localization/constants';
/** The dictionary of localized strings */
export type LocalizerDictionary = Dictionary;
/** A localization dictionary key */ /** A localization dictionary key */
type Token = keyof Dictionary; export type LocalizerToken = keyof Dictionary;
/** A dynamic argument that can be used in a localized string */ /** A dynamic argument that can be used in a localized string */
export type DynamicArg = string | number; export type DynamicArg = string | number;
/** A record of dynamic arguments for a specific key in the localization dictionary */ /** A record of dynamic arguments for a specific key in the localization dictionary */
export type ArgsRecord<T extends Token> = Record<DynamicArgs<Dictionary[T]>, DynamicArg>; export type ArgsRecord<T extends LocalizerToken> = Record<DynamicArgs<Dictionary[T]>, DynamicArg>;
export type PluralKey = 'count';
export type PluralString = `{${string}, plural, one [${string}] other [${string}]}`;
// TODO: create a proper type for this // TODO: create a proper type for this
export type DictionaryWithoutPluralStrings = Dictionary; export type DictionaryWithoutPluralStrings = Dictionary;
export type PluralKey = 'count';
export type PluralString = `{${string}, plural, one [${string}] other [${string}]}`;
/** The dynamic arguments in a localized string */ /** The dynamic arguments in a localized string */
type DynamicArgs<LocalizedString extends string> = type DynamicArgs<LocalizedString extends string> =
@ -28,18 +28,17 @@ type DynamicArgs<LocalizedString extends string> =
? PluralVar | DynamicArgs<PluralOne> | DynamicArgs<PluralOther> ? PluralVar | DynamicArgs<PluralOne> | DynamicArgs<PluralOther>
: /** If a string segment follows the variable form parse its variable name and recursively : /** If a string segment follows the variable form parse its variable name and recursively
* check for more dynamic args */ * 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 `${string}{${infer Var}}${infer Rest}` LocalizedString extends `${string}{${infer Var}}${infer Rest}`
? Var | DynamicArgs<Rest> ? Var | DynamicArgs<Rest>
: never; : never;
export type ArgsRecordExcludingDefaults<T extends Token> = Omit< export type ArgsRecordExcludingDefaults<T extends LocalizerToken> = Omit<
ArgsRecord<T>, ArgsRecord<T>,
keyof typeof LOCALE_DEFAULTS keyof typeof LOCALE_DEFAULTS
>; >;
/** The arguments for retrieving a localized message */ /** The arguments for retrieving a localized message */
export type GetMessageArgs<T extends Token> = T extends Token export type GetMessageArgs<T extends LocalizerToken> = T extends LocalizerToken
? DynamicArgs<Dictionary[T]> extends never ? DynamicArgs<Dictionary[T]> extends never
? [T] ? [T]
: ArgsRecordExcludingDefaults<T> extends Record<string, never> : ArgsRecordExcludingDefaults<T> extends Record<string, never>
@ -48,17 +47,14 @@ export type GetMessageArgs<T extends Token> = T extends Token
: never; : never;
/** Basic props for all calls of the I18n component */ /** Basic props for all calls of the I18n component */
type I18nBaseProps<T extends Token> = { type I18nBaseProps<T extends LocalizerToken> = {
token: T; token: T;
asTag?: ElementType; asTag?: ElementType;
className?: string; className?: string;
// TODO: investigate making these required when required and not required when not required
startTagProps?: CustomStartTagProps<T>;
endTagProps?: CustomEndTagProps<T>;
}; };
/** The props for the localization component */ /** The props for the localization component */
export type I18nProps<T extends Token> = T extends Token export type I18nProps<T extends LocalizerToken> = T extends LocalizerToken
? DynamicArgs<Dictionary[T]> extends never ? DynamicArgs<Dictionary[T]> extends never
? I18nBaseProps<T> ? I18nBaseProps<T>
: ArgsRecordExcludingDefaults<T> extends Record<string, never> : ArgsRecordExcludingDefaults<T> extends Record<string, never>
@ -66,52 +62,6 @@ export type I18nProps<T extends Token> = T extends Token
: I18nBaseProps<T> & { args: ArgsRecordExcludingDefaults<T> } : I18nBaseProps<T> & { args: ArgsRecordExcludingDefaults<T> }
: never; : never;
/** The props for custom tags at the start of an i18n strings */
export type CustomStartTagProps<T extends Token> = T extends Token
? Dictionary[T] extends `<${infer Tag}/>${string}`
? Tag extends CustomTag
? CustomTagProps<Tag>
: 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 <emoji/>`
* 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} & <span>1 other</span> reacted with <emoji/>`
* The first iteration will find `Tag` as `span>1 other</span> reacted with <emoji` because it
* grabs the first `<` and the last `/>, 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> =
LocalizedString extends `${string}<${infer Tag}/>${string}` ? FindCustomTag<Tag> : never;
type FindCustomTag<S extends string> = S extends CustomTag
? S
: S extends `${string}<${infer Tag}`
? Tag extends CustomTag
? Tag
: FindCustomTag<Tag>
: never;
/** The props for custom tags at the end of an i18n strings */
type CustomEndTagProps<T extends Token> =
CustomEndTag<Dictionary[T]> extends CustomTag
? CustomTagProps<CustomEndTag<Dictionary[T]>>
: never;
/** The dictionary of localized strings */
export type LocalizerDictionary = Dictionary;
/** A localization dictionary key */
export type LocalizerToken = Token;
export type I18nMethods = { export type I18nMethods = {
/** @see {@link window.i18n.stripped} */ /** @see {@link window.i18n.stripped} */
stripped: <T extends LocalizerToken, R extends LocalizerDictionary[T]>( stripped: <T extends LocalizerToken, R extends LocalizerDictionary[T]>(

@ -41,6 +41,7 @@ export interface FixedBaseEmoji extends Emoji {
export interface NativeEmojiData extends EmojiMartData { export interface NativeEmojiData extends EmojiMartData {
ariaLabels?: Record<string, string>; ariaLabels?: Record<string, string>;
ids?: Record<string, string>;
} }
export enum Action { export enum Action {

@ -51,6 +51,7 @@ export let i18nEmojiData: typeof I18n | null = null;
export async function initialiseEmojiData(data: any): Promise<void> { export async function initialiseEmojiData(data: any): Promise<void> {
const ariaLabels: Record<string, string> = {}; const ariaLabels: Record<string, string> = {};
const ids: Record<string, string> = {};
Object.entries(data.emojis).forEach(([key, value]: [string, any]) => { Object.entries(data.emojis).forEach(([key, value]: [string, any]) => {
value.search = `,${[ value.search = `,${[
[value.id, false], [value.id, false],
@ -74,12 +75,14 @@ export async function initialiseEmojiData(data: any): Promise<void> {
(value as FixedBaseEmoji).skins.forEach(skin => { (value as FixedBaseEmoji).skins.forEach(skin => {
ariaLabels[skin.native] = value.name; ariaLabels[skin.native] = value.name;
ids[skin.native] = value.id;
}); });
data.emojis[key] = value; data.emojis[key] = value;
}); });
data.ariaLabels = ariaLabels; data.ariaLabels = ariaLabels;
data.ids = ids;
nativeEmojiData = data; nativeEmojiData = data;
i18nEmojiData = await loadEmojiPanelI18n(); i18nEmojiData = await loadEmojiPanelI18n();

Loading…
Cancel
Save