Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions ee/packages/federation-matrix/src/utils/emojiConverter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { shortnameToUnicode, unicodeToShortname } from './emojiConverter';

describe('shortnameToUnicode', () => {
it('converts joypixels-primary shortnames', () => {
expect(shortnameToUnicode(':slight_smile:')).toBe('🙂');
expect(shortnameToUnicode(':smiley:')).toBe('😃');
});

it('resolves contested shortcodes to the joypixels meaning', () => {
expect(shortnameToUnicode(':cat:')).toBe('🐱');
});

it('converts legacy emojione-only shortnames', () => {
expect(shortnameToUnicode(':digit_one:')).toBe('1️⃣');
});

it('converts skin-tone variants', () => {
expect(shortnameToUnicode(':thumbsup_tone3:')).toBe('👍🏽');
});

it('keeps unknown shortnames as text', () => {
expect(shortnameToUnicode(':not_a_real_emoji:')).toBe(':not_a_real_emoji:');
});
});

describe('unicodeToShortname', () => {
it('emits joypixels-primary shortnames valid in the app emoji list', () => {
expect(unicodeToShortname('😃')).toBe(':smiley:');
});

it('matches unqualified (FE0F-stripped) emoji', () => {
expect(unicodeToShortname('\u{1F44D}')).toBe(':thumbsup:');
expect(unicodeToShortname('❤')).toBe(':heart:');
});

it('matches fully-qualified emoji', () => {
expect(unicodeToShortname('\u{1F44D}️')).toBe(':thumbsup:');
});

it('returns unknown text unchanged', () => {
expect(unicodeToShortname('abc')).toBe('abc');
});
});
62 changes: 52 additions & 10 deletions ee/packages/federation-matrix/src/utils/emojiConverter.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,55 @@
import type { Emoji } from 'emojibase';
import data from 'emojibase-data/en/data.json';
import shortcodes from 'emojibase-data/en/shortcodes/emojibase.json';
import emojibaseShortcodes from 'emojibase-data/en/shortcodes/emojibase.json';
import joypixelsShortcodes from 'emojibase-data/en/shortcodes/joypixels.json';

// Mirrors apps/meteor/app/emoji-native/lib/legacyEmojioneMap.ts: shortcodes that existed in
// emojione but are absent from both presets, still stored in old reactions.
const legacyEmojioneMap: Record<string, string> = {
bride_with_veil_tone1: '👰🏻',
bride_with_veil_tone2: '👰🏼',
bride_with_veil_tone3: '👰🏽',
bride_with_veil_tone4: '👰🏾',
bride_with_veil_tone5: '👰🏿',
bride_with_veil: '👰',
digit_zero: '0️⃣',
digit_one: '1️⃣',
digit_two: '2️⃣',
digit_three: '3️⃣',
digit_four: '4️⃣',
digit_five: '5️⃣',
digit_six: '6️⃣',
digit_seven: '7️⃣',
digit_eight: '8️⃣',
digit_nine: '9️⃣',
pound_symbol: '#️⃣',
asterisk_symbol: '*️⃣',
};

let _unicodeToShort: Map<string, string> | null = null;
let _shortToUnicode: Map<string, string> | null = null;

function getShortcodes(hexcode: string): string[] {
const entry = (shortcodes as Record<string, string | string[]>)[hexcode];
// joypixels first, matching the app's emoji list (apps/meteor/app/emoji-native/lib/generateEmojiData.ts),
// so the shortnames we emit are valid there and the ones we receive resolve here
const entry =
(joypixelsShortcodes as Record<string, string | string[]>)[hexcode] ??
(emojibaseShortcodes as Record<string, string | string[]>)[hexcode];
if (!entry) return [];
return Array.isArray(entry) ? entry : [entry];
}

const VS16 = String.fromCodePoint(0xfe0f);

function registerUnicode(unicode: string, shortname: string) {
_unicodeToShort!.set(unicode, shortname);
// Matrix clients commonly send unqualified keys (no U+FE0F)
const bare = unicode.replaceAll(VS16, '');
if (bare !== unicode && !_unicodeToShort!.has(bare)) {
_unicodeToShort!.set(bare, shortname);
}
}

function buildMaps() {
_unicodeToShort = new Map();
_shortToUnicode = new Map();
Expand All @@ -19,26 +58,29 @@ function buildMaps() {
const codes = getShortcodes(emoji.hexcode);
if (codes.length === 0) continue;

const shortname = `:${codes[0]}:`;
_unicodeToShort.set(emoji.emoji, shortname);
_shortToUnicode.set(shortname, emoji.emoji);

for (const alt of codes.slice(1)) {
_shortToUnicode.set(`:${alt}:`, emoji.emoji);
registerUnicode(emoji.emoji, `:${codes[0]}:`);
for (const code of codes) {
_shortToUnicode.set(`:${code}:`, emoji.emoji);
}

if (emoji.skins) {
for (const skin of emoji.skins) {
const skinCodes = getShortcodes(skin.hexcode);
if (skinCodes.length > 0) {
_unicodeToShort.set(skin.emoji, `:${skinCodes[0]}:`);
registerUnicode(skin.emoji, `:${skinCodes[0]}:`);
for (const code of skinCodes) {
_shortToUnicode.set(`:${code}:`, skin.emoji);
}
}
}
}
}

for (const [code, unicode] of Object.entries(legacyEmojioneMap)) {
if (!_shortToUnicode.has(`:${code}:`)) {
_shortToUnicode.set(`:${code}:`, unicode);
}
}
}

function ensureMaps() {
Expand All @@ -49,7 +91,7 @@ function ensureMaps() {

export function unicodeToShortname(text: string): string {
ensureMaps();
return _unicodeToShort!.get(text) ?? text;
return _unicodeToShort!.get(text) ?? _unicodeToShort!.get(text.replaceAll(VS16, '')) ?? text;
}

export function shortnameToUnicode(text: string): string {
Expand Down
Loading