diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 464ba6a0c2..2754309fbe 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -6,7 +6,6 @@ import { throttle } from 'lodash';
import api from 'flavours/glitch/api';
import { browserHistory } from 'flavours/glitch/components/router';
import { countableText } from 'flavours/glitch/features/compose/util/counter';
-import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'flavours/glitch/settings';
import { fetchCustomEmojiData } from '@/flavours/glitch/features/emoji/picker';
import { recoverHashtags } from 'flavours/glitch/utils/hashtag';
@@ -627,7 +626,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('@/flavours/glitch/features/emoji/emoji_mart_search_light');
+ const results = search(token.replace(':', ''), { maxResults: 5, custom });
dispatch(readyComposeSuggestionsEmojis(token, results));
};
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx b/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx
deleted file mode 100644
index 892d068b31..0000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { assetHost } from 'flavours/glitch/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 (
-
-

-
-
{emoji.colons}
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx b/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx
new file mode 100644
index 0000000000..8ab9ce1de2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx
@@ -0,0 +1,22 @@
+import type { FC } from 'react';
+
+import { useCustomEmojis } from '@/flavours/glitch/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 (
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.jsx b/app/javascript/flavours/glitch/components/autosuggest_input.jsx
index 9e342a353a..445ec601cf 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_input.jsx
+++ b/app/javascript/flavours/glitch/components/autosuggest_input.jsx
@@ -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) => {
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
index fae078da31..9b01d300a3 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
@@ -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) => {
diff --git a/app/javascript/flavours/glitch/components/emoji/index.tsx b/app/javascript/flavours/glitch/components/emoji/index.tsx
index b2e5e5fb66..3db6da7ed7 100644
--- a/app/javascript/flavours/glitch/components/emoji/index.tsx
+++ b/app/javascript/flavours/glitch/components/emoji/index.tsx
@@ -19,6 +19,7 @@ import {
stringToEmojiState,
tokenizeText,
} from '@/flavours/glitch/features/emoji/render';
+import type { ExtraCustomEmojiMap } from '@/flavours/glitch/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 = ({
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(() =>
diff --git a/app/javascript/flavours/glitch/entrypoints/public.tsx b/app/javascript/flavours/glitch/entrypoints/public.tsx
index 173c0075d5..33d791c815 100644
--- a/app/javascript/flavours/glitch/entrypoints/public.tsx
+++ b/app/javascript/flavours/glitch/entrypoints/public.tsx
@@ -13,12 +13,16 @@ import axios from 'axios';
import { on } from 'delegated-events';
import { throttle } from 'lodash';
+import { determineEmojiMode } from '@/flavours/glitch/features/emoji/mode';
+import { updateHtmlWithEmoji } from '@/flavours/glitch/features/emoji/render';
+import loadKeyboardExtensions from '@/flavours/glitch/load_keyboard_extensions';
+import { loadLocale, getLocale } from '@/flavours/glitch/locales';
+import { loadPolyfills } from '@/flavours/glitch/polyfills';
+import ready from '@/flavours/glitch/ready';
+import { assetHost } from '@/flavours/glitch/utils/config';
+import { getNestedProperty } from '@/flavours/glitch/utils/objects';
+import { isDarkMode } from '@/flavours/glitch/utils/theme';
import { formatTime } from '@/flavours/glitch/utils/time';
-import emojify from 'flavours/glitch/features/emoji/emoji';
-import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
-import { loadLocale, getLocale } from 'flavours/glitch/locales';
-import { loadPolyfills } from 'flavours/glitch/polyfills';
-import ready from 'flavours/glitch/ready';
import 'cocoon-js-vanilla';
@@ -37,7 +41,7 @@ const messages = defineMessages({
},
});
-function loaded() {
+async function loaded() {
const { messages: localeData } = getLocale();
const locale = document.documentElement.lang;
@@ -74,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('time.formatted')
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji.js b/app/javascript/flavours/glitch/features/emoji/emoji.js
deleted file mode 100644
index 09b3c89981..0000000000
--- a/app/javascript/flavours/glitch/features/emoji/emoji.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import Trie from 'substring-trie';
-
-import { getIsSystemTheme, isDarkMode } from '@/flavours/glitch/utils/theme';
-import { assetHost } from 'flavours/glitch/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;
diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_unicode_mapping_light.ts b/app/javascript/flavours/glitch/features/emoji/emoji_unicode_mapping_light.ts
deleted file mode 100644
index ecf36e3ea8..0000000000
--- a/app/javascript/flavours/glitch/features/emoji/emoji_unicode_mapping_light.ts
+++ /dev/null
@@ -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 };
diff --git a/app/javascript/flavours/glitch/features/emoji/mode.ts b/app/javascript/flavours/glitch/features/emoji/mode.ts
index 92e4749993..9cd45621cc 100644
--- a/app/javascript/flavours/glitch/features/emoji/mode.ts
+++ b/app/javascript/flavours/glitch/features/emoji/mode.ts
@@ -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
diff --git a/app/javascript/flavours/glitch/features/emoji/normalize.ts b/app/javascript/flavours/glitch/features/emoji/normalize.ts
index 0850a4bf8f..2816f6125c 100644
--- a/app/javascript/flavours/glitch/features/emoji/normalize.ts
+++ b/app/javascript/flavours/glitch/features/emoji/normalize.ts
@@ -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);
diff --git a/app/javascript/flavours/glitch/features/emoji/render.test.ts b/app/javascript/flavours/glitch/features/emoji/render.test.ts
index dffebd1f8c..a668f33d29 100644
--- a/app/javascript/flavours/glitch/features/emoji/render.test.ts
+++ b/app/javascript/flavours/glitch/features/emoji/render.test.ts
@@ -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',
diff --git a/app/javascript/flavours/glitch/features/emoji/render.ts b/app/javascript/flavours/glitch/features/emoji/render.ts
index 4b65f3abde..affa8e0f16 100644
--- a/app/javascript/flavours/glitch/features/emoji/render.ts
+++ b/app/javascript/flavours/glitch/features/emoji/render.ts
@@ -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 | 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) {
+ 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(
+ (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');
diff --git a/app/javascript/flavours/glitch/utils/objects.test.ts b/app/javascript/flavours/glitch/utils/objects.test.ts
new file mode 100644
index 0000000000..5c08cd0561
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/objects.test.ts
@@ -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();
+ });
+});
diff --git a/app/javascript/flavours/glitch/utils/objects.ts b/app/javascript/flavours/glitch/utils/objects.ts
new file mode 100644
index 0000000000..4430849c0c
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/objects.ts
@@ -0,0 +1,52 @@
+import { isPlainObject } from '@reduxjs/toolkit';
+
+export type RecordObject = Record;
+
+export function isRecordObject(obj: unknown): obj is RecordObject {
+ return isPlainObject(obj);
+}
+
+type NestedProperty = K extends readonly [
+ infer Head,
+ ...infer Tail,
+]
+ ? Head extends keyof NonNullable
+ ? Tail extends readonly PropertyKey[]
+ ? NestedProperty[Head], Tail>
+ : NonNullable[Head]
+ : undefined
+ : T;
+
+export function getNestedProperty<
+ TObject extends RecordObject,
+ const TKeys extends readonly PropertyKey[],
+>(object: TObject, ...keys: TKeys): NestedProperty | 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;
+}