diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index e0127f20923..cdac41b8a7d 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -2,27 +2,44 @@ import { useCallback } from 'react'; import { useLinks } from 'mastodon/hooks/useLinks'; +import { EmojiHTML } from '../features/emoji/emoji_html'; +import { isFeatureEnabled } from '../initial_state'; +import { useAppSelector } from '../store'; + interface AccountBioProps { - note: string; className: string; - dropdownAccountId?: string; + accountId: string; + showDropdown?: boolean; } export const AccountBio: React.FC = ({ - note, className, - dropdownAccountId, + accountId, + showDropdown = false, }) => { - const handleClick = useLinks(!!dropdownAccountId); + const handleClick = useLinks(showDropdown); const handleNodeChange = useCallback( (node: HTMLDivElement | null) => { - if (!dropdownAccountId || !node || node.childNodes.length === 0) { + if (!showDropdown || !node || node.childNodes.length === 0) { return; } - addDropdownToHashtags(node, dropdownAccountId); + addDropdownToHashtags(node, accountId); }, - [dropdownAccountId], + [showDropdown, accountId], ); + const note = useAppSelector((state) => { + const account = state.accounts.get(accountId); + if (!account) { + return ''; + } + return isFeatureEnabled('modern_emojis') + ? account.note + : account.note_emojified; + }); + const extraEmojis = useAppSelector((state) => { + const account = state.accounts.get(accountId); + return account?.emojis; + }); if (note.length === 0) { return null; @@ -31,10 +48,11 @@ export const AccountBio: React.FC = ({ return (
+ > + +
); }; diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx index a6bdda21686..a5a5e4c9575 100644 --- a/app/javascript/mastodon/components/hover_card_account.tsx +++ b/app/javascript/mastodon/components/hover_card_account.tsx @@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef< <>
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 0628e0791b5..02f06ec96ac 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -13,7 +13,8 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react' import { Icon } from 'mastodon/components/icon'; import { Poll } from 'mastodon/components/poll'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; +import { autoPlayGif, isFeatureEnabled, languages as preloadedLanguages } from 'mastodon/initial_state'; +import { EmojiHTML } from '../features/emoji/emoji_html'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) @@ -23,6 +24,9 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) * @returns {string} */ export function getStatusContent(status) { + if (isFeatureEnabled('modern_emojis')) { + return status.getIn(['translation', 'content']) || status.get('content'); + } return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); } @@ -228,7 +232,7 @@ class StatusContent extends PureComponent { const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); - const content = { __html: statusContent ?? getStatusContent(status) }; + const content = statusContent ?? getStatusContent(status); const language = status.getIn(['translation', 'language']) || status.get('language'); const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.props.history, @@ -253,7 +257,12 @@ class StatusContent extends PureComponent { return ( <>
-
+ {poll} {translateButton} @@ -265,7 +274,12 @@ class StatusContent extends PureComponent { } else { return (
-
+ {poll} {translateButton} diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index b9f83bebaaa..0bae0395031 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -898,8 +898,7 @@ export const AccountHeader: React.FC<{ )} diff --git a/app/javascript/mastodon/features/emoji/constants.ts b/app/javascript/mastodon/features/emoji/constants.ts index d38f17f2160..09022371b22 100644 --- a/app/javascript/mastodon/features/emoji/constants.ts +++ b/app/javascript/mastodon/features/emoji/constants.ts @@ -15,6 +15,16 @@ export const SKIN_TONE_CODES = [ 0x1f3ff, // Dark skin tone ] as const; +// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected. +export const EMOJI_MODE_NATIVE = 'native'; +export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags'; +export const EMOJI_MODE_TWEMOJI = 'twemoji'; + +export const EMOJI_TYPE_UNICODE = 'unicode'; +export const EMOJI_TYPE_CUSTOM = 'custom'; + +export const EMOJI_STATE_MISSING = 'missing'; + export const EMOJIS_WITH_DARK_BORDER = [ '🎱', // 1F3B1 '🐜', // 1F41C diff --git a/app/javascript/mastodon/features/emoji/database.ts b/app/javascript/mastodon/features/emoji/database.ts index 618f0108509..0b8ddd34fbe 100644 --- a/app/javascript/mastodon/features/emoji/database.ts +++ b/app/javascript/mastodon/features/emoji/database.ts @@ -1,17 +1,19 @@ import { SUPPORTED_LOCALES } from 'emojibase'; -import type { FlatCompactEmoji, Locale } from 'emojibase'; -import type { DBSchema } from 'idb'; +import type { Locale } from 'emojibase'; +import type { DBSchema, IDBPDatabase } from 'idb'; import { openDB } from 'idb'; -import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; - -import type { LocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; +import type { + CustomEmojiData, + UnicodeEmojiData, + LocaleOrCustom, +} from './types'; interface EmojiDB extends LocaleTables, DBSchema { custom: { key: string; - value: ApiCustomEmojiJSON; + value: CustomEmojiData; indexes: { category: string; }; @@ -24,7 +26,7 @@ interface EmojiDB extends LocaleTables, DBSchema { interface LocaleTable { key: string; - value: FlatCompactEmoji; + value: UnicodeEmojiData; indexes: { group: number; label: string; @@ -36,63 +38,114 @@ type LocaleTables = Record; const SCHEMA_VERSION = 1; -const db = await openDB('mastodon-emoji', SCHEMA_VERSION, { - upgrade(database) { - const customTable = database.createObjectStore('custom', { - keyPath: 'shortcode', - autoIncrement: false, - }); - customTable.createIndex('category', 'category'); +let db: IDBPDatabase | null = null; - database.createObjectStore('etags'); - - for (const locale of SUPPORTED_LOCALES) { - const localeTable = database.createObjectStore(locale, { - keyPath: 'hexcode', +async function loadDB() { + if (db) { + return db; + } + db = await openDB('mastodon-emoji', SCHEMA_VERSION, { + upgrade(database) { + const customTable = database.createObjectStore('custom', { + keyPath: 'shortcode', autoIncrement: false, }); - localeTable.createIndex('group', 'group'); - localeTable.createIndex('label', 'label'); - localeTable.createIndex('order', 'order'); - localeTable.createIndex('tags', 'tags', { multiEntry: true }); - } - }, -}); + customTable.createIndex('category', 'category'); -export async function putEmojiData(emojis: FlatCompactEmoji[], locale: Locale) { + database.createObjectStore('etags'); + + for (const locale of SUPPORTED_LOCALES) { + const localeTable = database.createObjectStore(locale, { + keyPath: 'hexcode', + autoIncrement: false, + }); + localeTable.createIndex('group', 'group'); + localeTable.createIndex('label', 'label'); + localeTable.createIndex('order', 'order'); + localeTable.createIndex('tags', 'tags', { multiEntry: true }); + } + }, + }); + return db; +} + +export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) { + const db = await loadDB(); const trx = db.transaction(locale, 'readwrite'); await Promise.all(emojis.map((emoji) => trx.store.put(emoji))); await trx.done; } -export async function putCustomEmojiData(emojis: ApiCustomEmojiJSON[]) { +export async function putCustomEmojiData(emojis: CustomEmojiData[]) { + const db = await loadDB(); const trx = db.transaction('custom', 'readwrite'); await Promise.all(emojis.map((emoji) => trx.store.put(emoji))); await trx.done; } -export function putLatestEtag(etag: string, localeString: string) { +export async function putLatestEtag(etag: string, localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); + const db = await loadDB(); return db.put('etags', etag, locale); } -export function searchEmojiByHexcode(hexcode: string, localeString: string) { +export async function searchEmojiByHexcode( + hexcode: string, + localeString: string, +) { const locale = toSupportedLocale(localeString); + const db = await loadDB(); return db.get(locale, hexcode); } -export function searchEmojiByTag(tag: string, localeString: string) { +export async function searchEmojisByHexcodes( + hexcodes: string[], + localeString: string, +) { + const locale = toSupportedLocale(localeString); + const db = await loadDB(); + return db.getAll( + locale, + IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]), + ); +} + +export async function searchEmojiByTag(tag: string, localeString: string) { const locale = toSupportedLocale(localeString); const range = IDBKeyRange.only(tag.toLowerCase()); + const db = await loadDB(); return db.getAllFromIndex(locale, 'tags', range); } -export function searchCustomEmojiByShortcode(shortcode: string) { +export async function searchCustomEmojiByShortcode(shortcode: string) { + const db = await loadDB(); return db.get('custom', shortcode); } +export async function searchCustomEmojisByShortcodes(shortcodes: string[]) { + const db = await loadDB(); + return db.getAll( + 'custom', + IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]), + ); +} + +export async function findMissingLocales(localeStrings: string[]) { + const locales = new Set(localeStrings.map(toSupportedLocale)); + const missingLocales: Locale[] = []; + const db = await loadDB(); + for (const locale of locales) { + const rowCount = await db.count(locale); + if (!rowCount) { + missingLocales.push(locale); + } + } + return missingLocales; +} + export async function loadLatestEtag(localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); + const db = await loadDB(); const rowCount = await db.count(locale); if (!rowCount) { return null; // No data for this locale, return null even if there is an etag. diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx new file mode 100644 index 00000000000..27af2dda279 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -0,0 +1,81 @@ +import type { HTMLAttributes } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +import type { List as ImmutableList } from 'immutable'; +import { isList } from 'immutable'; + +import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; +import { isFeatureEnabled } from '@/mastodon/initial_state'; +import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; + +import { useEmojiAppState } from './hooks'; +import { emojifyElement } from './render'; +import type { ExtraCustomEmojiMap } from './types'; + +type EmojiHTMLProps = Omit< + HTMLAttributes, + 'dangerouslySetInnerHTML' +> & { + htmlString: string; + extraEmojis?: ExtraCustomEmojiMap | ImmutableList; +}; + +export const EmojiHTML: React.FC = ({ + htmlString, + extraEmojis, + ...props +}) => { + if (isFeatureEnabled('modern_emojis')) { + return ( + + ); + } + return
; +}; + +const ModernEmojiHTML: React.FC = ({ + extraEmojis: rawEmojis, + htmlString: text, + ...props +}) => { + const appState = useEmojiAppState(); + const [innerHTML, setInnerHTML] = useState(''); + + const extraEmojis: ExtraCustomEmojiMap = useMemo(() => { + if (!rawEmojis) { + return {}; + } + if (isList(rawEmojis)) { + return ( + rawEmojis.toJS() as ApiCustomEmojiJSON[] + ).reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); + } + return rawEmojis; + }, [rawEmojis]); + + useEffect(() => { + if (!text) { + return; + } + const cb = async () => { + const div = document.createElement('div'); + div.innerHTML = text; + const ele = await emojifyElement(div, appState, extraEmojis); + setInnerHTML(ele.innerHTML); + }; + void cb(); + }, [text, appState, extraEmojis]); + + if (!innerHTML) { + return null; + } + + return
; +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_text.tsx b/app/javascript/mastodon/features/emoji/emoji_text.tsx new file mode 100644 index 00000000000..253371391a4 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_text.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; + +import { useEmojiAppState } from './hooks'; +import { emojifyText } from './render'; + +interface EmojiTextProps { + text: string; +} + +export const EmojiText: React.FC = ({ text }) => { + const appState = useEmojiAppState(); + const [rendered, setRendered] = useState<(string | HTMLImageElement)[]>([]); + + useEffect(() => { + const cb = async () => { + const rendered = await emojifyText(text, appState); + setRendered(rendered ?? []); + }; + void cb(); + }, [text, appState]); + + if (rendered.length === 0) { + return null; + } + + return ( + <> + {rendered.map((fragment, index) => { + if (typeof fragment === 'string') { + return {fragment}; + } + return ( + {fragment.alt} + ); + })} + + ); +}; diff --git a/app/javascript/mastodon/features/emoji/hooks.ts b/app/javascript/mastodon/features/emoji/hooks.ts new file mode 100644 index 00000000000..fd38129a19b --- /dev/null +++ b/app/javascript/mastodon/features/emoji/hooks.ts @@ -0,0 +1,16 @@ +import { useAppSelector } from '@/mastodon/store'; + +import { toSupportedLocale } from './locale'; +import { determineEmojiMode } from './mode'; +import type { EmojiAppState } from './types'; + +export function useEmojiAppState(): EmojiAppState { + const locale = useAppSelector((state) => + toSupportedLocale(state.meta.get('locale') as string), + ); + const mode = useAppSelector((state) => + determineEmojiMode(state.meta.get('emoji_style') as string), + ); + + return { currentLocale: locale, locales: [locale], mode }; +} diff --git a/app/javascript/mastodon/features/emoji/index.ts b/app/javascript/mastodon/features/emoji/index.ts index 6975465b55f..ef6cd67aeb5 100644 --- a/app/javascript/mastodon/features/emoji/index.ts +++ b/app/javascript/mastodon/features/emoji/index.ts @@ -2,7 +2,7 @@ import initialState from '@/mastodon/initial_state'; import { toSupportedLocale } from './locale'; -const serverLocale = toSupportedLocale(initialState?.meta.locale ?? 'en'); +const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en'); const worker = 'Worker' in window @@ -16,13 +16,22 @@ export async function initializeEmoji() { worker.addEventListener('message', (event: MessageEvent) => { const { data: message } = event; if (message === 'ready') { - worker.postMessage(serverLocale); worker.postMessage('custom'); + void loadEmojiLocale(userLocale); + // Load English locale as well, because people are still used to + // using it from before we supported other locales. + if (userLocale !== 'en') { + void loadEmojiLocale('en'); + } } }); } else { - const { importCustomEmojiData, importEmojiData } = await import('./loader'); - await Promise.all([importCustomEmojiData(), importEmojiData(serverLocale)]); + const { importCustomEmojiData } = await import('./loader'); + await importCustomEmojiData(); + await loadEmojiLocale(userLocale); + if (userLocale !== 'en') { + await loadEmojiLocale('en'); + } } } diff --git a/app/javascript/mastodon/features/emoji/loader.ts b/app/javascript/mastodon/features/emoji/loader.ts index f9c69713515..482d9e5c359 100644 --- a/app/javascript/mastodon/features/emoji/loader.ts +++ b/app/javascript/mastodon/features/emoji/loader.ts @@ -11,7 +11,7 @@ import { putLatestEtag, } from './database'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; -import type { LocaleOrCustom } from './locale'; +import type { LocaleOrCustom } from './types'; export async function importEmojiData(localeString: string) { const locale = toSupportedLocale(localeString); diff --git a/app/javascript/mastodon/features/emoji/locale.ts b/app/javascript/mastodon/features/emoji/locale.ts index 561c94afb0a..8ff23f5161a 100644 --- a/app/javascript/mastodon/features/emoji/locale.ts +++ b/app/javascript/mastodon/features/emoji/locale.ts @@ -1,7 +1,7 @@ import type { Locale } from 'emojibase'; import { SUPPORTED_LOCALES } from 'emojibase'; -export type LocaleOrCustom = Locale | 'custom'; +import type { LocaleOrCustom } from './types'; export function toSupportedLocale(localeBase: string): Locale { const locale = localeBase.toLowerCase(); diff --git a/app/javascript/mastodon/features/emoji/mode.ts b/app/javascript/mastodon/features/emoji/mode.ts new file mode 100644 index 00000000000..0f581d8b504 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/mode.ts @@ -0,0 +1,119 @@ +// Credit to Nolan Lawson for the original implementation. +// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js + +import { isDevelopment } from '@/mastodon/utils/environment'; + +import { + EMOJI_MODE_NATIVE, + EMOJI_MODE_NATIVE_WITH_FLAGS, + EMOJI_MODE_TWEMOJI, +} from './constants'; +import type { EmojiMode } from './types'; + +type Feature = Uint8ClampedArray; + +// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js +const FONT_FAMILY = + '"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' + + '"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif'; + +function getTextFeature(text: string, color: string) { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + + const ctx = canvas.getContext('2d', { + // Improves the performance of `getImageData()` + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently + willReadFrequently: true, + }); + if (!ctx) { + throw new Error('Canvas context not available'); + } + ctx.textBaseline = 'top'; + ctx.font = `100px ${FONT_FAMILY}`; + ctx.fillStyle = color; + ctx.scale(0.01, 0.01); + ctx.fillText(text, 0, 0); + + return ctx.getImageData(0, 0, 1, 1).data satisfies Feature; +} + +function compareFeatures(feature1: Feature, feature2: Feature) { + const feature1Str = [...feature1].join(','); + const feature2Str = [...feature2].join(','); + // This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes. + // Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is + // 0,0,0,61 - there is a transparency here. + return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,'); +} + +function testEmojiSupport(text: string) { + // Render white and black and then compare them to each other and ensure they're the same + // color, and neither one is black. This shows that the emoji was rendered in color. + const feature1 = getTextFeature(text, '#000'); + const feature2 = getTextFeature(text, '#fff'); + return compareFeatures(feature1, feature2); +} + +const EMOJI_VERSION_TEST_EMOJI = '🫨'; // shaking head, from v15 +const EMOJI_FLAG_TEST_EMOJI = 'πŸ‡¨πŸ‡­'; + +export function determineEmojiMode(style: string): EmojiMode { + if (style === EMOJI_MODE_NATIVE) { + // If flags are not supported, we replace them with Twemoji. + if (shouldReplaceFlags()) { + return EMOJI_MODE_NATIVE_WITH_FLAGS; + } + return EMOJI_MODE_NATIVE; + } + if (style === EMOJI_MODE_TWEMOJI) { + return EMOJI_MODE_TWEMOJI; + } + + // Auto style so determine based on browser capabilities. + if (shouldUseTwemoji()) { + return EMOJI_MODE_TWEMOJI; + } else if (shouldReplaceFlags()) { + return EMOJI_MODE_NATIVE_WITH_FLAGS; + } + return EMOJI_MODE_NATIVE; +} + +export function shouldUseTwemoji(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + // Test a known color emoji to see if 15.1 is supported. + return !testEmojiSupport(EMOJI_VERSION_TEST_EMOJI); + } catch (err: unknown) { + // If an error occurs, fall back to Twemoji to be safe. + if (isDevelopment()) { + console.warn( + 'Emoji rendering test failed, defaulting to Twemoji. Error:', + err, + ); + } + return true; + } +} + +// Based on https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L19 +export function shouldReplaceFlags(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + // Test a known flag emoji to see if it is rendered in color. + return !testEmojiSupport(EMOJI_FLAG_TEST_EMOJI); + } catch (err: unknown) { + // If an error occurs, assume flags should be replaced. + if (isDevelopment()) { + console.warn( + 'Flag emoji rendering test failed, defaulting to replacement. Error:', + err, + ); + } + return true; + } +} diff --git a/app/javascript/mastodon/features/emoji/normalize.test.ts b/app/javascript/mastodon/features/emoji/normalize.test.ts index ee9cd89487f..f0ea140590b 100644 --- a/app/javascript/mastodon/features/emoji/normalize.test.ts +++ b/app/javascript/mastodon/features/emoji/normalize.test.ts @@ -22,9 +22,9 @@ const emojiSVGFiles = await readdir( ); const svgFileNames = emojiSVGFiles .filter((file) => file.isFile() && file.name.endsWith('.svg')) - .map((file) => basename(file.name, '.svg').toUpperCase()); + .map((file) => basename(file.name, '.svg')); const svgFileNamesWithoutBorder = svgFileNames.filter( - (fileName) => !fileName.endsWith('_BORDER'), + (fileName) => !fileName.endsWith('_border'), ); const unicodeEmojis = flattenEmojiData(unicodeRawEmojis); @@ -60,13 +60,13 @@ describe('unicodeToTwemojiHex', () => { describe('twemojiHasBorder', () => { test.concurrent.for( svgFileNames - .filter((file) => file.endsWith('_BORDER')) + .filter((file) => file.endsWith('_border')) .map((file) => { - const hexCode = file.replace('_BORDER', ''); + const hexCode = file.replace('_border', ''); return [ hexCode, - CODES_WITH_LIGHT_BORDER.includes(hexCode), - CODES_WITH_DARK_BORDER.includes(hexCode), + CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()), + CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()), ] as const; }), )('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => { diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 94dc33a6ea2..6a64c3b8bfa 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -7,6 +7,7 @@ import { EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_LIGHT_BORDER, } from './constants'; +import type { TwemojiBorderInfo } from './types'; // Misc codes that have special handling const SKIER_CODE = 0x26f7; @@ -51,13 +52,7 @@ export function unicodeToTwemojiHex(unicodeHex: string): string { normalizedCodes.push(code); } - return hexNumbersToString(normalizedCodes, 0); -} - -interface TwemojiBorderInfo { - hexCode: string; - hasLightBorder: boolean; - hasDarkBorder: boolean; + return hexNumbersToString(normalizedCodes, 0).toLowerCase(); } export const CODES_WITH_DARK_BORDER = @@ -77,7 +72,7 @@ export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo { hasDarkBorder = true; } return { - hexCode: normalizedHex, + hexCode: twemojiHex, hasLightBorder, hasDarkBorder, }; diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts new file mode 100644 index 00000000000..23f85c36b3e --- /dev/null +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -0,0 +1,163 @@ +import { + EMOJI_MODE_NATIVE, + EMOJI_MODE_NATIVE_WITH_FLAGS, + EMOJI_MODE_TWEMOJI, +} from './constants'; +import { emojifyElement, tokenizeText } from './render'; +import type { CustomEmojiData, UnicodeEmojiData } from './types'; + +vitest.mock('./database', () => ({ + searchCustomEmojisByShortcodes: vitest.fn( + () => + [ + { + shortcode: 'custom', + static_url: 'emoji/static', + url: 'emoji/custom', + category: 'test', + visible_in_picker: true, + }, + ] satisfies CustomEmojiData[], + ), + searchEmojisByHexcodes: vitest.fn( + () => + [ + { + hexcode: '1F60A', + group: 0, + label: 'smiling face with smiling eyes', + order: 0, + tags: ['smile', 'happy'], + unicode: '😊', + }, + { + hexcode: '1F1EA-1F1FA', + group: 0, + label: 'flag-eu', + order: 0, + tags: ['flag', 'european union'], + unicode: 'πŸ‡ͺπŸ‡Ί', + }, + ] satisfies UnicodeEmojiData[], + ), + findMissingLocales: vitest.fn(() => []), +})); + +describe('emojifyElement', () => { + const testElement = document.createElement('div'); + testElement.innerHTML = '

Hello 😊πŸ‡ͺπŸ‡Ί!

:custom:

'; + + const expectedSmileImage = + '😊'; + const expectedFlagImage = + 'πŸ‡ͺπŸ‡Ί'; + const expectedCustomEmojiImage = + ':custom:'; + + function cloneTestElement() { + return testElement.cloneNode(true) as HTMLElement; + } + + test('emojifies custom emoji in native mode', async () => { + const emojifiedElement = await emojifyElement(cloneTestElement(), { + locales: ['en'], + mode: EMOJI_MODE_NATIVE, + currentLocale: 'en', + }); + expect(emojifiedElement.innerHTML).toBe( + `

Hello 😊πŸ‡ͺπŸ‡Ί!

${expectedCustomEmojiImage}

`, + ); + }); + + test('emojifies flag emoji in native-with-flags mode', async () => { + const emojifiedElement = await emojifyElement(cloneTestElement(), { + locales: ['en'], + mode: EMOJI_MODE_NATIVE_WITH_FLAGS, + currentLocale: 'en', + }); + expect(emojifiedElement.innerHTML).toBe( + `

Hello 😊${expectedFlagImage}!

${expectedCustomEmojiImage}

`, + ); + }); + + test('emojifies everything in twemoji mode', async () => { + const emojifiedElement = await emojifyElement(cloneTestElement(), { + locales: ['en'], + mode: EMOJI_MODE_TWEMOJI, + currentLocale: 'en', + }); + expect(emojifiedElement.innerHTML).toBe( + `

Hello ${expectedSmileImage}${expectedFlagImage}!

${expectedCustomEmojiImage}

`, + ); + }); +}); + +describe('tokenizeText', () => { + test('returns empty array for string with only whitespace', () => { + expect(tokenizeText(' \n')).toEqual([]); + }); + + test('returns an array of text to be a single token', () => { + expect(tokenizeText('Hello')).toEqual(['Hello']); + }); + + test('returns tokens for text with emoji', () => { + expect(tokenizeText('Hello 😊 πŸ‡ΏπŸ‡Ό!!')).toEqual([ + 'Hello ', + { + type: 'unicode', + code: '😊', + }, + ' ', + { + type: 'unicode', + code: 'πŸ‡ΏπŸ‡Ό', + }, + '!!', + ]); + }); + + test('returns tokens for text with custom emoji', () => { + expect(tokenizeText('Hello :smile:!!')).toEqual([ + 'Hello ', + { + type: 'custom', + code: 'smile', + }, + '!!', + ]); + }); + + test('handles custom emoji with underscores and numbers', () => { + expect(tokenizeText('Hello :smile_123:!!')).toEqual([ + 'Hello ', + { + type: 'custom', + code: 'smile_123', + }, + '!!', + ]); + }); + + test('returns tokens for text with mixed emoji', () => { + expect(tokenizeText('Hello 😊 :smile:!!')).toEqual([ + 'Hello ', + { + type: 'unicode', + code: '😊', + }, + ' ', + { + type: 'custom', + code: 'smile', + }, + '!!', + ]); + }); + + test('does not capture custom emoji with invalid characters', () => { + expect(tokenizeText('Hello :smile-123:!!')).toEqual([ + 'Hello :smile-123:!!', + ]); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts new file mode 100644 index 00000000000..8f0c1ee15fe --- /dev/null +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -0,0 +1,331 @@ +import type { Locale } from 'emojibase'; +import EMOJI_REGEX from 'emojibase-regex/emoji-loose'; + +import { autoPlayGif } from '@/mastodon/initial_state'; +import { assetHost } from '@/mastodon/utils/config'; + +import { loadEmojiLocale } from '.'; +import { + EMOJI_MODE_NATIVE, + EMOJI_MODE_NATIVE_WITH_FLAGS, + EMOJI_TYPE_UNICODE, + EMOJI_TYPE_CUSTOM, + EMOJI_STATE_MISSING, +} from './constants'; +import { + findMissingLocales, + searchCustomEmojisByShortcodes, + searchEmojisByHexcodes, +} from './database'; +import { + emojiToUnicodeHex, + twemojiHasBorder, + unicodeToTwemojiHex, +} from './normalize'; +import type { + CustomEmojiToken, + EmojiAppState, + EmojiLoadedState, + EmojiMode, + EmojiState, + EmojiStateMap, + EmojiToken, + ExtraCustomEmojiMap, + LocaleOrCustom, + UnicodeEmojiToken, +} from './types'; +import { stringHasUnicodeFlags } from './utils'; + +const localeCacheMap = new Map([ + [EMOJI_TYPE_CUSTOM, new Map()], +]); + +// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions. +export async function emojifyElement( + element: Element, + appState: EmojiAppState, + extraEmojis: ExtraCustomEmojiMap = {}, +): Promise { + const queue: (HTMLElement | Text)[] = [element]; + while (queue.length > 0) { + const current = queue.shift(); + if ( + !current || + current instanceof HTMLScriptElement || + current instanceof HTMLStyleElement + ) { + continue; + } + + if ( + current.textContent && + (current instanceof Text || !current.hasChildNodes()) + ) { + const renderedContent = await emojifyText( + current.textContent, + appState, + extraEmojis, + ); + if (renderedContent) { + if (!(current instanceof Text)) { + current.textContent = null; // Clear the text content if it's not a Text node. + } + current.replaceWith(renderedToHTMLFragment(renderedContent)); + } + continue; + } + + for (const child of current.childNodes) { + if (child instanceof HTMLElement || child instanceof Text) { + queue.push(child); + } + } + } + return element; +} + +export async function emojifyText( + text: string, + appState: EmojiAppState, + extraEmojis: ExtraCustomEmojiMap = {}, +) { + // Exit if no text to convert. + if (!text.trim()) { + return null; + } + + const tokens = tokenizeText(text); + + // If only one token and it's a string, exit early. + if (tokens.length === 1 && typeof tokens[0] === 'string') { + return null; + } + + // Get all emoji from the state map, loading any missing ones. + await ensureLocalesAreLoaded(appState.locales); + await loadMissingEmojiIntoCache(tokens, appState.locales); + + const renderedFragments: (string | HTMLImageElement)[] = []; + for (const token of tokens) { + if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) { + let state: EmojiState | undefined; + if (token.type === EMOJI_TYPE_CUSTOM) { + const extraEmojiData = extraEmojis[token.code]; + if (extraEmojiData) { + state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData }; + } else { + state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM); + } + } else { + state = emojiForLocale( + emojiToUnicodeHex(token.code), + appState.currentLocale, + ); + } + + // If the state is valid, create an image element. Otherwise, just append as text. + if (state && typeof state !== 'string') { + const image = stateToImage(state); + renderedFragments.push(image); + continue; + } + } + const text = typeof token === 'string' ? token : token.code; + renderedFragments.push(text); + } + + return renderedFragments; +} + +// Private functions + +async function ensureLocalesAreLoaded(locales: Locale[]) { + const missingLocales = await findMissingLocales(locales); + for (const locale of missingLocales) { + await loadEmojiLocale(locale); + } +} + +const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i; +const TOKENIZE_REGEX = new RegExp( + `(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`, + 'g', +); + +type TokenizedText = (string | EmojiToken)[]; + +export function tokenizeText(text: string): TokenizedText { + if (!text.trim()) { + return []; + } + + const tokens = []; + let lastIndex = 0; + for (const match of text.matchAll(TOKENIZE_REGEX)) { + if (match.index > lastIndex) { + tokens.push(text.slice(lastIndex, match.index)); + } + + const code = match[0]; + + if (code.startsWith(':') && code.endsWith(':')) { + // Custom emoji + tokens.push({ + type: EMOJI_TYPE_CUSTOM, + code: code.slice(1, -1), // Remove the colons + } satisfies CustomEmojiToken); + } else { + // Unicode emoji + tokens.push({ + type: EMOJI_TYPE_UNICODE, + code: code, + } satisfies UnicodeEmojiToken); + } + lastIndex = match.index + code.length; + } + if (lastIndex < text.length) { + tokens.push(text.slice(lastIndex)); + } + return tokens; +} + +function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap { + return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap); +} + +function emojiForLocale( + code: string, + locale: LocaleOrCustom, +): EmojiState | undefined { + const cache = cacheForLocale(locale); + return cache.get(code); +} + +async function loadMissingEmojiIntoCache( + tokens: TokenizedText, + locales: Locale[], +) { + const missingUnicodeEmoji = new Set(); + const missingCustomEmoji = new Set(); + + // Iterate over tokens and check if they are in the cache already. + for (const token of tokens) { + if (typeof token === 'string') { + continue; // Skip plain strings. + } + + // If this is a custom emoji, check it separately. + if (token.type === EMOJI_TYPE_CUSTOM) { + const code = token.code; + const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM); + if (!emojiState) { + missingCustomEmoji.add(code); + } + // Otherwise this is a unicode emoji, so check it against all locales. + } else { + const code = emojiToUnicodeHex(token.code); + if (missingUnicodeEmoji.has(code)) { + continue; // Already marked as missing. + } + for (const locale of locales) { + const emojiState = emojiForLocale(code, locale); + if (!emojiState) { + // If it's missing in one locale, we consider it missing for all. + missingUnicodeEmoji.add(code); + } + } + } + } + + if (missingUnicodeEmoji.size > 0) { + const missingEmojis = Array.from(missingUnicodeEmoji).toSorted(); + for (const locale of locales) { + const emojis = await searchEmojisByHexcodes(missingEmojis, locale); + const cache = cacheForLocale(locale); + for (const emoji of emojis) { + cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji }); + } + const notFoundEmojis = missingEmojis.filter((code) => + emojis.every((emoji) => emoji.hexcode !== code), + ); + for (const code of notFoundEmojis) { + cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji. + } + localeCacheMap.set(locale, cache); + } + } + + if (missingCustomEmoji.size > 0) { + const missingEmojis = Array.from(missingCustomEmoji).toSorted(); + const emojis = await searchCustomEmojisByShortcodes(missingEmojis); + const cache = cacheForLocale(EMOJI_TYPE_CUSTOM); + for (const emoji of emojis) { + cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji }); + } + const notFoundEmojis = missingEmojis.filter((code) => + emojis.every((emoji) => emoji.shortcode !== code), + ); + for (const code of notFoundEmojis) { + cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji. + } + localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache); + } +} + +function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean { + if (token.type === EMOJI_TYPE_UNICODE) { + // If the mode is native or native with flags for non-flag emoji + // we can just append the text node directly. + if ( + mode === EMOJI_MODE_NATIVE || + (mode === EMOJI_MODE_NATIVE_WITH_FLAGS && + !stringHasUnicodeFlags(token.code)) + ) { + return false; + } + } + + return true; +} + +function stateToImage(state: EmojiLoadedState) { + const image = document.createElement('img'); + image.draggable = false; + image.classList.add('emojione'); + + if (state.type === EMOJI_TYPE_UNICODE) { + const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode)); + if (emojiInfo.hasLightBorder) { + image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`; + } else if (emojiInfo.hasDarkBorder) { + image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`; + } + + image.alt = state.data.unicode; + image.title = state.data.label; + image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`; + } else { + // Custom emoji + const shortCode = `:${state.data.shortcode}:`; + image.classList.add('custom-emoji'); + image.alt = shortCode; + image.title = shortCode; + image.src = autoPlayGif ? state.data.url : state.data.static_url; + image.dataset.original = state.data.url; + image.dataset.static = state.data.static_url; + } + + return image; +} + +function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) { + const fragment = document.createDocumentFragment(); + for (const fragmentItem of renderedArray) { + if (typeof fragmentItem === 'string') { + fragment.appendChild(document.createTextNode(fragmentItem)); + } else if (fragmentItem instanceof HTMLImageElement) { + fragment.appendChild(fragmentItem); + } + } + return fragment; +} diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts new file mode 100644 index 00000000000..f5932ed97fd --- /dev/null +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -0,0 +1,64 @@ +import type { FlatCompactEmoji, Locale } from 'emojibase'; + +import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; + +import type { + EMOJI_MODE_NATIVE, + EMOJI_MODE_NATIVE_WITH_FLAGS, + EMOJI_MODE_TWEMOJI, + EMOJI_STATE_MISSING, + EMOJI_TYPE_CUSTOM, + EMOJI_TYPE_UNICODE, +} from './constants'; + +export type EmojiMode = + | typeof EMOJI_MODE_NATIVE + | typeof EMOJI_MODE_NATIVE_WITH_FLAGS + | typeof EMOJI_MODE_TWEMOJI; + +export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM; + +export interface EmojiAppState { + locales: Locale[]; + currentLocale: Locale; + mode: EmojiMode; +} + +export interface UnicodeEmojiToken { + type: typeof EMOJI_TYPE_UNICODE; + code: string; +} +export interface CustomEmojiToken { + type: typeof EMOJI_TYPE_CUSTOM; + code: string; +} +export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken; + +export type CustomEmojiData = ApiCustomEmojiJSON; +export type UnicodeEmojiData = FlatCompactEmoji; +export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData; + +export type EmojiStateMissing = typeof EMOJI_STATE_MISSING; +export interface EmojiStateUnicode { + type: typeof EMOJI_TYPE_UNICODE; + data: UnicodeEmojiData; +} +export interface EmojiStateCustom { + type: typeof EMOJI_TYPE_CUSTOM; + data: CustomEmojiData; +} +export type EmojiState = + | EmojiStateMissing + | EmojiStateUnicode + | EmojiStateCustom; +export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom; + +export type EmojiStateMap = Map; + +export type ExtraCustomEmojiMap = Record; + +export interface TwemojiBorderInfo { + hexCode: string; + hasLightBorder: boolean; + hasDarkBorder: boolean; +} diff --git a/app/javascript/mastodon/features/emoji/utils.test.ts b/app/javascript/mastodon/features/emoji/utils.test.ts new file mode 100644 index 00000000000..75cac8c5b4c --- /dev/null +++ b/app/javascript/mastodon/features/emoji/utils.test.ts @@ -0,0 +1,47 @@ +import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils'; + +describe('stringHasEmoji', () => { + test.concurrent.for([ + ['only text', false], + ['text with emoji πŸ˜€', true], + ['multiple emojis πŸ˜€πŸ˜ƒπŸ˜„', true], + ['emoji with skin tone πŸ‘πŸ½', true], + ['emoji with ZWJ πŸ‘©β€β€οΈβ€πŸ‘¨', true], + ['emoji with variation selector ✊️', true], + ['emoji with keycap 1️⃣', true], + ['emoji with flags πŸ‡ΊπŸ‡Έ', true], + ['emoji with regional indicators πŸ‡¦πŸ‡Ί', true], + ['emoji with gender πŸ‘©β€βš•οΈ', true], + ['emoji with family πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', true], + ['emoji with zero width joiner πŸ‘©β€πŸ”¬', true], + ['emoji with non-BMP codepoint πŸ§‘β€πŸš€', true], + ['emoji with combining marks πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', true], + ['emoji with enclosing keycap #️⃣', true], + ['emoji with no visible glyph \u200D', false], + ] as const)( + 'stringHasEmoji has emojis in "%s": %o', + ([text, expected], { expect }) => { + expect(stringHasUnicodeEmoji(text)).toBe(expected); + }, + ); +}); + +describe('stringHasFlags', () => { + test.concurrent.for([ + ['EU πŸ‡ͺπŸ‡Ί', true], + ['Germany πŸ‡©πŸ‡ͺ', true], + ['Canada πŸ‡¨πŸ‡¦', true], + ['SΓ£o TomΓ© & PrΓ­ncipe πŸ‡ΈπŸ‡Ή', true], + ['Scotland 🏴󠁧󠁒󠁳󠁣󠁴󠁿', true], + ['black flag 🏴', false], + ['arrr πŸ΄β€β˜ οΈ', false], + ['rainbow flag πŸ³οΈβ€πŸŒˆ', false], + ['non-flag πŸ”₯', false], + ['only text', false], + ] as const)( + 'stringHasFlags has flag in "%s": %o', + ([text, expected], { expect }) => { + expect(stringHasUnicodeFlags(text)).toBe(expected); + }, + ); +}); diff --git a/app/javascript/mastodon/features/emoji/utils.ts b/app/javascript/mastodon/features/emoji/utils.ts new file mode 100644 index 00000000000..d00accea8c5 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/utils.ts @@ -0,0 +1,13 @@ +import EMOJI_REGEX from 'emojibase-regex/emoji-loose'; + +export function stringHasUnicodeEmoji(text: string): boolean { + return EMOJI_REGEX.test(text); +} + +// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50 +const EMOJIS_FLAGS_REGEX = + /[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u; + +export function stringHasUnicodeFlags(text: string): boolean { + return EMOJIS_FLAGS_REGEX.test(text); +} diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 590c4c8d2b4..7763d9cb798 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -45,6 +45,7 @@ * @property {string} sso_redirect * @property {string} status_page_url * @property {boolean} terms_of_service_enabled + * @property {string?} emoji_style */ /** @@ -95,6 +96,7 @@ export const disableHoverCards = getMeta('disable_hover_cards'); export const disabledAccountId = getMeta('disabled_account_id'); export const displayMedia = getMeta('display_media'); export const domain = getMeta('domain'); +export const emojiStyle = getMeta('emoji_style') || 'auto'; export const expandSpoilers = getMeta('expand_spoilers'); export const forceSingleColumn = !getMeta('advanced_layout'); export const limitedFederationMode = getMeta('limited_federation_mode'); diff --git a/app/javascript/mastodon/main.tsx b/app/javascript/mastodon/main.tsx index 70e6391beee..e840429c41e 100644 --- a/app/javascript/mastodon/main.tsx +++ b/app/javascript/mastodon/main.tsx @@ -4,7 +4,7 @@ import { Globals } from '@react-spring/web'; import { setupBrowserNotifications } from 'mastodon/actions/notifications'; import Mastodon from 'mastodon/containers/mastodon'; -import { me, reduceMotion } from 'mastodon/initial_state'; +import { isFeatureEnabled, me, reduceMotion } from 'mastodon/initial_state'; import * as perf from 'mastodon/performance'; import ready from 'mastodon/ready'; import { store } from 'mastodon/store'; @@ -29,6 +29,11 @@ function main() { }); } + if (isFeatureEnabled('modern_emojis')) { + const { initializeEmoji } = await import('@/mastodon/features/emoji'); + await initializeEmoji(); + } + const root = createRoot(mountNode); root.render(); store.dispatch(setupBrowserNotifications()); diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index ddcb214a472..cc95d8e7543 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -30,6 +30,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:use_blurhash] = object_account_user.setting_use_blurhash store[:use_pending_items] = object_account_user.setting_use_pending_items store[:show_trends] = Setting.trends && object_account_user.setting_trends + store[:emoji_style] = object_account_user.settings['web.emoji_style'] if Mastodon::Feature.modern_emojis_enabled? else store[:auto_play_gif] = Setting.auto_play_gif store[:display_media] = Setting.display_media diff --git a/package.json b/package.json index e1085909b76..1d1952e262f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "emoji-mart": "npm:emoji-mart-lazyload@latest", "emojibase": "^16.0.0", "emojibase-data": "^16.0.3", + "emojibase-regex": "^16.0.0", "escape-html": "^1.0.3", "fast-glob": "^3.3.3", "fuzzysort": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 598b15acb53..147da72ad12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2671,6 +2671,7 @@ __metadata: emoji-mart: "npm:emoji-mart-lazyload@latest" emojibase: "npm:^16.0.0" emojibase-data: "npm:^16.0.3" + emojibase-regex: "npm:^16.0.0" escape-html: "npm:^1.0.3" eslint: "npm:^9.23.0" eslint-import-resolver-typescript: "npm:^4.2.5" @@ -6607,6 +6608,13 @@ __metadata: languageName: node linkType: hard +"emojibase-regex@npm:^16.0.0": + version: 16.0.0 + resolution: "emojibase-regex@npm:16.0.0" + checksum: 10c0/8ee5ff798e51caa581434b1cb2f9737e50195093c4efa1739df21a50a5496f80517924787d865e8cf7d6a0b4c90dbedc04bdc506dcbcc582e14cdf0bb47af0f0 + languageName: node + linkType: hard + "emojibase@npm:^16.0.0": version: 16.0.0 resolution: "emojibase@npm:16.0.0"