Remove legacy emojify function (#38965)

This commit is contained in:
Echo
2026-05-08 16:05:31 +02:00
committed by GitHub
parent 2543425e04
commit 8d6406f561
18 changed files with 343 additions and 463 deletions

View File

@@ -13,14 +13,17 @@ import axios from 'axios';
import { on } from 'delegated-events';
import { throttle } from 'lodash';
import { determineEmojiMode } from '@/mastodon/features/emoji/mode';
import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render';
import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '@/mastodon/locales';
import { loadPolyfills } from '@/mastodon/polyfills';
import ready from '@/mastodon/ready';
import { assetHost } from '@/mastodon/utils/config';
import { getNestedProperty } from '@/mastodon/utils/objects';
import { isDarkMode } from '@/mastodon/utils/theme';
import { formatTime } from '@/mastodon/utils/time';
import emojify from '../mastodon/features/emoji/emoji';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '../mastodon/locales';
import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready';
import 'cocoon-js-vanilla';
const messages = defineMessages({
@@ -38,7 +41,7 @@ const messages = defineMessages({
},
});
function loaded() {
async function loaded() {
const { messages: localeData } = getLocale();
const locale = document.documentElement.lang;
@@ -75,9 +78,30 @@ function loaded() {
return messageFormat.format(values) as string;
};
document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML);
});
let emojiStyle = 'auto';
const initialStateText =
document.getElementById('initial-state')?.textContent;
if (initialStateText) {
const stateEmojiStyle = getNestedProperty(
JSON.parse(initialStateText) as unknown,
'meta',
'emoji_style',
);
if (typeof stateEmojiStyle === 'string') {
emojiStyle = stateEmojiStyle;
}
}
const emojiMode = determineEmojiMode(emojiStyle);
const darkTheme = isDarkMode();
for (const element of document.querySelectorAll('.emojify')) {
await updateHtmlWithEmoji({
assetHost,
element,
locale,
mode: emojiMode,
darkTheme,
});
}
document
.querySelectorAll<HTMLTimeElement>('time.formatted')

View File

@@ -6,7 +6,6 @@ import { throttle } from 'lodash';
import api from 'mastodon/api';
import { browserHistory } from 'mastodon/components/router';
import { countableText } from 'mastodon/features/compose/util/counter';
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'mastodon/settings';
import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker';
@@ -593,7 +592,8 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
const custom = await fetchCustomEmojiData();
const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom });
const { search } = await import('@/mastodon/features/emoji/emoji_mart_search_light');
const results = search(token.replace(':', ''), { maxResults: 5, custom });
dispatch(readyComposeSuggestionsEmojis(token, results));
};

View File

@@ -1,35 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<AutosuggestEmoji /> > renders emoji with custom url 1`] = `
<div
className="autosuggest-emoji"
>
<img
alt="foobar"
className="emojione"
src="http://example.com/emoji.png"
/>
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;
exports[`<AutosuggestEmoji /> > renders native emoji 1`] = `
<div
className="autosuggest-emoji"
>
<img
alt="💙"
className="emojione"
src="/emoji/1f499.svg"
/>
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;

View File

@@ -1,29 +0,0 @@
import renderer from 'react-test-renderer';
import AutosuggestEmoji from '../autosuggest_emoji';
describe('<AutosuggestEmoji />', () => {
it('renders native emoji', () => {
const emoji = {
native: '💙',
colons: ':foobar:',
};
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders emoji with custom url', () => {
const emoji = {
custom: true,
imageUrl: 'http://example.com/emoji.png',
native: 'foobar',
colons: ':foobar:',
};
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { assetHost } from 'mastodon/utils/config';
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
export default class AutosuggestEmoji extends PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,
};
render () {
const { emoji } = this.props;
let url;
if (emoji.custom) {
url = emoji.imageUrl;
} else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
return (
<div className='autosuggest-emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div>
);
}
}

View File

@@ -0,0 +1,22 @@
import type { FC } from 'react';
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
import { Emoji } from './emoji';
interface LegacyEmoji {
colons: string;
custom?: boolean;
native?: string;
imageUrl?: string;
}
export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => {
const emojis = useCustomEmojis();
return (
<div className='autosuggest-emoji'>
<Emoji code={emoji.native ?? emoji.colons} customEmoji={emojis} />
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div>
);
};

View File

@@ -9,7 +9,7 @@ import Overlay from 'react-overlays/Overlay';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestEmoji } from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {

View File

@@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestEmoji } from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => {

View File

@@ -19,6 +19,7 @@ import {
stringToEmojiState,
tokenizeText,
} from '@/mastodon/features/emoji/render';
import type { ExtraCustomEmojiMap } from '@/mastodon/features/emoji/types';
import { AnimateEmojiContext, CustomEmojiContext } from './context';
@@ -26,14 +27,19 @@ interface EmojiProps {
code: string;
showFallback?: boolean;
showLoading?: boolean;
customEmoji?: ExtraCustomEmojiMap | null;
}
export const Emoji: FC<EmojiProps> = ({
code,
showFallback = true,
showLoading = true,
customEmoji: customEmojiOverride,
}) => {
const customEmoji = useContext(CustomEmojiContext);
let customEmoji = useContext(CustomEmojiContext);
if (customEmojiOverride) {
customEmoji = customEmojiOverride;
}
// First, set the emoji state based on the input code.
const [state, setState] = useState(() =>

View File

@@ -1,97 +0,0 @@
import emojify from '../emoji';
describe('emoji', () => {
describe('.emojify', () => {
it('ignores unknown shortcodes', () => {
expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
});
it('ignores shortcodes inside of tags', () => {
expect(emojify('<p data-foo=":smile:"></p>')).toEqual('<p data-foo=":smile:"></p>');
});
it('works with unclosed tags', () => {
expect(emojify('hello>')).toEqual('hello&gt;');
expect(emojify('<hello')).toEqual('');
});
it('works with unclosed shortcodes', () => {
expect(emojify('smile:')).toEqual('smile:');
expect(emojify(':smile')).toEqual(':smile');
});
it('does unicode', () => {
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
'<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">');
expect(emojify('👨‍👩‍👧‍👧')).toEqual(
'<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">');
expect(emojify('👩‍👩‍👦')).toEqual('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">');
expect(emojify('\u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
});
it('does multiple unicode', () => {
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> bar');
});
it('ignores unicode inside of tags', () => {
expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).toEqual('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
});
it('does multiple emoji properly (issue 5188)', () => {
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
});
it('does an emoji that has no shortcode', () => {
expect(emojify('👁‍🗨')).toEqual('<img draggable="false" class="emojione" alt="👁‍🗨" title="" src="/emoji/1f441-200d-1f5e8.svg">');
});
it('does an emoji whose filename is irregular', () => {
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg">');
});
it('avoid emojifying on invisible text', () => {
expect(emojify('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>'))
.toEqual('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>');
expect(emojify('<span class="invisible">:luigi:</span>', { ':luigi:': { static_url: 'luigi.exe' } }))
.toEqual('<span class="invisible">:luigi:</span>');
});
it('avoid emojifying on invisible text with nested tags', () => {
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
expect(emojify('<span class="invisible">😄<br>😴</span>😇'))
.toEqual('<span class="invisible">😄<br>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
});
it('does not emojify emojis with textual presentation VS15 character', () => {
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
.toEqual('✴︎');
});
it('does a simple emoji properly', () => {
expect(emojify('♀♂'))
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg">');
});
it('does an emoji containing ZWJ properly', () => {
expect(emojify('💂‍♀️💂‍♂️'))
.toEqual('<img draggable="false" class="emojione" alt="💂‍♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂‍♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
});
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>'))
.toEqual('<p><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>');
});
});
});

View File

@@ -1,170 +0,0 @@
import Trie from 'substring-trie';
import { getIsSystemTheme, isDarkMode } from '@/mastodon/utils/theme';
import { assetHost } from 'mastodon/utils/config';
import { autoPlayGif } from '../../initial_state';
import { unicodeMapping } from './emoji_unicode_mapping_light';
const trie = new Trie(Object.keys(unicodeMapping));
// Convert to file names from emojis. (For different variation selector emojis)
const emojiFilenames = (emojis) => {
return emojis.map(v => unicodeMapping[v].filename);
};
// Emoji requiring extra borders depending on theme
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲', '🪮', '🐦‍⬛']);
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️', '🪽', '🪿']);
/**
* @param {string} filename
* @param {"light" | "dark" } colorScheme
* @returns {string}
*/
const emojiFilename = (filename, colorScheme) => {
const borderedEmoji = colorScheme === "light" ? lightEmoji : darkEmoji;
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
};
const emojifyTextNode = (node, customEmojis) => {
const VS15 = 0xFE0E;
const VS16 = 0xFE0F;
let str = node.textContent;
const fragment = new DocumentFragment();
let i = 0;
for (;;) {
let unicode_emoji;
// Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode:
if (customEmojis === null) {
while (i < str.length && !(unicode_emoji = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
} else {
while (i < str.length && str[i] !== ':' && !(unicode_emoji = trie.search(str.slice(i)))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
}
// We reached the end of the string, nothing to replace
if (i === str.length) {
break;
}
let rend, replacement = null;
if (str[i] === ':') { // Potentially the start of a custom emoji :shortcode:
rend = str.indexOf(':', i + 1) + 1;
// no matching ending ':', skip
if (!rend) {
i++;
continue;
}
const shortcode = str.slice(i, rend);
const custom_emoji = customEmojis[shortcode];
// not a recognized shortcode, skip
if (!custom_emoji) {
i++;
continue;
}
// now got a replacee as ':shortcode:'
// if you want additional emoji handler, add statements below which set replacement and return true.
const filename = autoPlayGif ? custom_emoji.url : custom_emoji.static_url;
replacement = document.createElement('img');
replacement.setAttribute('draggable', 'false');
replacement.setAttribute('class', 'emojione custom-emoji');
replacement.setAttribute('alt', shortcode);
replacement.setAttribute('title', shortcode);
replacement.setAttribute('src', filename);
replacement.setAttribute('data-original', custom_emoji.url);
replacement.setAttribute('data-static', custom_emoji.static_url);
} else { // start of an unicode emoji
rend = i + unicode_emoji.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend - 1) !== VS16 && str.codePointAt(rend) === VS15) {
i = rend + 1;
continue;
}
const { filename, shortCode } = unicodeMapping[unicode_emoji];
const title = shortCode ? `:${shortCode}:` : '';
const isSystemTheme = getIsSystemTheme();
const theme = (isSystemTheme || !isDarkMode()) ? 'light' : 'dark';
const imageFilename = emojiFilename(filename, theme);
const img = document.createElement('img');
img.setAttribute('draggable', 'false');
img.setAttribute('class', 'emojione');
img.setAttribute('alt', unicode_emoji);
img.setAttribute('title', title);
img.setAttribute('src', `${assetHost}/emoji/${imageFilename}.svg`);
if (isSystemTheme && imageFilename !== emojiFilename(filename, 'dark')) {
replacement = document.createElement('picture');
const source = document.createElement('source');
source.setAttribute('media', '(prefers-color-scheme: dark)');
source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, 'dark')}.svg`);
replacement.appendChild(source);
replacement.appendChild(img);
} else {
replacement = img;
}
}
// Add the processed-up-to-now string and the emoji replacement
fragment.append(document.createTextNode(str.slice(0, i)));
fragment.append(replacement);
str = str.slice(rend);
i = 0;
}
fragment.append(document.createTextNode(str));
node.parentElement.replaceChild(fragment, node);
};
const emojifyNode = (node, customEmojis) => {
for (const child of Array.from(node.childNodes)) {
switch(child.nodeType) {
case Node.TEXT_NODE:
emojifyTextNode(child, customEmojis);
break;
case Node.ELEMENT_NODE:
if (!child.classList.contains('invisible'))
emojifyNode(child, customEmojis);
break;
}
}
};
/**
* Legacy emoji processing function.
* @param {string} str
* @param {object} customEmojis
* @returns {string}
*/
const emojify = (str, customEmojis = {}) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = str;
if (!Object.keys(customEmojis).length)
customEmojis = null;
emojifyNode(wrapper, customEmojis);
return wrapper.innerHTML;
};
export default emojify;

View File

@@ -1,62 +0,0 @@
// A mapping of unicode strings to an object containing the filename
// (i.e. the svg filename) and a shortCode intended to be shown
// as a "title" attribute in an HTML element (aka tooltip).
import emojiCompressed from 'virtual:mastodon-emoji-compressed';
import type {
FilenameData,
ShortCodesToEmojiDataKey,
} from 'virtual:mastodon-emoji-compressed';
import { unicodeToFilename } from './unicode_utils';
type UnicodeMapping = Record<
FilenameData[number][0],
{
shortCode: ShortCodesToEmojiDataKey;
filename: FilenameData[number][number];
}
>;
const [
shortCodesToEmojiData,
_skins,
_categories,
_short_names,
emojisWithoutShortCodes,
] = emojiCompressed;
// decompress
const unicodeMapping: UnicodeMapping = {};
function processEmojiMapData(
emojiMapData: FilenameData[number],
shortCode?: ShortCodesToEmojiDataKey,
) {
const [native, _filename] = emojiMapData;
// filename name can be derived from unicodeToFilename
const filename = emojiMapData[1] ?? unicodeToFilename(native);
unicodeMapping[native] = {
shortCode,
filename,
};
}
Object.keys(shortCodesToEmojiData).forEach(
(shortCode: ShortCodesToEmojiDataKey) => {
if (shortCode === undefined) return;
const emojiData = shortCodesToEmojiData[shortCode];
if (!emojiData) return;
const [filenameData, _searchData] = emojiData;
filenameData.forEach((emojiMapData) => {
processEmojiMapData(emojiMapData, shortCode);
});
},
);
emojisWithoutShortCodes.forEach((emojiMapData) => {
processEmojiMapData(emojiMapData);
});
export { unicodeMapping };

View File

@@ -34,6 +34,43 @@ export function useEmojiAppState(): EmojiAppState {
};
}
export function getEmojiAppState(): EmojiAppState {
const currentLocale = toSupportedLocale(document.documentElement.lang);
let emojiStyle = 'auto';
const initialStateText =
document.getElementById('initial-state')?.textContent;
if (initialStateText) {
try {
const state = JSON.parse(initialStateText) as unknown;
if (
state !== null &&
typeof state === 'object' &&
'meta' in state &&
state.meta !== null &&
typeof state.meta === 'object' &&
'emoji_style' in state.meta &&
typeof state.meta.emoji_style === 'string'
) {
emojiStyle = state.meta.emoji_style;
}
} catch (err: unknown) {
console.warn(
'Failed to parse initial state for emoji, defaulting to auto. Error:',
err,
);
}
}
return {
currentLocale,
locales: [currentLocale],
mode: determineEmojiMode(emojiStyle),
darkTheme: isDarkMode(),
assetHost,
};
}
type Feature = Uint8ClampedArray;
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js

View File

@@ -152,11 +152,11 @@ const CODES_WITH_LIGHT_BORDER = EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
export function unicodeHexToUrl({
unicodeHex,
darkTheme,
darkTheme = true,
assetHost,
}: {
unicodeHex: string;
darkTheme: boolean;
darkTheme?: boolean;
assetHost: string;
}): string {
const normalizedHex = unicodeToTwemojiHex(unicodeHex);

View File

@@ -1,11 +1,13 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { EMOJI_MODE_TWEMOJI } from './constants';
import * as db from './database';
import * as loader from './loader';
import {
loadEmojiDataToState,
stringToEmojiState,
tokenizeText,
updateHtmlWithEmoji,
} from './render';
import type { EmojiStateCustom, EmojiStateUnicode } from './types';
@@ -107,6 +109,74 @@ describe('stringToEmojiState', () => {
});
});
describe('updateHtmlWithEmoji', () => {
const defaultOptions = {
assetHost: '',
darkTheme: false,
mode: EMOJI_MODE_TWEMOJI,
locale: 'en',
} as const;
beforeEach(() => {
vi.clearAllMocks();
});
test('updates element text with emojis', async () => {
const element = document.createElement('div');
element.textContent = '😊';
vi.spyOn(db, 'loadLegacyShortcodesByShortcode').mockResolvedValueOnce(
undefined,
);
vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce(
unicodeEmojiFactory(),
);
await updateHtmlWithEmoji({
...defaultOptions,
element,
});
const img = element.querySelector('img');
expect(img).toBeDefined();
});
test('does not update element text when mode is native', async () => {
const element = document.createElement('div');
element.textContent = '😊';
const dbShortcodeCall = vi.spyOn(db, 'loadLegacyShortcodesByShortcode');
const dbEmojiCall = vi.spyOn(db, 'loadEmojiByHexcode');
await updateHtmlWithEmoji({
...defaultOptions,
mode: 'native',
element,
});
expect(dbShortcodeCall).not.toHaveBeenCalled();
expect(dbEmojiCall).not.toHaveBeenCalled();
expect(element.textContent).toBe('😊');
});
test('does not try to load custom emojis', async () => {
const element = document.createElement('div');
element.textContent = ':smile:';
const dbShortcodeCall = vi.spyOn(db, 'loadLegacyShortcodesByShortcode');
const dbEmojiCall = vi.spyOn(db, 'loadEmojiByHexcode');
await updateHtmlWithEmoji({
...defaultOptions,
element,
});
expect(dbShortcodeCall).not.toHaveBeenCalled();
expect(dbEmojiCall).not.toHaveBeenCalled();
expect(element.textContent).toBe(':smile:');
});
});
describe('loadEmojiDataToState', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -137,6 +207,18 @@ describe('loadEmojiDataToState', () => {
});
});
test('converts unicode emoji code to hexcode when loading data', async () => {
const dbCall = vi
.spyOn(db, 'loadEmojiByHexcode')
.mockResolvedValue(unicodeEmojiFactory());
const unicodeState = {
type: 'unicode',
code: '😊',
} as const satisfies EmojiStateUnicode;
await loadEmojiDataToState(unicodeState, 'en');
expect(dbCall).toHaveBeenCalledWith('1F60A', 'en');
});
test('returns null for custom emoji without data', async () => {
const customState = {
type: 'custom',

View File

@@ -4,7 +4,9 @@ import {
EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM,
} from './constants';
import { emojiToInversionClassName, unicodeHexToUrl } from './normalize';
import type {
EmojiAppState,
EmojiLoadedState,
EmojiMode,
EmojiState,
@@ -47,14 +49,14 @@ export function tokenizeText(text: string): TokenizedText {
if (code.startsWith(':') && code.endsWith(':')) {
// Custom emoji
tokens.push({
type: EMOJI_TYPE_CUSTOM,
code,
type: EMOJI_TYPE_CUSTOM,
} satisfies EmojiStateCustom);
} else {
// Unicode emoji
tokens.push({
code,
type: EMOJI_TYPE_UNICODE,
code: code,
} satisfies EmojiStateUnicode);
}
lastIndex = match.index + code.length;
@@ -76,8 +78,8 @@ export function stringToEmojiState(
): EmojiStateUnicode | Required<EmojiStateCustom> | null {
if (isUnicodeEmoji(code)) {
return {
type: EMOJI_TYPE_UNICODE,
code: emojiToUnicodeHex(code),
type: EMOJI_TYPE_UNICODE,
};
}
@@ -95,6 +97,68 @@ export function stringToEmojiState(
return null;
}
/**
* Takes an element and emojifies all native emoji.
*/
export async function updateHtmlWithEmoji({
assetHost,
darkTheme,
element,
mode,
locale,
}: {
element: Element;
locale: string;
} & Omit<EmojiAppState, 'currentLocale' | 'locales'>) {
if (mode === EMOJI_MODE_NATIVE) {
return;
}
const tokens = tokenizeText(element.innerHTML);
const newChildren: (string | Element)[] = [];
for (const token of tokens) {
if (typeof token === 'string') {
newChildren.push(token);
continue;
}
const state = await loadEmojiDataToState(token, locale);
// Ignore custom emoji if we encounter them.
if (!state || state.type === EMOJI_TYPE_CUSTOM) {
newChildren.push(token.code);
continue;
}
if (!shouldRenderImage(state, mode)) {
newChildren.push(state.data.unicode);
continue;
}
const img = document.createElement('img');
img.src = unicodeHexToUrl({
assetHost,
darkTheme,
unicodeHex: state.data.hexcode,
});
img.alt = state.data.unicode;
img.title = state.data.label;
img.classList.add('emojione');
const inversionClass = emojiToInversionClassName(state.data.unicode);
if (inversionClass) {
img.classList.add(inversionClass);
}
newChildren.push(img);
}
element.innerHTML = newChildren.reduce<string>(
(prev, curr) =>
typeof curr === 'string' ? prev + curr : prev + curr.outerHTML,
'',
);
}
/**
* Loads emoji data into the given state if not already loaded.
* @param state Emoji state to load data for.
@@ -121,17 +185,19 @@ export async function loadEmojiDataToState(
LocaleNotLoadedError,
} = await import('./database');
const code = isUnicodeEmoji(state.code)
? emojiToUnicodeHex(state.code)
: state.code;
// First, try to load the data from IndexedDB.
try {
const legacyCode = await loadLegacyShortcodesByShortcode(state.code);
const legacyCode = await loadLegacyShortcodesByShortcode(code);
// This is duplicative, but that's because TS can't distinguish the state type easily.
const data = await loadEmojiByHexcode(
legacyCode?.hexcode ?? state.code,
locale,
);
const data = await loadEmojiByHexcode(legacyCode?.hexcode ?? code, locale);
if (data) {
return {
...state,
code,
type: EMOJI_TYPE_UNICODE,
data,
// TODO: Use CLDR shortcodes when the picker supports them.
@@ -140,14 +206,14 @@ export async function loadEmojiDataToState(
}
// If not found, assume it's not an emoji and return null.
log('Could not find emoji %s for locale %s', state.code, locale);
log('Could not find emoji %s for locale %s', code, locale);
return null;
} catch (err: unknown) {
// If the locale is not loaded, load it and retry once.
if (!retry && err instanceof LocaleNotLoadedError) {
log(
'Error loading emoji %s for locale %s, loading locale and retrying.',
state.code,
code,
locale,
);
const { importEmojiData } = await import('./loader');

View File

@@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/no-confusing-void-expression */
import { getNestedProperty } from './objects';
describe('getNestedProperty', () => {
test('returns the value of a nested property if it exists', () => {
const obj = { a: { b: { c: 42 } } };
expect(getNestedProperty(obj, 'a', 'b', 'c')).toBe(42);
});
test('returns undefined if any part of the path does not exist', () => {
const obj = { a: { b: { c: 42 } } };
expect(getNestedProperty(obj, 'a', 'x', 'c')).toBeUndefined();
expect(getNestedProperty(obj, 'a', 'b', 'x')).toBeUndefined();
expect(getNestedProperty(obj, 'x', 'b', 'c')).toBeUndefined();
});
test('returns undefined if the initial object is not a record', () => {
expect(getNestedProperty(null, 'a', 'b')).toBeUndefined();
expect(getNestedProperty(42, 'a', 'b')).toBeUndefined();
expect(getNestedProperty('string', 'a', 'b')).toBeUndefined();
});
test('returns undefined if no keys are provided', () => {
const obj = { a: 1 };
expect(getNestedProperty(obj)).toBeUndefined();
});
});

View File

@@ -0,0 +1,52 @@
import { isPlainObject } from '@reduxjs/toolkit';
export type RecordObject = Record<PropertyKey, unknown>;
export function isRecordObject(obj: unknown): obj is RecordObject {
return isPlainObject(obj);
}
type NestedProperty<T, K extends readonly PropertyKey[]> = K extends readonly [
infer Head,
...infer Tail,
]
? Head extends keyof NonNullable<T>
? Tail extends readonly PropertyKey[]
? NestedProperty<NonNullable<T>[Head], Tail>
: NonNullable<T>[Head]
: undefined
: T;
export function getNestedProperty<
TObject extends RecordObject,
const TKeys extends readonly PropertyKey[],
>(object: TObject, ...keys: TKeys): NestedProperty<TObject, TKeys> | undefined;
export function getNestedProperty(
object: unknown,
...keys: PropertyKey[]
): unknown;
export function getNestedProperty(
object: unknown,
...keys: PropertyKey[]
): unknown {
if (!isRecordObject(object) || keys.length === 0) {
return undefined;
}
const remainingKeys = [...keys];
let currentObject: RecordObject = object;
while (remainingKeys.length > 0) {
const currentKey = remainingKeys.shift();
if (currentKey !== undefined && currentKey in currentObject) {
const nextObject = currentObject[currentKey];
if (isRecordObject(nextObject)) {
currentObject = nextObject;
continue;
} else if (remainingKeys.length === 0) {
return nextObject;
}
}
}
return undefined;
}