feat: remove custom tag renderer and refactor hover emojis

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

@ -1,21 +1,13 @@
import styled from 'styled-components';
import { Fragment } from 'react';
import {
import type {
ArgsRecord,
GetMessageArgs,
I18nProps,
LocalizerDictionary,
LocalizerToken,
} from '../../types/Localizer';
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 */
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
* @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 cleanArgs = args && containsFormattingTags ? sanitizeArgs(args) : args;
let i18nString = window.i18n.formatMessageWithArgs<T, LocalizerDictionary[T]>(
const i18nString = window.i18n.formatMessageWithArgs(
rawString as LocalizerDictionary[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 */
<StyledHtmlRenderer isDarkTheme={isDarkMode}>
<SessionHtmlRenderer tag={props.asTag} html={i18nString} className={props.className} />
@ -163,22 +122,4 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
) : (
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 { I18n } from '../../../basic/I18n';
import { nativeEmojiData } from '../../../../util/emoji';
import { I18nProps, LocalizerToken } from '../../../../types/Localizer';
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 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 = (
senders: Array<string>
): { contacts: Array<string>; numberOfReactors: number; hasMe: boolean } => {
@ -72,46 +80,31 @@ const generateContactsString = (
return { contacts, hasMe, numberOfReactors };
};
const getI18nComponent = (
const getI18nComponentProps = (
isYou: boolean,
contacts: Array<string>,
numberOfReactors: number,
emoji: string
) => {
emoji: string,
emojiName?: string
): I18nProps<LocalizerToken> => {
const name = contacts[0];
const other_name = contacts[1];
const emoji_name = emojiName ? `:${emojiName}:` : emoji;
const count = numberOfReactors - 1;
switch (numberOfReactors) {
case 1:
return isYou ? (
<I18n token="emojiReactsHoverYouNameDesktop" endTagProps={{ emoji }} />
) : (
<I18n token="emojiReactsHoverNameDesktop" args={{ name }} endTagProps={{ emoji }} />
);
return isYou
? { token: 'emojiReactsHoverYouNameDesktop', args: { emoji_name } }
: { token: 'emojiReactsHoverNameDesktop', args: { name, emoji_name } };
case 2:
return isYou ? (
<I18n token="emojiReactsHoverYouNameTwoDesktop" args={{ name }} endTagProps={{ emoji }} />
) : (
<I18n
token="emojiReactsHoverNameTwoDesktop"
args={{ name, other_name }}
endTagProps={{ emoji }}
/>
);
return isYou
? { token: 'emojiReactsHoverYouNameTwoDesktop', args: { name, emoji_name } }
: { token: 'emojiReactsHoverNameTwoDesktop', args: { name, other_name, emoji_name } };
default:
return isYou ? (
<I18n
token="emojiReactsHoverYouNameMultipleDesktop"
args={{ name, count: numberOfReactors - 1 }}
endTagProps={{ emoji }}
/>
) : (
<I18n
token="emojiReactsHoverTwoNameMultipleDesktop"
args={{ name, count: numberOfReactors - 1 }}
endTagProps={{ emoji }}
/>
);
return isYou
? { token: 'emojiReactsHoverYouNameMultipleDesktop', args: { count, emoji_name } }
: { token: 'emojiReactsHoverTwoNameMultipleDesktop', args: { name, count, emoji_name } };
}
};
@ -127,19 +120,30 @@ type Props = {
export const ReactionPopup = (props: 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(
() => generateContactsString(senders),
[senders]
);
const content = useMemo(
() => getI18nComponent(hasMe, contacts, numberOfReactors, emoji),
[hasMe, contacts, numberOfReactors, emoji]
const i18nProps = useMemo(
() => getI18nComponentProps(hasMe, contacts, numberOfReactors, emoji, emojiName),
[hasMe, contacts, numberOfReactors, emoji, emojiName]
);
return (
<StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}>
{content}
<I18n {...i18nProps} />
<StyledEmoji role={'img'} aria-label={emojiAriaLabel}>
{emoji}
</StyledEmoji>
</StyledPopupContainer>
);
};

@ -1,24 +1,24 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { ElementType } from 'react';
import type { Dictionary } from '../localization/locales';
import { CustomTag, CustomTagProps } from '../components/basic/SessionCustomTagRenderer';
import { LOCALE_DEFAULTS } from '../localization/constants';
import type { LOCALE_DEFAULTS } from '../localization/constants';
/** The dictionary of localized strings */
export type LocalizerDictionary = Dictionary;
/** A localization dictionary key */
type Token = keyof Dictionary;
export type LocalizerToken = keyof Dictionary;
/** A dynamic argument that can be used in a localized string */
export type DynamicArg = string | number;
/** 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 PluralKey = 'count';
export type PluralString = `{${string}, plural, one [${string}] other [${string}]}`;
export type ArgsRecord<T extends LocalizerToken> = Record<DynamicArgs<Dictionary[T]>, 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}]}`;
/** The dynamic arguments in a localized string */
type DynamicArgs<LocalizedString extends string> =
@ -28,18 +28,17 @@ type DynamicArgs<LocalizedString extends string> =
? PluralVar | DynamicArgs<PluralOne> | DynamicArgs<PluralOther>
: /** If a string segment 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 `${string}{${infer Var}}${infer Rest}`
? Var | DynamicArgs<Rest>
: never;
export type ArgsRecordExcludingDefaults<T extends Token> = Omit<
export type ArgsRecordExcludingDefaults<T extends LocalizerToken> = Omit<
ArgsRecord<T>,
keyof typeof LOCALE_DEFAULTS
>;
/** 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
? [T]
: ArgsRecordExcludingDefaults<T> extends Record<string, never>
@ -48,17 +47,14 @@ export type GetMessageArgs<T extends Token> = T extends Token
: never;
/** Basic props for all calls of the I18n component */
type I18nBaseProps<T extends Token> = {
type I18nBaseProps<T extends LocalizerToken> = {
token: T;
asTag?: ElementType;
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 */
export type I18nProps<T extends Token> = T extends Token
export type I18nProps<T extends LocalizerToken> = T extends LocalizerToken
? DynamicArgs<Dictionary[T]> extends never
? I18nBaseProps<T>
: ArgsRecordExcludingDefaults<T> extends Record<string, never>
@ -66,52 +62,6 @@ export type I18nProps<T extends Token> = T extends Token
: I18nBaseProps<T> & { args: ArgsRecordExcludingDefaults<T> }
: 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 = {
/** @see {@link window.i18n.stripped} */
stripped: <T extends LocalizerToken, R extends LocalizerDictionary[T]>(

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

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

Loading…
Cancel
Save