snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
196
apps/meteor/app/emoji-native/lib/generateEmojiData.spec.ts
Normal file
196
apps/meteor/app/emoji-native/lib/generateEmojiData.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Emoji } from 'emojibase';
|
||||
import data from 'emojibase-data/en/data.json';
|
||||
import emojibaseShortcodes from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||
import joypixelsShortcodes from 'emojibase-data/en/shortcodes/joypixels.json';
|
||||
|
||||
import type { EmojiEntry } from './generateEmojiData';
|
||||
import { getEmojiData } from './generateEmojiData';
|
||||
import { legacyEmojioneMap } from './legacyEmojioneMap';
|
||||
import { shortnameToUnicode } from './shortnameToUnicode';
|
||||
|
||||
const asArray = (entry: string | string[] | undefined): string[] => {
|
||||
if (!entry) return [];
|
||||
return Array.isArray(entry) ? entry : [entry];
|
||||
};
|
||||
|
||||
/**
|
||||
* The emoji we expose shortcodes for, derived independently from `generateEmojiData` so this suite
|
||||
* fails if the two ever drift apart.
|
||||
*/
|
||||
const emittedEmojis = (() => {
|
||||
const emojis = new Map<string, string>();
|
||||
|
||||
for (const emojiData of data as Emoji[]) {
|
||||
if (emojiData.group === 2) continue;
|
||||
|
||||
const { hexcode } = emojiData;
|
||||
const codepoint = hexcode.includes('-') ? NaN : parseInt(hexcode, 16);
|
||||
const isRegional = codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff;
|
||||
if (emojiData.group === undefined && !isRegional) continue;
|
||||
|
||||
emojis.set(hexcode, emojiData.emoji);
|
||||
for (const skin of emojiData.skins ?? []) {
|
||||
if (!skin.tone) continue;
|
||||
emojis.set(skin.hexcode, skin.emoji);
|
||||
}
|
||||
}
|
||||
|
||||
return emojis;
|
||||
})();
|
||||
|
||||
describe('native emoji shortcodes', () => {
|
||||
const { emojiList, emojisByCategory } = getEmojiData();
|
||||
|
||||
const unicodeOf = (shortcode: string) => emojiList[`:${shortcode}:`]?.unicode;
|
||||
|
||||
// Shortcodes both presets define, pointing at different emoji. Reaction keys and message
|
||||
// shortcodes stored before the native emoji migration — and everything the mobile clients
|
||||
// send today — use the joypixels/emojione meaning, so these must not follow emojibase.
|
||||
describe.each([
|
||||
['up', '🆙', 'arrow_up_small', '🔼'],
|
||||
['cat', '🐱', 'cat2', '🐈️'],
|
||||
['dog', '🐶', 'dog2', '🐕️'],
|
||||
['o', '⭕️', 'o2', '🅾️'],
|
||||
['point_up', '☝️', 'point_up_2', '👆️'],
|
||||
['sunglasses', '😎', 'dark_sunglasses', '🕶️'],
|
||||
['pencil', '📝', 'pencil2', '✏️'],
|
||||
['train', '🚋', 'train2', '🚆'],
|
||||
['whale', '🐳', 'whale2', '🐋'],
|
||||
['umbrella', '☔️', 'umbrella2', '☂️'],
|
||||
['computer', '💻️', 'desktop_computer', '🖥️'],
|
||||
['satellite', '📡', 'satellite_orbital', '🛰️'],
|
||||
['tiger', '🐯', 'tiger2', '🐅'],
|
||||
['no', '🇳🇴', 'thumbsdown', '👎️'],
|
||||
['om', '🇴🇲', 'om_symbol', '🕉️'],
|
||||
['sunny', '☀️', 'white_sun_small_cloud', '🌤️'],
|
||||
['japan', '🗾', 'flag_jp', '🇯🇵'],
|
||||
])('contested shortcode :%s:', (contested, expected, displacedShortcode, displaced) => {
|
||||
it(`resolves to ${expected}, not ${displaced}`, () => {
|
||||
expect(unicodeOf(contested)).toBe(expected);
|
||||
});
|
||||
|
||||
it(`leaves ${displaced} reachable as :${displacedShortcode}:`, () => {
|
||||
expect(unicodeOf(displacedShortcode)).toBe(displaced);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to emojibase for emoji joypixels never named', () => {
|
||||
// joypixels stopped shipping, so everything added to Unicode since then only has an
|
||||
// emojibase name — without the fallback these emoji disappear from the picker entirely
|
||||
expect(unicodeOf('fingerprint')).toBe('');
|
||||
expect(unicodeOf('face_with_eye_bags')).toBe('');
|
||||
expect(unicodeOf('leafless_tree')).toBe('');
|
||||
expect(unicodeOf('root_vegetable')).toBe('');
|
||||
});
|
||||
|
||||
it('only backfills legacy shortcodes the presets do not provide', () => {
|
||||
// the map is hand-generated; if a preset already names a shortcode, the entry is dead weight
|
||||
// and, worse, hides which name is authoritative
|
||||
const redundant = Object.keys(legacyEmojioneMap).filter((shortcode) => emojiList[`:${shortcode}:`]);
|
||||
|
||||
expect(redundant).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves the legacy shortcodes that survived', () => {
|
||||
expect(shortnameToUnicode(':bride_with_veil:')).toBe('👰');
|
||||
expect(shortnameToUnicode(':bride_with_veil_tone3:')).toBe('👰🏽');
|
||||
expect(shortnameToUnicode(':digit_zero:')).toBe('0️⃣');
|
||||
expect(shortnameToUnicode(':tone3:')).toBe('🏽');
|
||||
});
|
||||
|
||||
it('keeps joypixels aliases of contested emoji working', () => {
|
||||
expect(unicodeOf('point_up_2')).toBe('👆️');
|
||||
expect(unicodeOf('cat_face')).toBe('🐱');
|
||||
expect(unicodeOf('tram_car')).toBe('🚋');
|
||||
});
|
||||
|
||||
it('applies the same precedence to skin tone variants', () => {
|
||||
expect(unicodeOf('point_up_tone1')).toBe('☝🏻');
|
||||
expect(unicodeOf('point_up_2_tone1')).toBe('👆🏻');
|
||||
});
|
||||
|
||||
it('resolves contested shortcodes to unicode for shortnameToUnicode consumers', () => {
|
||||
expect(shortnameToUnicode(':up:')).toBe('🆙');
|
||||
expect(shortnameToUnicode(':cat:')).toBe('🐱');
|
||||
expect(shortnameToUnicode(':o:')).toBe('⭕️');
|
||||
});
|
||||
|
||||
it('gives joypixels precedence for every shortcode the two presets contest', () => {
|
||||
const contested: string[] = [];
|
||||
|
||||
for (const [hexcode, emoji] of emittedEmojis) {
|
||||
for (const shortcode of asArray((joypixelsShortcodes as Record<string, string | string[]>)[hexcode])) {
|
||||
const entry = emojiList[`:${shortcode}:`];
|
||||
if (entry && entry.unicode !== emoji) {
|
||||
contested.push(`:${shortcode}: is ${entry.unicode}, joypixels says ${emoji}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(contested).toEqual([]);
|
||||
});
|
||||
|
||||
it('never strands an emoji without a shortcode', () => {
|
||||
const stranded: string[] = [];
|
||||
const reachable = new Set(Object.values(emojiList).map((entry) => entry.unicode));
|
||||
|
||||
for (const [hexcode, emoji] of emittedEmojis) {
|
||||
const hadShortcode =
|
||||
asArray((emojibaseShortcodes as Record<string, string | string[]>)[hexcode]).length > 0 ||
|
||||
asArray((joypixelsShortcodes as Record<string, string | string[]>)[hexcode]).length > 0;
|
||||
|
||||
if (hadShortcode && !reachable.has(emoji)) {
|
||||
stranded.push(`${emoji} (${hexcode})`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(stranded).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps every picker entry resolvable', () => {
|
||||
const unresolvable = Object.values(emojisByCategory)
|
||||
.flat()
|
||||
.filter((shortcode) => !emojiList[`:${shortcode}:`]);
|
||||
|
||||
expect(unresolvable).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps every picker entry pointing at its own emoji', () => {
|
||||
// a picker entry whose name resolves to a *different* emoji would insert the wrong emoji
|
||||
const misdirected = Object.values(emojisByCategory)
|
||||
.flat()
|
||||
.filter((shortcode) => emojiList[`:${shortcode}:`]?.name !== shortcode);
|
||||
|
||||
expect(misdirected).toEqual([]);
|
||||
});
|
||||
|
||||
it('registers the primary shortcode before any of its aliases', () => {
|
||||
// picker search and unicodeToShortcodeMap both take the first registered name for an entry,
|
||||
// so an alias registered ahead of the primary surfaces the wrong name to users
|
||||
const firstSeen = new Map<EmojiEntry, string>();
|
||||
for (const [shortcode, entry] of Object.entries(emojiList)) {
|
||||
if (!firstSeen.has(entry)) firstSeen.set(entry, shortcode);
|
||||
}
|
||||
|
||||
const outOfOrder = Object.values(emojisByCategory)
|
||||
.flat()
|
||||
.filter((shortcode) => firstSeen.get(emojiList[`:${shortcode}:`]) !== `:${shortcode}:`);
|
||||
|
||||
expect(outOfOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it('never lists a shortname that resolves to another emoji', () => {
|
||||
// emoji picker search reads `shortnames`, so a stale alias surfaces the wrong result
|
||||
const stale: string[] = [];
|
||||
|
||||
for (const entry of Object.values(emojiList)) {
|
||||
for (const shortname of entry.shortnames) {
|
||||
if (emojiList[shortname] !== entry) {
|
||||
stale.push(`${entry.unicode} lists ${shortname}, which resolves to ${emojiList[shortname]?.unicode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
152
apps/meteor/app/emoji-native/lib/generateEmojiData.ts
Normal file
152
apps/meteor/app/emoji-native/lib/generateEmojiData.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import data from 'emojibase-data/en/data.json';
|
||||
import emojibaseShortcodes from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||
import joypixelsShortcodes from 'emojibase-data/en/shortcodes/joypixels.json';
|
||||
|
||||
// Map emojibase group numbers to our category keys
|
||||
const groupToCategory: Record<number, string> = {
|
||||
0: 'people', // smileys & emotion
|
||||
1: 'people', // people & body
|
||||
2: 'people', // component - skin tones etc (we skip these)
|
||||
3: 'nature', // animals & nature
|
||||
4: 'food', // food & drink
|
||||
5: 'travel', // travel & places
|
||||
6: 'activity', // activities
|
||||
7: 'objects', // objects
|
||||
8: 'symbols', // symbols
|
||||
9: 'flags', // flags
|
||||
};
|
||||
|
||||
export type EmojiEntry = {
|
||||
name: string;
|
||||
uc_base: string;
|
||||
uc_output: string;
|
||||
uc_match: string;
|
||||
uc_greedy: string;
|
||||
shortnames: string[];
|
||||
category: string;
|
||||
emojiPackage: string;
|
||||
unicode: string;
|
||||
};
|
||||
|
||||
function hexFromEmoji(emoji: string): string {
|
||||
return [...emoji].map((cp) => cp.codePointAt(0)!.toString(16)).join('-');
|
||||
}
|
||||
|
||||
function isRegionalIndicator(hexcode: string): boolean {
|
||||
if (hexcode.includes('-')) return false;
|
||||
const cp = parseInt(hexcode, 16);
|
||||
return cp >= 0x1f1e6 && cp <= 0x1f1ff;
|
||||
}
|
||||
|
||||
function getShortcodes(hexcode: string): string[] {
|
||||
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];
|
||||
}
|
||||
|
||||
function buildEmojiData() {
|
||||
const emojiList: Record<string, EmojiEntry> = {};
|
||||
const emojisByCategory: Record<string, string[]> = {
|
||||
people: [],
|
||||
nature: [],
|
||||
food: [],
|
||||
activity: [],
|
||||
travel: [],
|
||||
objects: [],
|
||||
symbols: [],
|
||||
flags: [],
|
||||
};
|
||||
const toneList: Record<string, number> = {};
|
||||
const bareAliases: [string, string][] = [];
|
||||
|
||||
for (const emojiData of data) {
|
||||
// Skip component group (skin tones, hair styles)
|
||||
if (emojiData.group === 2) continue;
|
||||
|
||||
const isRegional = isRegionalIndicator(emojiData.hexcode);
|
||||
const category = groupToCategory[emojiData.group ?? -1] ?? (isRegional ? 'flags' : undefined);
|
||||
if (!category) continue;
|
||||
|
||||
const codes = getShortcodes(emojiData.hexcode);
|
||||
if (codes.length === 0) continue;
|
||||
|
||||
const primaryShortcode = codes[0];
|
||||
const altShortcodes = codes.slice(1).map((s) => `:${s}:`);
|
||||
const hex = hexFromEmoji(emojiData.emoji);
|
||||
const bare = emojiData.emoji.replace(/\uFE0F/g, '');
|
||||
|
||||
const entry: EmojiEntry = {
|
||||
name: primaryShortcode,
|
||||
uc_base: hex,
|
||||
uc_output: hex,
|
||||
uc_match: hex,
|
||||
uc_greedy: hex,
|
||||
shortnames: altShortcodes,
|
||||
category,
|
||||
emojiPackage: 'native',
|
||||
unicode: emojiData.emoji,
|
||||
};
|
||||
|
||||
const key = `:${primaryShortcode}:`;
|
||||
emojiList[key] = entry;
|
||||
|
||||
if (emojiData.type === 1 && bare !== emojiData.emoji) {
|
||||
bareAliases.push([bare, key]);
|
||||
}
|
||||
|
||||
// Only add to category if it's NOT a skin tone variant
|
||||
if (!emojiData.tone && !isRegional) {
|
||||
emojisByCategory[category].push(primaryShortcode);
|
||||
}
|
||||
|
||||
// Register alternate shortcodes
|
||||
for (const alt of altShortcodes) {
|
||||
emojiList[alt] = entry;
|
||||
}
|
||||
|
||||
// Track skin tone support
|
||||
if (emojiData.skins && emojiData.skins.length > 0) {
|
||||
toneList[primaryShortcode] = 1;
|
||||
|
||||
// Register skin tone variants
|
||||
for (const skin of emojiData.skins) {
|
||||
if (!skin.tone) continue;
|
||||
|
||||
const tones = Array.isArray(skin.tone) ? skin.tone : [skin.tone];
|
||||
const skinHex = hexFromEmoji(skin.emoji);
|
||||
|
||||
const skinEntry: EmojiEntry = {
|
||||
name: primaryShortcode,
|
||||
uc_base: skinHex,
|
||||
uc_output: skinHex,
|
||||
uc_match: skinHex,
|
||||
uc_greedy: skinHex,
|
||||
shortnames: [],
|
||||
category,
|
||||
emojiPackage: 'native',
|
||||
unicode: skin.emoji,
|
||||
};
|
||||
|
||||
const toneShortcodes = getShortcodes(skin.hexcode);
|
||||
const fallbackShortcode = `${primaryShortcode}_tone${tones.join('-')}`;
|
||||
for (const code of [...toneShortcodes, fallbackShortcode]) {
|
||||
emojiList[`:${code}:`] = skinEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { emojiList, emojisByCategory, toneList, bareAliases };
|
||||
}
|
||||
|
||||
// Build once and cache
|
||||
let _cached: ReturnType<typeof buildEmojiData> | null = null;
|
||||
|
||||
export function getEmojiData() {
|
||||
if (!_cached) {
|
||||
_cached = buildEmojiData();
|
||||
}
|
||||
return _cached;
|
||||
}
|
||||
95
apps/meteor/app/emoji-native/lib/getEmojiConfig.spec.ts
Normal file
95
apps/meteor/app/emoji-native/lib/getEmojiConfig.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getEmojiConfig } from './getEmojiConfig';
|
||||
import type { EmojiPackages } from '../../emoji/lib/rocketchat';
|
||||
|
||||
const buildEmojiPackages = (ascii: boolean): EmojiPackages =>
|
||||
({
|
||||
packages: { native: { ascii } },
|
||||
list: {},
|
||||
}) as unknown as EmojiPackages;
|
||||
|
||||
describe('native emoji render', () => {
|
||||
it('converts ascii emoticons when the ascii flag is enabled', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(true));
|
||||
|
||||
expect(render('hello :)')).toBe('hello <span class="emoji" title=":)">🙂</span>');
|
||||
});
|
||||
|
||||
it('converts HTML-escaped ascii emoticons', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(true));
|
||||
|
||||
expect(render('hello >:(')).toBe('hello <span class="emoji" title=">:(">😠</span>');
|
||||
expect(render('hello <3')).toBe('hello <span class="emoji" title="<3">❤</span>');
|
||||
});
|
||||
|
||||
it('keeps ascii emoticons literal when the ascii flag is disabled', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(false));
|
||||
|
||||
expect(render('hello :)')).toBe('hello :)');
|
||||
});
|
||||
|
||||
it('converts ascii emoticons adjacent to HTML tags', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(true));
|
||||
|
||||
expect(render('<p>hello :D</p>')).toBe('<p>hello <span class="emoji" title=":D">😄</span></p>');
|
||||
expect(render('<p>:)</p>')).toBe('<p><span class="emoji" title=":)">🙂</span></p>');
|
||||
});
|
||||
|
||||
it('does not convert ascii sequences inside words or URLs', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(true));
|
||||
|
||||
expect(render('https://rocket.chat')).toBe('https://rocket.chat');
|
||||
expect(render('hello:)')).toBe('hello:)');
|
||||
});
|
||||
|
||||
it('does not convert ascii sequences inside HTML tags', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(true));
|
||||
|
||||
expect(render('<a href="https://rocket.chat">rocket</a>')).toBe('<a href="https://rocket.chat">rocket</a>');
|
||||
});
|
||||
|
||||
it('still renders shortcodes when the ascii flag is enabled', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(true));
|
||||
|
||||
expect(render(':smiley:')).toContain('<span class="emoji" title=":smiley:">');
|
||||
});
|
||||
|
||||
it('renders bare (non-VS16) emoji-default characters', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(false));
|
||||
|
||||
expect(render('🐕')).toBe('<span class="emoji" title=":dog2:">🐕</span>');
|
||||
expect(render('🐶')).toBe('<span class="emoji" title=":dog:">🐶</span>');
|
||||
expect(render('⭐')).toBe('<span class="emoji" title=":star:">⭐</span>');
|
||||
expect(render('👍')).toBe('<span class="emoji" title=":thumbsup:">👍</span>');
|
||||
});
|
||||
|
||||
it('resolves the VS16-qualified form to the same shortcode', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(false));
|
||||
|
||||
expect(render('🐕\u{FE0F}')).toBe('<span class="emoji" title=":dog2:">🐕\u{FE0F}</span>');
|
||||
expect(render('⭐\u{FE0F}')).toBe('<span class="emoji" title=":star:">⭐\u{FE0F}</span>');
|
||||
});
|
||||
|
||||
it('only treats text-default symbols as emoji when VS16 is present', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(false));
|
||||
|
||||
expect(render('©')).toBe('©');
|
||||
expect(render('™')).toBe('™');
|
||||
expect(render('©\u{FE0F}')).toBe('<span class="emoji" title=":copyright:">©\u{FE0F}</span>');
|
||||
});
|
||||
|
||||
it('renders shortcodes contested with emojibase using their emojione meaning', () => {
|
||||
const { render } = getEmojiConfig(buildEmojiPackages(false));
|
||||
|
||||
expect(render(':up:')).toBe('<span class="emoji" title=":up:">🆙</span>');
|
||||
expect(render(':arrow_up_small:')).toBe('<span class="emoji" title=":arrow_up_small:">🔼</span>');
|
||||
expect(render(':cat:')).toBe('<span class="emoji" title=":cat:">🐱</span>');
|
||||
expect(render(':cat2:')).toBe('<span class="emoji" title=":cat2:">🐈️</span>');
|
||||
});
|
||||
|
||||
it('renders picker entries for contested shortcodes', () => {
|
||||
const { renderPicker } = getEmojiConfig(buildEmojiPackages(false));
|
||||
|
||||
expect(renderPicker(':up:')).toBe('<span class="emoji" title=":up:">🆙</span>');
|
||||
expect(renderPicker(':cat2:')).toBe('<span class="emoji" title=":cat2:">🐈️</span>');
|
||||
});
|
||||
});
|
||||
147
apps/meteor/app/emoji-native/lib/getEmojiConfig.ts
Normal file
147
apps/meteor/app/emoji-native/lib/getEmojiConfig.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { asciiList } from '@rocket.chat/message-parser';
|
||||
import { escapeHTML, escapeRegExp, unescapeHTML } from '@rocket.chat/tools';
|
||||
|
||||
import type { EmojiEntry } from './generateEmojiData';
|
||||
import { getEmojiData } from './generateEmojiData';
|
||||
import { legacyEmojioneMap } from './legacyEmojioneMap';
|
||||
import { shortnameToUnicode } from './shortnameToUnicode';
|
||||
import type { EmojiPackages } from '../../emoji/lib/rocketchat';
|
||||
|
||||
const emojiCategories = [
|
||||
{ key: 'people', i18n: 'Smileys_and_People' },
|
||||
{ key: 'nature', i18n: 'Animals_and_Nature' },
|
||||
{ key: 'food', i18n: 'Food_and_Drink' },
|
||||
{ key: 'activity', i18n: 'Activity' },
|
||||
{ key: 'travel', i18n: 'Travel_and_Places' },
|
||||
{ key: 'objects', i18n: 'Objects' },
|
||||
{ key: 'symbols', i18n: 'Symbols' },
|
||||
{ key: 'flags', i18n: 'Flags' },
|
||||
];
|
||||
|
||||
// Build a reverse mapping from unicode emoji to shortcode for detection
|
||||
let unicodeToShortcodeMap: Map<string, string> | null = null;
|
||||
let emojiRegex: RegExp | null = null;
|
||||
|
||||
function getUnicodeToShortcodeMap(): Map<string, string> {
|
||||
if (!unicodeToShortcodeMap) {
|
||||
const { emojiList, bareAliases } = getEmojiData();
|
||||
unicodeToShortcodeMap = new Map();
|
||||
|
||||
for (const [shortcode, entry] of Object.entries(emojiList)) {
|
||||
const emojiEntry = entry;
|
||||
if (emojiEntry.unicode && !unicodeToShortcodeMap.has(emojiEntry.unicode)) {
|
||||
unicodeToShortcodeMap.set(emojiEntry.unicode, shortcode);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [bare, shortcode] of bareAliases) {
|
||||
if (!unicodeToShortcodeMap.has(bare)) {
|
||||
unicodeToShortcodeMap.set(bare, shortcode);
|
||||
}
|
||||
}
|
||||
}
|
||||
return unicodeToShortcodeMap;
|
||||
}
|
||||
|
||||
function getEmojiRegex(): RegExp {
|
||||
if (!emojiRegex) {
|
||||
const unicodeMap = getUnicodeToShortcodeMap();
|
||||
// Sort emojis by length (longest first) to match multi-codepoint emojis correctly
|
||||
const unicodeEmojis = [...unicodeMap.keys()].sort((a, b) => b.length - a.length);
|
||||
const unicodePattern = unicodeEmojis.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
||||
// Combine shortcode and unicode emoji patterns
|
||||
emojiRegex = new RegExp(`(:([a-zA-Z0-9_+-]+):)|(${unicodePattern})`, 'g');
|
||||
}
|
||||
return emojiRegex;
|
||||
}
|
||||
|
||||
// HTML-escaped variants are needed because the renderer receives escaped HTML (e.g. `>:(` arrives as `>:(`)
|
||||
const asciiPattern = [...new Set(Object.keys(asciiList).flatMap((ascii) => [escapeHTML(ascii), ascii]))]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(escapeRegExp)
|
||||
.join('|');
|
||||
|
||||
// `>` and `<` count as boundaries so emoticons touching tags still convert (e.g. `<p>hello :D</p>`)
|
||||
const asciiRegex = new RegExp(
|
||||
`<object[^>]*>.*?</object>|<span[^>]*>.*?</span>|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|(?<=^|\\s|>)(${asciiPattern})(?=\\s|$|[!,.?<])`,
|
||||
'g',
|
||||
);
|
||||
|
||||
function renderAsciiEmoji(text: string): string {
|
||||
return text.replace(asciiRegex, (entire, ascii) => {
|
||||
if (ascii === undefined) {
|
||||
return entire;
|
||||
}
|
||||
|
||||
const unescaped = unescapeHTML(ascii);
|
||||
const unicode = asciiList[unescaped];
|
||||
if (!unicode) {
|
||||
return entire;
|
||||
}
|
||||
|
||||
return `<span class="emoji" title="${escapeHTML(unescaped)}">${unicode}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderEmoji(text: string, emojiPackages: EmojiPackages): string {
|
||||
const { emojiList } = getEmojiData();
|
||||
const unicodeMap = getUnicodeToShortcodeMap();
|
||||
const pattern = getEmojiRegex();
|
||||
|
||||
const rendered = text.replace(pattern, (match, shortcodeGroup, shortcodeName, unicodeGroup) => {
|
||||
// If it's a shortcode pattern (:emoji:)
|
||||
if (shortcodeGroup) {
|
||||
if (emojiPackages.list[`:${shortcodeName}:`]?.emojiPackage === 'emojiCustom') {
|
||||
return match; // Don't render custom emojis as native
|
||||
}
|
||||
const key = `:${shortcodeName}:`;
|
||||
const emoji = emojiList[key] as EmojiEntry | undefined;
|
||||
if (emoji?.unicode) {
|
||||
return `<span class="emoji" title="${shortcodeGroup}">${emoji.unicode}</span>`;
|
||||
}
|
||||
|
||||
// Fallback to legacy emojione shortcodes for backward compatibility
|
||||
const legacy = legacyEmojioneMap[shortcodeName];
|
||||
if (legacy) {
|
||||
return `<span class="emoji" title="${shortcodeGroup}">${legacy}</span>`;
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
// If it's a unicode emoji character (unicodeGroup will be defined)
|
||||
if (unicodeGroup) {
|
||||
const shortcode = unicodeMap.get(unicodeGroup);
|
||||
if (shortcode) {
|
||||
return `<span class="emoji" title="${shortcode}">${unicodeGroup}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
return emojiPackages.packages.native?.ascii ? renderAsciiEmoji(rendered) : rendered;
|
||||
}
|
||||
|
||||
function renderPicker(emojiToRender: string): string | undefined {
|
||||
const { emojiList } = getEmojiData();
|
||||
const emoji = emojiList[emojiToRender] as EmojiEntry | undefined;
|
||||
if (!emoji?.unicode) return undefined;
|
||||
|
||||
return `<span class="emoji" title="${emojiToRender}">${emoji.unicode}</span>`;
|
||||
}
|
||||
|
||||
export const getEmojiConfig = (emojiPackages: EmojiPackages) => {
|
||||
const { emojiList, emojisByCategory, toneList } = getEmojiData();
|
||||
|
||||
return {
|
||||
emojiList,
|
||||
emojisByCategory,
|
||||
emojiCategories,
|
||||
toneList,
|
||||
render: (text: string) => renderEmoji(text, emojiPackages),
|
||||
renderPicker,
|
||||
sprites: false,
|
||||
shortnameToUnicode,
|
||||
};
|
||||
};
|
||||
34
apps/meteor/app/emoji-native/lib/legacyEmojioneMap.ts
Normal file
34
apps/meteor/app/emoji-native/lib/legacyEmojioneMap.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Auto-generated legacy emojione shortcode mapping
|
||||
// These shortcodes existed in emojione but are absent from the joypixels preset that
|
||||
// `generateEmojiData` reads, so they are backfilled here to keep old stored messages and
|
||||
// reactions resolving. `digit_*`, `pound_symbol` and `asterisk_symbol` were themselves
|
||||
// hand-patched into emojione by Rocket.Chat, and `tone1`-`tone5` name skin-tone components,
|
||||
// which are not emoji in their own right.
|
||||
|
||||
const legacyEmojioneMapData: 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: '*️⃣',
|
||||
tone1: '🏻',
|
||||
tone2: '🏼',
|
||||
tone3: '🏽',
|
||||
tone4: '🏾',
|
||||
tone5: '🏿',
|
||||
};
|
||||
|
||||
export const legacyEmojioneMap: Record<string, string> = Object.assign(Object.create(null), legacyEmojioneMapData);
|
||||
19
apps/meteor/app/emoji-native/lib/shortnameToUnicode.ts
Normal file
19
apps/meteor/app/emoji-native/lib/shortnameToUnicode.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { EmojiEntry } from './generateEmojiData';
|
||||
import { getEmojiData } from './generateEmojiData';
|
||||
import { legacyEmojioneMap } from './legacyEmojioneMap';
|
||||
|
||||
export function shortnameToUnicode(text: string): string {
|
||||
const { emojiList } = getEmojiData();
|
||||
|
||||
return text.replace(/:([a-zA-Z0-9_+-]+):/g, (match, shortcode) => {
|
||||
const key = `:${shortcode}:`;
|
||||
const emoji = emojiList[key] as EmojiEntry | undefined;
|
||||
if (emoji?.unicode) return emoji.unicode;
|
||||
|
||||
// Fallback to legacy emojione shortcodes for backward compatibility
|
||||
const legacy = legacyEmojioneMap[shortcode];
|
||||
if (legacy) return legacy;
|
||||
|
||||
return match;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user