Files
mastodon/app/javascript/flavours/glitch/features/emoji/mode.ts
Echo 8a8453e3b1 [Glitch] Emoji Rendering
Port 760d00b7f7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-07-23 19:05:04 +02:00

120 lines
3.9 KiB
TypeScript

// Credit to Nolan Lawson for the original implementation.
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
import { isDevelopment } from '@/flavours/glitch/utils/environment';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import type { EmojiMode } from './types';
type Feature = Uint8ClampedArray;
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js
const FONT_FAMILY =
'"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
'"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
function getTextFeature(text: string, color: string) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d', {
// Improves the performance of `getImageData()`
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
willReadFrequently: true,
});
if (!ctx) {
throw new Error('Canvas context not available');
}
ctx.textBaseline = 'top';
ctx.font = `100px ${FONT_FAMILY}`;
ctx.fillStyle = color;
ctx.scale(0.01, 0.01);
ctx.fillText(text, 0, 0);
return ctx.getImageData(0, 0, 1, 1).data satisfies Feature;
}
function compareFeatures(feature1: Feature, feature2: Feature) {
const feature1Str = [...feature1].join(',');
const feature2Str = [...feature2].join(',');
// This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
// Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
// 0,0,0,61 - there is a transparency here.
return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,');
}
function testEmojiSupport(text: string) {
// Render white and black and then compare them to each other and ensure they're the same
// color, and neither one is black. This shows that the emoji was rendered in color.
const feature1 = getTextFeature(text, '#000');
const feature2 = getTextFeature(text, '#fff');
return compareFeatures(feature1, feature2);
}
const EMOJI_VERSION_TEST_EMOJI = '🫨'; // shaking head, from v15
const EMOJI_FLAG_TEST_EMOJI = '🇨🇭';
export function determineEmojiMode(style: string): EmojiMode {
if (style === EMOJI_MODE_NATIVE) {
// If flags are not supported, we replace them with Twemoji.
if (shouldReplaceFlags()) {
return EMOJI_MODE_NATIVE_WITH_FLAGS;
}
return EMOJI_MODE_NATIVE;
}
if (style === EMOJI_MODE_TWEMOJI) {
return EMOJI_MODE_TWEMOJI;
}
// Auto style so determine based on browser capabilities.
if (shouldUseTwemoji()) {
return EMOJI_MODE_TWEMOJI;
} else if (shouldReplaceFlags()) {
return EMOJI_MODE_NATIVE_WITH_FLAGS;
}
return EMOJI_MODE_NATIVE;
}
export function shouldUseTwemoji(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
// Test a known color emoji to see if 15.1 is supported.
return !testEmojiSupport(EMOJI_VERSION_TEST_EMOJI);
} catch (err: unknown) {
// If an error occurs, fall back to Twemoji to be safe.
if (isDevelopment()) {
console.warn(
'Emoji rendering test failed, defaulting to Twemoji. Error:',
err,
);
}
return true;
}
}
// Based on https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L19
export function shouldReplaceFlags(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
// Test a known flag emoji to see if it is rendered in color.
return !testEmojiSupport(EMOJI_FLAG_TEST_EMOJI);
} catch (err: unknown) {
// If an error occurs, assume flags should be replaced.
if (isDevelopment()) {
console.warn(
'Flag emoji rendering test failed, defaulting to replacement. Error:',
err,
);
}
return true;
}
}