Init new emoji data/api

This commit is contained in:
Jamie 2026-05-04 16:14:53 -07:00 committed by GitHub
parent 71fe87c611
commit 592e1b4476
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
200 changed files with 3718 additions and 3259 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ coverage/*
build/curve25519_compiled.js
build/compact-locales
build/*.policy
build/emoji-data.json
stylesheets/*.css.map
/dist
.DS_Store

View File

@ -231,8 +231,7 @@ const STD_PACKAGES = new Set([
'danger',
'debug',
'direction',
'emoji-datasource',
'emoji-regex',
'emoji-regex-xs',
'eslint',
'eslint-plugin-better-tailwindcss',
'filesize',

View File

@ -3,6 +3,7 @@
# Generated files
build/**/*.js
build/**/*.json
app/**/*.js
config/local-*.json
config/local.json
@ -30,6 +31,7 @@ packages/lame/lame-*/
danger/node_modules/**
sticker-creator/node_modules/**
components/**
scripts/emoji-datasource/emoji-datasource.json
# Assets
/images/

View File

@ -30,13 +30,13 @@ import { Environment, setEnvironment } from '../ts/environment.std.ts';
import { parseUnknown } from '../ts/util/schemas.std.ts';
import { LocaleEmojiListSchema } from '../ts/types/emoji.std.ts';
import { FunProvider } from '../ts/components/fun/FunProvider.dom.tsx';
import { EmojiSkinTone } from '../ts/components/fun/data/emojis.std.ts';
import { MOCK_GIFS_PAGINATED_ONE_PAGE } from '../ts/test-helpers/funPickerMocks.dom.tsx';
import { NavTab } from '../ts/types/Nav.std.ts';
import type { FunEmojiSelection } from '../ts/components/fun/panels/FunPanelEmojis.dom.tsx';
import type { FunGifSelection } from '../ts/components/fun/panels/FunPanelGifs.dom.tsx';
import type { FunStickerSelection } from '../ts/components/fun/panels/FunPanelStickers.dom.tsx';
import { Emoji } from '../ts/axo/emoji.std.ts';
setEnvironment(Environment.Development, true);
@ -258,7 +258,7 @@ function withFunProvider(Story, context) {
recentEmojis={[]}
recentStickers={[]}
recentGifs={[]}
emojiSkinToneDefault={EmojiSkinTone.None}
emojiSkinToneDefault={Emoji.SkinTone.None}
onEmojiSkinToneDefaultChange={noop}
installedStickerPacks={[]}
showStickerPickerHint={false}

View File

@ -5897,11 +5897,11 @@ Signal Desktop makes use of the following open source projects.
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## emoji-datasource
## emoji-regex-xs
The MIT License (MIT)
MIT License
Copyright (c) 2013 Cal Henderson
Copyright (c) 2025 Steven Levithan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -5921,29 +5921,6 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## emoji-regex
Copyright Mathias Bynens <https://mathiasbynens.be/>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## enhanced-resolve
Copyright JS Foundation and other contributors

View File

@ -12,6 +12,7 @@ import { SignalService as Proto } from '../ts/protobuf/index.std.ts';
import { parseUnknown } from '../ts/util/schemas.std.ts';
import { utf16ToEmoji } from '../ts/util/utf16ToEmoji.node.ts';
import { getAppRootDir } from '../ts/util/appRootDir.main.ts';
import { Emoji } from '../ts/axo/emoji.std.ts';
const MANIFEST_PATH = join(getAppRootDir(), 'build', 'jumbomoji.json');
@ -28,7 +29,7 @@ type SheetCacheEntry = Map<string, Uint8Array<ArrayBuffer>>;
export class EmojiService {
readonly #resourceService: OptionalResourceService;
readonly #emojiMap = new Map<string, EmojiEntryType>();
readonly #emojiMap = new Map<Emoji.Variant, EmojiEntryType>();
readonly #sheetCache = new LRUCache<string, SheetCacheEntry>({
// Each sheet is roughly 500kb
@ -44,16 +45,21 @@ export class EmojiService {
protocol.handle('emoji', async req => {
const url = new URL(req.url);
const emoji = url.searchParams.get('emoji');
if (!emoji) {
if (emoji == null || !Emoji.isEmoji(emoji)) {
return new Response('invalid', { status: 400 });
}
return this.#fetch(emoji);
return this.#fetch(Emoji.ignorePreferredSkinTone(emoji));
});
for (const [sheet, emojiList] of Object.entries(manifest)) {
for (const utf16 of emojiList) {
this.#emojiMap.set(utf16, { sheet, utf16 });
if (Emoji.isEmoji(utf16)) {
this.#emojiMap.set(Emoji.ignorePreferredSkinTone(utf16), {
sheet,
utf16,
});
}
}
}
}
@ -67,7 +73,7 @@ export class EmojiService {
return new EmojiService(resourceService, manifest);
}
async #fetch(emoji: string): Promise<Response> {
async #fetch(emoji: Emoji.Variant): Promise<Response> {
const entry = this.#emojiMap.get(emoji);
if (!entry) {
return new Response('entry not found', { status: 404 });

View File

@ -1,7 +1,7 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import getEmojiRegex from 'emoji-regex';
import getEmojiRegex from 'emoji-regex-xs';
import type {
MessageFormatElement,
TagElement,

View File

@ -19,7 +19,7 @@
"postuninstall": "pnpm run build:acknowledgments",
"start": "electron .",
"generate": "run-s generate:phase-0 generate:phase-1",
"generate:phase-0": "run-s build:protobuf build:rolldown:prod",
"generate:phase-0": "run-s build:protobuf build:emoji-data build:rolldown:prod",
"generate:phase-1": "run-p --aggregate-output --print-label build:icu-types build:compact-locales build:styles:prod get-expire-time build:policy-files",
"build-release": "pnpm run build",
"notarize": "echo 'No longer necessary'",
@ -77,7 +77,7 @@
"dev:styles:tailwind": "pnpm run build:styles:tailwind --watch",
"dev:icu-types": "chokidar ./_locales/en/messages.json --initial --command \"pnpm run build:icu-types\"",
"dev:protobuf": "chokidar ./protos/**/*.proto --command \"pnpm run build:protobuf\"",
"build:storybook": "pnpm run build:protobuf && cross-env SIGNAL_ENV=storybook storybook build",
"build:storybook": "pnpm run build:protobuf && pnpm run build:emoji-data && cross-env SIGNAL_ENV=storybook storybook build",
"test:storybook": "pnpm run build:storybook && run-p --race test:storybook:*",
"test:storybook:serve": "http-server storybook-static --port 6006 --silent",
"test:storybook:test": "wait-on http://127.0.0.1:6006/ --timeout 5000 && test-storybook --testTimeout 60000",
@ -86,6 +86,7 @@
"build-linux": "run-s build:policy-files generate build:rolldown:prod && pnpm run build:release --publish=never",
"build:acknowledgments": "node scripts/generate-acknowledgments.mjs",
"build:dns-fallback": "node scripts/generate-dns-fallback.mjs",
"build:emoji-data": "node scripts/generate-emoji-data.mjs",
"build:icu-types": "node scripts/generate-icu-types.mjs",
"build:compact-locales": "node scripts/generate-compact-locales.mjs",
"build:tray-icons": "node scripts/generate-tray-icons.mjs",
@ -121,7 +122,6 @@
"@signalapp/ringrtc": "2.68.1",
"@signalapp/sqlcipher": "3.2.1",
"@signalapp/windows-ucv": "1.0.1",
"emoji-datasource": "16.0.0",
"google-libphonenumber": "3.2.44"
},
"devDependencies": {
@ -237,7 +237,7 @@
"electron": "41.5.0",
"electron-builder": "26.0.14",
"electron-mocha": "13.1.0",
"emoji-regex": "10.6.0",
"emoji-regex-xs": "2.0.1",
"enhanced-resolve": "5.20.1",
"enquirer": "2.4.1",
"eslint": "10.1.0",
@ -630,6 +630,7 @@
"build/dns-fallback.json",
"build/optional-resources.json",
"build/jumbomoji.json",
"build/emoji-data.json",
"node_modules/**",
"!node_modules/.cache",
"!**/node_modules/**/{CHANGELOG.md,README.md,README,readme.md,readme,test,__tests__,tests,powered-test,example,examples,*.d.ts,*.d.ts.map,*.js.map,*.gypi,.snyk-*.flag,benchmark}",
@ -637,10 +638,6 @@
"!**/node_modules/.bin",
"!**/node_modules/**/build/**",
"!**/node_modules/**/prebuilds/**",
"!node_modules/emoji-datasource/emoji_pretty.json",
"!node_modules/emoji-datasource/img/**/*.png",
"node_modules/emoji-datasource/categories.json",
"node_modules/emoji-datasource/emoji.json",
"node_modules/@signalapp/sqlcipher/prebuilds/${platform}-${arch}/*.node",
"node_modules/@signalapp/libsignal-client/prebuilds/${platform}-${arch}/*.node",
"!node_modules/@signalapp/ringrtc/scripts/*",

21
pnpm-lock.yaml generated
View File

@ -87,9 +87,6 @@ importers:
'@signalapp/windows-ucv':
specifier: 1.0.1
version: 1.0.1
emoji-datasource:
specifier: 16.0.0
version: 16.0.0
google-libphonenumber:
specifier: 3.2.44
version: 3.2.44
@ -430,9 +427,9 @@ importers:
electron-mocha:
specifier: 13.1.0
version: 13.1.0
emoji-regex:
specifier: 10.6.0
version: 10.6.0
emoji-regex-xs:
specifier: 2.0.1
version: 2.0.1
enhanced-resolve:
specifier: 5.20.1
version: 5.20.1
@ -6156,11 +6153,9 @@ packages:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
emoji-datasource@16.0.0:
resolution: {integrity: sha512-/qHKqK5Nr3+8zhgO6kHmF43Fm5C8HNn0AaFRIpgw8HF3+uF0Vfc8jgLI1ZQS5ba1vBzksS8NBCjHejwLb2D/Sg==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex-xs@2.0.1:
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
engines: {node: '>=10.0.0'}
emoji-regex@7.0.3:
resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==}
@ -16400,9 +16395,7 @@ snapshots:
emittery@0.13.1: {}
emoji-datasource@16.0.0: {}
emoji-regex@10.6.0: {}
emoji-regex-xs@2.0.1: {}
emoji-regex@7.0.3: {}

View File

@ -24,7 +24,6 @@ const external = [
'sass',
// Large libraries (3.7mb total)
'emoji-datasource',
'google-libphonenumber',
// Imported, but not used in production builds

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,307 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
// Keep this data in sync with:
// https://raw.githubusercontent.com/greyson-signal/emoji-data/457ad4f7a09699ec940b149c8f4e76382bb0aadf/emoji.json
import SRC_EMOJIS from './emoji-datasource/emoji-datasource.json' with { type: 'json' };
import getEmojiRegex from 'emoji-regex-xs';
import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { gzip } from 'node:zlib';
import { promisify } from 'node:util';
import { assert } from './utils/assert.mjs';
const PRETTY = process.argv.includes('--pretty');
const gzipAsync = promisify(gzip);
const EMOJI_REGEX = getEmojiRegex();
const ROOT_DIR = join(import.meta.dirname, '..');
const EMOJI_DATA_PATH = join(ROOT_DIR, 'build', 'emoji-data.json');
/**
* @typedef {string & { Emoji: never }} Emoji
*
* @typedef {(
* | 'SMILIES_AND_PEOPLE'
* | 'ANIMALS_AND_NATURE'
* | 'FOOD_AND_DRINK'
* | 'TRAVEL_AND_PLACES'
* | 'ACTIVITIES'
* | 'OBJECTS'
* | 'SYMBOLS'
* | 'FLAGS'
* )} Category
*
* @typedef {(
* | '1F3FB'
* | '1F3FC'
* | '1F3FD'
* | '1F3FE'
* | '1F3FF'
* )} SkinTone
*
* @typedef {Record<SkinTone, Emoji>} SkinTones
*
* @typedef {{
* shortName: string;
* shortNameAlts?: Array<string>;
* emoticon?: string;
* emoticonAlts?: Array<string>;
* skinTones?: SkinTones;
* }} EmojiInfo
*
* @typedef {Record<Category, Array<Emoji>>} Categories
* @typedef {Record<Emoji, EmojiInfo>} Emojis
* @typedef {Record<Emoji, Emoji>} Parents
*
* @typedef {{ categories: Categories, emojis: Emojis, parents: Parents }} EmojiData
*/
/** @satisfies {Record<string, Category | null>} */
const CATEGORY_NAME_MAP = {
'Smileys & Emotion': 'SMILIES_AND_PEOPLE', // merged
'People & Body': 'SMILIES_AND_PEOPLE', // merged
Component: null, // dropped
'Animals & Nature': 'ANIMALS_AND_NATURE',
'Food & Drink': 'FOOD_AND_DRINK',
'Travel & Places': 'TRAVEL_AND_PLACES',
Activities: 'ACTIVITIES',
Objects: 'OBJECTS',
Symbols: 'SYMBOLS',
Flags: 'FLAGS',
};
/** @type {{ [K in SkinTone]: K }} */
const SKIN_TONES = {
'1F3FB': '1F3FB',
'1F3FC': '1F3FC',
'1F3FD': '1F3FD',
'1F3FE': '1F3FE',
'1F3FF': '1F3FF',
};
const KNOWN_MISSING_APPLE_IMG = new Set([
'FEMALE SIGN',
'MALE SIGN',
'MEDICAL SYMBOL',
]);
const DEPRECATED_EMOJI = new Set([
/**
* 2022 - Family Emoji Redesign: Gender Inclusive Variants
* https://www.unicode.org/L2/L2023/23029-family-emoji.pdf
* https://www.unicode.org/L2/L2022/22276-family-emoji-guidelines.pdf
*/
'FAMILY: MAN, BOY, BOY',
'FAMILY: MAN, BOY',
'FAMILY: MAN, GIRL, BOY',
'FAMILY: MAN, GIRL, GIRL',
'FAMILY: MAN, GIRL',
'FAMILY: MAN, MAN, BOY',
'FAMILY: MAN, MAN, BOY, BOY',
'FAMILY: MAN, MAN, GIRL',
'FAMILY: MAN, MAN, GIRL, BOY',
'FAMILY: MAN, MAN, GIRL, GIRL',
'FAMILY: MAN, WOMAN, BOY',
'FAMILY: MAN, WOMAN, BOY, BOY',
'FAMILY: MAN, WOMAN, GIRL',
'FAMILY: MAN, WOMAN, GIRL, BOY',
'FAMILY: MAN, WOMAN, GIRL, GIRL',
'FAMILY: WOMAN, BOY, BOY',
'FAMILY: WOMAN, BOY',
'FAMILY: WOMAN, GIRL, BOY',
'FAMILY: WOMAN, GIRL, GIRL',
'FAMILY: WOMAN, GIRL',
'FAMILY: WOMAN, WOMAN, BOY',
'FAMILY: WOMAN, WOMAN, BOY, BOY',
'FAMILY: WOMAN, WOMAN, GIRL',
'FAMILY: WOMAN, WOMAN, GIRL, BOY',
'FAMILY: WOMAN, WOMAN, GIRL, GIRL',
]);
/**
* @param {string} input
* @returns {input is Emoji}
*/
function isEmoji(input) {
const match = input.match(EMOJI_REGEX);
return match != null && match[0] === input;
}
/**
* @param {string} input
* @returns {input is keyof typeof CATEGORY_NAME_MAP}
*/
function isCategoryName(input) {
return Object.hasOwn(CATEGORY_NAME_MAP, input);
}
/**
* @param {Emoji} emoji
* @returns {boolean}
*/
function isDeprecated(emoji) {
return DEPRECATED_EMOJI.has(emoji);
}
/**
* @param {string} unified
* @returns {string}
*/
function encodeUnified(unified) {
return unified
.split('-')
.map(char => String.fromCodePoint(Number.parseInt(char, 16)))
.join('');
}
/**
* @param {Partial<SkinTones>} skinTones
* @returns {skinTones is SkinTones}
*/
function hasAllSkinTones(skinTones) {
return Object.values(SKIN_TONES).every(skinTone => {
return Object.hasOwn(skinTones, skinTone) && skinTones[skinTone] != null;
});
}
const SRC_EMOJIS_SORTED = SRC_EMOJIS.toSorted((a, b) => {
return a.sort_order - b.sort_order;
});
/** @type {Categories} */
const categories = {
SMILIES_AND_PEOPLE: [],
ANIMALS_AND_NATURE: [],
FOOD_AND_DRINK: [],
TRAVEL_AND_PLACES: [],
ACTIVITIES: [],
OBJECTS: [],
SYMBOLS: [],
FLAGS: [],
};
/** @type {Emojis} */
const emojis = {};
/** @type {Parents} */
const parents = {};
for (const emojiSrc of SRC_EMOJIS_SORTED) {
const emojiName = emojiSrc.name;
const categoryName = emojiSrc.category;
assert(isCategoryName(categoryName), `Unexpected category: ${categoryName}`);
const category = CATEGORY_NAME_MAP[categoryName];
if (category == null) {
continue; // drop components
}
assert(
emojiSrc.has_img_apple === !KNOWN_MISSING_APPLE_IMG.has(emojiName),
'Unexpected mismatch between has_img_apple and KNOWN_MISSING_APPLE_IMG'
);
if (!emojiSrc.has_img_apple) {
continue;
}
const emoji = encodeUnified(emojiSrc.unified);
assert(isEmoji(emoji), 'Unexpected invalid emoji');
if (isDeprecated(emoji)) {
continue; // drop deprecated emoji
}
/** @type {EmojiInfo} */
const info = {
shortName: emojiSrc.short_name,
};
const shortNameAlts = new Set(emojiSrc.short_names);
shortNameAlts.delete(emojiSrc.short_name);
if (shortNameAlts.size !== 0) {
info.shortNameAlts = Array.from(shortNameAlts);
}
if (emojiSrc.text != null) {
info.emoticon = emojiSrc.text;
}
const emoticonAlts = new Set(emojiSrc.texts);
if (emojiSrc.text != null) {
emoticonAlts.delete(emojiSrc.text);
}
if (emoticonAlts.size !== 0) {
info.emoticonAlts = Array.from(emoticonAlts);
}
if (emojiSrc.skin_variations != null) {
/** @type {Partial<Record<SkinTone, Emoji>>} */
const skinTones = {};
for (const skinTone of Object.values(SKIN_TONES)) {
const match =
emojiSrc.skin_variations[skinTone] ??
emojiSrc.skin_variations[`${skinTone}-${skinTone}`];
if (match == null) {
continue;
}
const variant = encodeUnified(match.unified);
assert(match.has_img_apple, 'Unexpected missing apple image');
assert(isEmoji(variant), 'Unexpected invalid variant');
assert(!isDeprecated(variant), 'Unexpected deprecated variant');
skinTones[skinTone] = variant;
}
assert(hasAllSkinTones(skinTones), 'Expected to have all skin tones');
info.skinTones = skinTones;
for (const skinTone of Object.values(SKIN_TONES)) {
const variant = info.skinTones[skinTone];
parents[variant] = emoji;
}
}
categories[category] ??= [];
categories[category].push(emoji);
emojis[emoji] = info;
}
/** @type {EmojiData} */
const emojiData = {
categories,
emojis,
parents,
};
const bytesFormat = new Intl.NumberFormat('en', {
style: 'unit',
unit: 'byte',
unitDisplay: 'narrow',
});
/**
* @param {string} name
* @param {unknown} json
*/
async function logGzipSize(name, json) {
const src = `${JSON.stringify(json)}\n`;
const srcBytes = bytesFormat.format(Buffer.byteLength(src, 'utf8'));
const gzipped = await gzipAsync(`${JSON.stringify(json)}\n`);
const gzipBytes = bytesFormat.format(gzipped.length);
console.log(`${name}: ${srcBytes} (${gzipBytes} gzip)`);
}
await logGzipSize('before', SRC_EMOJIS);
await logGzipSize('after', emojiData);
const json = PRETTY
? JSON.stringify(emojiData, null, 2)
: JSON.stringify(emojiData);
await writeFile(EMOJI_DATA_PATH, `${json}\n`);

813
ts/axo/emoji.std.ts Normal file
View File

@ -0,0 +1,813 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import getEmojiRegex from 'emoji-regex-xs';
import Fuse from 'fuse.js';
import type { FuseOptionKey, IFuseOptions } from 'fuse.js';
import LOOSE_EMOJI_DATA from '../../build/emoji-data.json' with { type: 'json' };
import { assert } from './_internal/assert.std.tsx';
function lazy<T>(fn: () => T): () => T {
let cached: T;
return () => {
cached ??= fn();
return cached;
};
}
/**
* Data
* ----------------------------------------------------------------------------
*/
type ParentInfo = Readonly<{
shortName: Emoji.ShortName;
shortNameAlts?: ReadonlyArray<Emoji.ShortName>;
emoticon?: string;
emoticonAlts?: ReadonlyArray<string>;
skinTones?: Readonly<Record<Emoji.SkinTone, Emoji.SkinToneVariant>>;
}>;
type EmojiData = Readonly<{
categories: Record<Emoji.Category, Array<Emoji.Parent>>;
emojis: Readonly<Record<Emoji.Parent, ParentInfo>>;
parents: Readonly<Record<Emoji.SkinToneVariant, Emoji.Parent>>;
}>;
const EMOJI_DATA: EmojiData = {
// TypeScript only complains about this one for some reason
categories: LOOSE_EMOJI_DATA.categories as EmojiData['categories'],
emojis: LOOSE_EMOJI_DATA.emojis,
parents: LOOSE_EMOJI_DATA.parents,
};
function getParentInfo(parent: Emoji.Parent): ParentInfo {
return assert(EMOJI_DATA.emojis[parent], `Missing "${parent}"`);
}
/** @internal */
function PARENT_AND_ONLY_VARIANT<const T extends string>(
input: T
): T & Emoji.Parent & Emoji.Variant {
assert(Emoji.isEmoji(input));
assert(Emoji.isParent(input));
const info = getParentInfo(input);
assert(info.skinTones == null);
return input as T & Emoji.Parent & Emoji.Variant;
}
/** @internal */
function PARENT(input: string): Emoji.Parent {
assert(Emoji.isEmoji(input));
assert(Emoji.isParent(input));
const info = getParentInfo(input);
assert(info.skinTones != null);
return input;
}
/**
* String Normalization
* ----------------------------------------------------------------------------
*/
const EMOJI_COMPLETION_REGEX = /(?<!^)[\s,_-]+/gi;
/** @testexport For displaying in the ui */
export function _toEmojiCompletionLabel(
shortName: Emoji.ShortName
): Emoji.CompletionLabel {
return shortName
.normalize()
.replaceAll(EMOJI_COMPLETION_REGEX, '_')
.toLowerCase() as Emoji.CompletionLabel;
}
/** @testexport For matching in search utils */
export function _toEmojiCompletionQuery(
shortName: Emoji.ShortName
): Emoji.CompletionQuery {
return shortName
.normalize('NFD')
.replaceAll(/\p{Diacritic}/gu, '')
.replaceAll(EMOJI_COMPLETION_REGEX, ' ')
.toLowerCase() as Emoji.CompletionQuery;
}
/**
* Search
* ----------------------------------------------------------------------------
*/
type SearchIndexEntry = Readonly<{
parent: Emoji.Parent;
rank: number;
completion: Emoji.CompletionQuery;
completionAlts: ReadonlyArray<Emoji.CompletionQuery>;
emoticon: string | null;
emoticonAlts: ReadonlyArray<string>;
}>;
const ALL_PARENTS_WITH_DEFAULT_RANK = lazy(() => {
const parents = new Map<Emoji.Parent, number>();
for (const category of Object.values(EMOJI_DATA.categories)) {
for (const parent of category) {
parents.set(parent, parents.size + 1);
}
}
return parents;
});
type EmojiSearchFuseKey = FuseOptionKey<SearchIndexEntry> & {
name: keyof SearchIndexEntry;
};
const EmojiSearchFuseKeys: Array<EmojiSearchFuseKey> = [
{ name: 'completion', weight: 100 },
{ name: 'completionAlts', weight: 1 },
{ name: 'emoticon', weight: 50 },
{ name: 'emoticonAlts', weight: 1 },
];
const EmojiSearchFuseFuzzyOptions: IFuseOptions<SearchIndexEntry> = {
shouldSort: false,
threshold: 0.2,
minMatchCharLength: 1,
keys: EmojiSearchFuseKeys,
includeScore: true,
includeMatches: true,
};
const EmojiSearchFuseExactOptions: IFuseOptions<SearchIndexEntry> = {
shouldSort: false,
threshold: 0,
minMatchCharLength: 1,
keys: EmojiSearchFuseKeys,
includeScore: true,
includeMatches: true,
};
/** @internal */
type EmojiSearchFuses = Readonly<{
size: number;
getExact: () => Fuse<SearchIndexEntry>;
getFuzzy: () => Fuse<SearchIndexEntry>;
}>;
/** @internal */
function createEmojiSearchFuses(
searchIndex: ReadonlyArray<SearchIndexEntry>
): EmojiSearchFuses {
const size = searchIndex.length;
const INDEX = lazy(() => Fuse.createIndex(EmojiSearchFuseKeys, searchIndex));
return {
size,
getExact: lazy(
() => new Fuse(searchIndex, EmojiSearchFuseExactOptions, INDEX())
),
getFuzzy: lazy(
() => new Fuse(searchIndex, EmojiSearchFuseFuzzyOptions, INDEX())
),
};
}
/**
* Localizer
* ----------------------------------------------------------------------------
*/
/** @internal */
type EmojiLocalizer = Readonly<{
getShortName: (parent: Emoji.Parent) => Emoji.ShortName;
matchCompletion: (completion: Emoji.CompletionQuery) => Emoji.Parent | null;
getSearchFuses: () => EmojiSearchFuses;
}>;
/** @internal */
const DEFAULT_ENGLISH_INDEXES = lazy(() => {
const completionToParent = new Map<Emoji.CompletionQuery, Emoji.Parent>();
const searchIndex: Array<SearchIndexEntry> = [];
for (const [parent, rank] of ALL_PARENTS_WITH_DEFAULT_RANK()) {
const info = getParentInfo(parent);
const shortName = info.shortName;
const shortNameAlts = info.shortNameAlts ?? [];
const completion = _toEmojiCompletionQuery(shortName);
const completionAlts = shortNameAlts.map(shortNameAlt => {
return _toEmojiCompletionQuery(shortNameAlt);
});
completionToParent.set(completion, parent);
searchIndex.push({
parent,
rank,
completion,
completionAlts,
emoticon: info.emoticon ?? null,
emoticonAlts: info.emoticonAlts ?? [],
});
}
return { completionToParent, searchIndex };
});
/** @internal */
const DEFAULT_ENGLISH_LOCALIZER: EmojiLocalizer = {
getShortName(value) {
return getParentInfo(value).shortName;
},
matchCompletion(completion) {
return DEFAULT_ENGLISH_INDEXES().completionToParent.get(completion) ?? null;
},
getSearchFuses() {
return createEmojiSearchFuses(DEFAULT_ENGLISH_INDEXES().searchIndex);
},
};
/** @internal */
let CURRENT_EMOJI_LOCALIZER: EmojiLocalizer | null = null;
/** @internal */
function getLocalizer() {
return CURRENT_EMOJI_LOCALIZER ?? DEFAULT_ENGLISH_LOCALIZER;
}
/** @testexport */
export function _hasEmojiLocalizer(): boolean {
return CURRENT_EMOJI_LOCALIZER != null;
}
/** @testexport */
export function _resetEmojiLocalizer(): void {
CURRENT_EMOJI_LOCALIZER = null;
}
/**
* API
* ----------------------------------------------------------------------------
*/
export type Emoji = string & { Emoji: never };
export namespace Emoji {
export type Parent = Emoji & { EmojiParent: never };
export type Variant = Emoji & { EmojiVariant: never };
export type SkinToneVariant = Variant & { EmojiSkinToneVariant: never };
export type ShortName = string & { EmojiShortName: never };
export type CompletionLabel = string & { EmojiCompletionLabel: never };
export type CompletionQuery = string & { EmojiCompletionQuery: never };
export enum SkinTone {
None = '',
Type1 = '1F3FB',
Type2 = '1F3FC',
Type3 = '1F3FD',
Type4 = '1F3FE',
Type5 = '1F3FF',
}
export enum Category {
SMILIES_AND_PEOPLE = 'SMILIES_AND_PEOPLE',
ANIMALS_AND_NATURE = 'ANIMALS_AND_NATURE',
FOOD_AND_DRINK = 'FOOD_AND_DRINK',
TRAVEL_AND_PLACES = 'TRAVEL_AND_PLACES',
ACTIVITIES = 'ACTIVITIES',
OBJECTS = 'OBJECTS',
SYMBOLS = 'SYMBOLS',
FLAGS = 'FLAGS',
}
export function getDebugLabel(input: string): string {
return Array.from(input.slice(0, 12), char => {
const num = char.codePointAt(0) ?? 0;
const hex = num.toString(16).toUpperCase().padStart(4, '0');
return `U+${hex}`;
}).join(' ');
}
export function getDisplayLabel(emoji: Emoji): ShortName {
return getLocalizer().getShortName(getParent(emoji));
}
export function getCompletionLabel(emoji: Emoji): CompletionLabel {
return _toEmojiCompletionLabel(getDisplayLabel(emoji));
}
export function isEmoji(input: string): input is Emoji {
return isParent(input) || isSkinToneVariant(input);
}
export function isParent(emoji: string): emoji is Parent {
return Object.hasOwn(EMOJI_DATA.emojis, emoji);
}
export function isSkinToneVariant(emoji: string): emoji is SkinToneVariant {
return Object.hasOwn(EMOJI_DATA.parents, emoji);
}
export function matchShortName(shortName: string): Parent | null {
const query = _toEmojiCompletionQuery(shortName as ShortName);
return getLocalizer().matchCompletion(query);
}
export function getCategoryParents(
category: Category
): ReadonlyArray<Parent> {
return EMOJI_DATA.categories[category];
}
export function getParent(emoji: Emoji): Parent {
if (isSkinToneVariant(emoji)) {
return assert(EMOJI_DATA.parents[emoji], `Missing parent for "${emoji}"`);
}
assert(isParent(emoji), `Expected "${emoji}" to be a parent`);
return emoji;
}
export function hasSkinToneVariants(emoji: Parent): boolean {
return getParentInfo(emoji).skinTones != null;
}
export function getDefaultVariant(parent: Parent): Variant {
return getVariant(parent, SkinTone.None);
}
export function getVariant(parent: Parent, skinTone: SkinTone): Variant {
if (skinTone === SkinTone.None) {
return parent as Emoji as Variant;
}
const { skinTones } = getParentInfo(parent);
if (skinTones == null) {
return parent as Emoji as Variant;
}
return assert(skinTones[skinTone]);
}
export function unsafeCastMaybeInvalidStringToVariant<T extends string>(
emoji: T extends Emoji ? never : T
): Variant {
return emoji as string as Variant;
}
export function ignorePreferredSkinTone(emoji: Emoji): Variant {
return emoji as Variant;
}
export const BAR_CHART = PARENT_AND_ONLY_VARIANT('📊');
export const BEE = PARENT_AND_ONLY_VARIANT('🐝');
export const BELL = PARENT_AND_ONLY_VARIANT('🔔');
export const BIKE = PARENT_AND_ONLY_VARIANT('🚲');
export const BLACK_CIRCLE = PARENT_AND_ONLY_VARIANT('⚫');
export const BLUE_HEART = PARENT_AND_ONLY_VARIANT('💙');
export const BLUSH = PARENT_AND_ONLY_VARIANT('😊');
export const BULB = PARENT_AND_ONLY_VARIANT('💡');
export const BUST_IN_SILHOUETTE = PARENT_AND_ONLY_VARIANT('👤');
export const CAKE = PARENT_AND_ONLY_VARIANT('🍰');
export const CALENDAR = PARENT_AND_ONLY_VARIANT('📆');
export const CALL_ME_HAND = PARENT('🤙');
export const CAMERA = PARENT_AND_ONLY_VARIANT('📷');
export const CAR = PARENT_AND_ONLY_VARIANT('🚗');
export const CAT = PARENT_AND_ONLY_VARIANT('🐱');
export const CHECKMARK = PARENT_AND_ONLY_VARIANT('✅');
export const CLINKING_GLASSES = PARENT_AND_ONLY_VARIANT('🥂');
export const COFFEE = PARENT_AND_ONLY_VARIANT('☕');
export const CONFETTI_BALL = PARENT_AND_ONLY_VARIANT('🎊');
export const CONFUSED = PARENT_AND_ONLY_VARIANT('😕');
export const COOL = PARENT_AND_ONLY_VARIANT('🆒');
export const CREDIT_CARD = PARENT_AND_ONLY_VARIANT('💳');
export const CRY = PARENT_AND_ONLY_VARIANT('😢');
export const DOG = PARENT_AND_ONLY_VARIANT('🐶');
export const DOTTED_LINE_FACE = PARENT_AND_ONLY_VARIANT('🫥');
export const ELEPHANT = PARENT_AND_ONLY_VARIANT('🐘');
export const EXPLODING_HEAD = PARENT_AND_ONLY_VARIANT('🤯');
export const FACE_WITH_SPIRAL_EYES = PARENT_AND_ONLY_VARIANT('😵‍💫');
export const FERRIS_WHEEL = PARENT_AND_ONLY_VARIANT('🎡');
export const FIRE = PARENT_AND_ONLY_VARIANT('🔥');
export const FIREWORK_SPARKLER = PARENT_AND_ONLY_VARIANT('🎇');
export const FOLDED_HANDS = PARENT('🙏');
export const FRIED_SHRIMP = PARENT_AND_ONLY_VARIANT('🍤');
export const GHOST = PARENT_AND_ONLY_VARIANT('👻');
export const GREEN_CIRCLE = PARENT_AND_ONLY_VARIANT('🟢');
export const GRIMACING = PARENT_AND_ONLY_VARIANT('😬');
export const GRINNING = PARENT_AND_ONLY_VARIANT('😀');
export const HAND = PARENT('✋');
export const HEART = PARENT_AND_ONLY_VARIANT('❤️');
export const HEART_ON_FIRE = PARENT_AND_ONLY_VARIANT('❤️‍🔥');
export const HOUSE = PARENT_AND_ONLY_VARIANT('🏠');
export const JOY = PARENT_AND_ONLY_VARIANT('😂');
export const KISSING_HEART = PARENT_AND_ONLY_VARIANT('😘');
export const LIPSTICK = PARENT_AND_ONLY_VARIANT('💄');
export const MICROPHONE = PARENT_AND_ONLY_VARIANT('🎤');
export const MOBILE_PHONE_OFF = PARENT_AND_ONLY_VARIANT('📴');
export const MONKEY = PARENT_AND_ONLY_VARIANT('🐒');
export const MOVIE_CAMERA = PARENT_AND_ONLY_VARIANT('🎥');
export const MUSCLE = PARENT('💪');
export const NAIL_CARE = PARENT('💅');
export const NEUTRAL_FACE = PARENT_AND_ONLY_VARIANT('😐');
export const NO_BELL = PARENT_AND_ONLY_VARIANT('🔕');
export const OK_HAND = PARENT('👌');
export const ONE = PARENT_AND_ONLY_VARIANT('1⃣');
export const ONE_HUNDRED = PARENT_AND_ONLY_VARIANT('💯');
export const OPEN_MOUTH = PARENT_AND_ONLY_VARIANT('😮');
export const PAPERCLIP = PARENT_AND_ONLY_VARIANT('📎');
export const PARKING = PARENT_AND_ONLY_VARIANT('🅿️');
export const PLUS = PARENT_AND_ONLY_VARIANT('');
export const POULTRY_LEG = PARENT_AND_ONLY_VARIANT('🍗');
export const PUSHPIN = PARENT_AND_ONLY_VARIANT('📌');
export const RAGE = PARENT_AND_ONLY_VARIANT('😡');
export const REPEAT = PARENT_AND_ONLY_VARIANT('🔁');
export const ROCKET = PARENT_AND_ONLY_VARIANT('🚀');
export const ROSE = PARENT_AND_ONLY_VARIANT('🌹');
export const SHARK = PARENT_AND_ONLY_VARIANT('🦈');
export const SHRUG = PARENT('🤷');
export const SHUSHING_FACE = PARENT_AND_ONLY_VARIANT('🤫');
export const SKULL = PARENT_AND_ONLY_VARIANT('💀');
export const SLEEPING = PARENT_AND_ONLY_VARIANT('😴');
export const SLIGHTLY_FROWNING_FACE = PARENT_AND_ONLY_VARIANT('🙁');
export const SLIGHTLY_SMILING_FACE = PARENT_AND_ONLY_VARIANT('🙂');
export const SMILE = PARENT_AND_ONLY_VARIANT('😄');
export const SMILE_CAT = PARENT_AND_ONLY_VARIANT('😸');
export const SOB = PARENT_AND_ONLY_VARIANT('😭');
export const SPARKLE = PARENT_AND_ONLY_VARIANT('❇️');
export const SPARKLES = PARENT_AND_ONLY_VARIANT('✨');
export const SPARKLING_HEART = PARENT_AND_ONLY_VARIANT('💖');
export const SPEAKER = PARENT_AND_ONLY_VARIANT('🔈');
export const SPEECH_BALLOON = PARENT_AND_ONLY_VARIANT('💬');
export const STAR = PARENT_AND_ONLY_VARIANT('⭐');
export const STUCK_OUT_TONGUE = PARENT_AND_ONLY_VARIANT('😛');
export const SUNGLASSES = PARENT_AND_ONLY_VARIANT('😎');
export const SUNNY = PARENT_AND_ONLY_VARIANT('☀️');
export const SWEAT_SMILE = PARENT_AND_ONLY_VARIANT('😅');
export const TADA = PARENT_AND_ONLY_VARIANT('🎉');
export const THE_HORNS = PARENT('🤘');
export const THREE = PARENT_AND_ONLY_VARIANT('3⃣');
export const THUMBS_DOWN = PARENT('👎');
export const THUMBS_UP = PARENT('👍');
export const TOPHAT = PARENT_AND_ONLY_VARIANT('🎩');
export const TURTLE = PARENT_AND_ONLY_VARIANT('🐢');
export const TWO = PARENT_AND_ONLY_VARIANT('2⃣');
export const WARNING = PARENT_AND_ONLY_VARIANT('⚠️');
export const WAVE = PARENT('👋');
export const WEIGHT_LIFTER = PARENT('🏋️');
export const WHITE_HEART = PARENT_AND_ONLY_VARIANT('🤍');
export const WILTED_FLOWER = PARENT_AND_ONLY_VARIANT('🥀');
export const WINK = PARENT_AND_ONLY_VARIANT('😉');
export const ZIPPER_MOUTH_FACE = PARENT_AND_ONLY_VARIANT('🤐');
export const ZZZ = PARENT_AND_ONLY_VARIANT('💤');
const DEFAULT_PREFERRED_REACTION_EMOJI: Array<Parent> = [
HEART,
THUMBS_UP,
THUMBS_DOWN,
JOY,
OPEN_MOUTH,
CRY,
];
export function getDefaultPreferredReactionEmojis(
skinTone: SkinTone
): ReadonlyArray<Variant> {
return DEFAULT_PREFERRED_REACTION_EMOJI.map(emoji => {
return getVariant(emoji, skinTone);
});
}
export const CATEGORY_ORDER: ReadonlyArray<Category> = [
Category.SMILIES_AND_PEOPLE,
Category.ANIMALS_AND_NATURE,
Category.FOOD_AND_DRINK,
Category.TRAVEL_AND_PLACES,
Category.ACTIVITIES,
Category.OBJECTS,
Category.SYMBOLS,
Category.FLAGS,
];
export const SKIN_TONE_ORDER: ReadonlyArray<SkinTone> = [
SkinTone.None,
SkinTone.Type1,
SkinTone.Type2,
SkinTone.Type3,
SkinTone.Type4,
SkinTone.Type5,
];
export function* iterateAllVariants(): Generator<Variant, void, void> {
for (const category of Object.values(EMOJI_DATA.categories)) {
for (const parent of category) {
yield getDefaultVariant(parent);
const info = getParentInfo(parent);
if (info.skinTones != null) {
for (const skinTone of SKIN_TONE_ORDER) {
if (skinTone === SkinTone.None) {
continue;
}
yield info.skinTones[skinTone];
}
}
}
}
}
/** @internal */
function isEmptyString(input: string): boolean {
return input === '' || input.trim() === '';
}
/**
* Emoji.replaceEmojiWithSpaces()
* ------------------------------
*/
const SPACES_CACHE: Record<number, string> = {};
const MAX_EMOJI_UTF16_LENGTH = 15;
for (let i = 0; i < MAX_EMOJI_UTF16_LENGTH; i += 1) {
SPACES_CACHE[i] = ' '.repeat(i);
}
export function replaceEmojiWithSpaces(input: string): string {
if (isEmptyString(input)) {
return input; // fast path
}
const emojiRegex = getEmojiRegex();
return input.replaceAll(emojiRegex, match => {
const length = match.length;
return SPACES_CACHE[length] ?? ' '.repeat(length);
});
}
/**
* Emoji.stripEmojiFromText()
* --------------------------
*/
export function stripEmojiFromText(input: string): string {
if (isEmptyString(input)) {
return input; // fast path
}
const emojiRegex = getEmojiRegex();
return input.replaceAll(emojiRegex, '');
}
/**
* Emoji.getMatches()
* ------------------
*/
export type Match = Readonly<{
emoji: Variant;
offset: number;
}>;
const MAX_EMOJI_TO_MATCH = 5000;
export function* getMatches(input: string): Generator<Match, void, void> {
if (isEmptyString(input)) {
return; // fast path
}
const emojiRegex = getEmojiRegex();
let count = 0;
for (const match of input.matchAll(emojiRegex)) {
const emoji = match[0];
if (isEmoji(emoji)) {
yield {
emoji: ignorePreferredSkinTone(emoji),
offset: match.index,
};
}
count += 1;
if (count >= MAX_EMOJI_TO_MATCH) {
break;
}
}
}
/**
* Emoji.getSegments()
* -------------------
*/
export type TextSegment = Readonly<{
kind: 'text';
value: string;
offset: number;
}>;
export type EmojiSegment = Readonly<{
kind: 'emoji';
value: Variant;
offset: number;
}>;
export type Segment = TextSegment | EmojiSegment;
export function* getSegments(input: string): Generator<Segment, void, void> {
if (isEmptyString(input)) {
yield { kind: 'text', value: input, offset: 0 }; // fast path
return;
}
let cursor = 0;
// oxlint-disable-next-line typescript/no-unnecessary-qualifier allow this call to be spied on
for (const match of Emoji.getMatches(input)) {
const { offset, emoji } = match;
if (offset > cursor) {
const value = input.slice(cursor, offset);
yield { kind: 'text', value, offset: cursor };
}
yield { kind: 'emoji', value: emoji, offset };
cursor = offset + emoji.length;
}
if (cursor < input.length) {
yield { kind: 'text', value: input.slice(cursor), offset: cursor };
}
}
/**
* getEmojiOnlyCount()
* -------------------
*/
/**
* Count the number of emoji in some text, returns 0 if there is non-emoji
* text, or there is too many emoji
* @internal
*/
function getEmojiOnlyCount(input: string, limit: number): number {
if (isEmptyString(input)) {
return 0; // fast path
}
let count = 0;
// oxlint-disable-next-line typescript/no-unnecessary-qualifier allow this call to be spied on
for (const segment of Emoji.getSegments(input)) {
if (segment.kind === 'text') {
return 0; // found other text
}
if (segment.kind === 'emoji') {
count += 1;
}
if (count > limit) {
return 0; // too many
}
}
return count;
}
/**
* Emoji.isLoneEmoji()
* -------------------
*/
export function isLoneEmoji(input: string): boolean {
const count = getEmojiOnlyCount(input, 1);
return count === 1;
}
/**
* Emoji.getJumboEmojiCount()
* --------------------------
*/
export type JumboEmojiCount = null | 1 | 2 | 3 | 4 | 5;
export const MAX_JUMBO_EMOJI_COUNT = 5 satisfies JumboEmojiCount;
export function getJumboEmojiCount(input: string): JumboEmojiCount {
const count = getEmojiOnlyCount(input, MAX_JUMBO_EMOJI_COUNT);
if (count === 0) {
return null;
}
assert(count <= MAX_JUMBO_EMOJI_COUNT);
return count as JumboEmojiCount;
}
/**
* Localization
*/
export type EmojiLocaleItem = Readonly<{
emoji: string;
tags: ReadonlyArray<string>;
rank: number;
}>;
export type EmojiLocaleData = ReadonlyArray<EmojiLocaleItem>;
export function setupLocale(localeData: EmojiLocaleData): void {
const LOCALE_INDEXES = lazy(() => {
const localeItemsByParent = new Map<Parent, EmojiLocaleItem>();
for (const item of localeData) {
if (isParent(item.emoji)) {
localeItemsByParent.set(item.emoji, item);
}
}
const parentToShortName = new Map<Parent, ShortName>();
const completionToParent = new Map<CompletionQuery, Parent>();
const searchIndex: Array<SearchIndexEntry> = [];
for (const [parent, defaultRank] of ALL_PARENTS_WITH_DEFAULT_RANK()) {
const info = getParentInfo(parent);
const item = localeItemsByParent.get(parent);
let allShortNames = (item?.tags ?? []) as Array<ShortName>;
allShortNames.push(info.shortName);
if (info.shortNameAlts != null) {
allShortNames = allShortNames.concat(info.shortNameAlts);
}
const shortName = assert(allShortNames.at(0));
const shortNameAlts = allShortNames.slice(1);
parentToShortName.set(parent, shortName);
const completion = _toEmojiCompletionQuery(shortName);
const completionAlts = shortNameAlts.map(shortNameAlt => {
return _toEmojiCompletionQuery(shortNameAlt);
});
completionToParent.set(completion, parent);
searchIndex.push({
parent,
rank: item?.rank ?? defaultRank,
completion,
completionAlts,
emoticon: info.emoticon ?? null,
emoticonAlts: [],
});
}
return { parentToShortName, completionToParent, searchIndex };
});
CURRENT_EMOJI_LOCALIZER = {
getShortName(parent) {
return (
LOCALE_INDEXES().parentToShortName.get(parent) ??
DEFAULT_ENGLISH_LOCALIZER.getShortName(parent)
);
},
matchCompletion(completion) {
return (
LOCALE_INDEXES().completionToParent.get(completion) ??
DEFAULT_ENGLISH_LOCALIZER.matchCompletion(completion)
);
},
getSearchFuses() {
return createEmojiSearchFuses(LOCALE_INDEXES().searchIndex);
},
};
}
export function search(input: string, limit = 200): ReadonlyArray<Parent> {
if (isEmptyString(input)) {
return []; // fast path
}
const fuses = getLocalizer().getSearchFuses();
const query = _toEmojiCompletionQuery(input.trim() as ShortName);
const fuse = query.length < 2 ? fuses.getExact() : fuses.getFuzzy();
const results = fuse.search(query.substring(0, 32));
const scores = results.map(result => {
const { item, matches } = result;
const { parent } = item;
const match = matches?.[0]?.value ?? item.completion;
let score: number;
if (match.startsWith(query)) {
// Exact prefix matches in [0,1] range
score = 1 - query.length / match.length;
} else {
const queryScore = result.score ?? 0;
const rankScore = item.rank / fuses.size;
// Other matches in [1,] range ordered by score and rank
score = 1 + queryScore + rankScore;
}
return { parent, score };
});
const sorted = scores.toSorted((a, b) => {
return a.score - b.score;
});
return sorted.slice(0, limit).map(result => {
return result.parent;
});
}
}

View File

@ -70,7 +70,6 @@ import { updateIdentityKey } from './services/profiles.preload.ts';
import { initializeUpdateListener } from './services/updateListener.preload.ts';
import { RoutineProfileRefresher } from './routineProfileRefresh.preload.ts';
import { isOlderThan } from './util/timestamp.std.ts';
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji.std.ts';
import { safeParsePartial } from './util/schemas.std.ts';
import { PollVoteSchema, PollTerminateSchema } from './types/Polls.dom.ts';
import type { ConversationModel } from './models/conversations.preload.ts';
@ -295,6 +294,7 @@ import { initMessageCleanup } from './services/messageStateCleanup.dom.ts';
import { MessageCache } from './services/MessageCache.preload.ts';
import { saveAndNotify } from './messages/saveAndNotify.preload.ts';
import { getBackupKeyHash } from './services/backups/crypto.preload.ts';
import { Emoji } from './axo/emoji.std.ts';
const { isNumber, throttle } = lodash;
@ -2547,7 +2547,13 @@ async function startApp(): Promise<void> {
const { reaction, timestamp } = data.message;
if (!isValidReactionEmoji(reaction.emoji)) {
if (reaction.emoji == null) {
log.warn('Received a reaction without an emoji. Dropping it');
confirm();
return;
}
if (!Emoji.isEmoji(reaction.emoji)) {
log.warn('Received an invalid reaction emoji. Dropping it');
confirm();
return;
@ -3103,7 +3109,13 @@ async function startApp(): Promise<void> {
'Reaction without targetAuthorAci'
);
if (!isValidReactionEmoji(reaction.emoji)) {
if (reaction.emoji == null) {
log.warn('Received a reaction without an emoji. Dropping it');
confirm();
return;
}
if (!Emoji.isEmoji(reaction.emoji)) {
log.warn('Received an invalid reaction emoji. Dropping it');
confirm();
return;

View File

@ -8,6 +8,7 @@ import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './AnimatedEmojiGalore.dom.tsx';
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore.dom.tsx';
import { Emoji } from '../axo/emoji.std.ts';
export default {
title: 'Components/AnimatedEmojiGalore',
@ -15,7 +16,7 @@ export default {
function getDefaultProps(): PropsType {
return {
emoji: '❤️',
emoji: Emoji.HEART,
onAnimationEnd: action('onAnimationEnd'),
};
}

View File

@ -8,16 +8,12 @@ import lodash from 'lodash';
import { useReducedMotion } from '../hooks/useReducedMotion.dom.ts';
import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx';
import { strictAssert } from '../util/assert.std.ts';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { random } = lodash;
export type PropsType = {
emoji: string;
emoji: Emoji.Variant;
onAnimationEnd: () => unknown;
rotate?: number;
scale?: number;
@ -48,9 +44,7 @@ export function AnimatedEmojiGalore({
emoji,
onAnimationEnd,
}: PropsType): JSX.Element {
strictAssert(isEmojiVariantValue(emoji), 'Must be valid english short name');
const emojiVariantKey = getEmojiVariantKeyByValue(emoji);
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
strictAssert(Emoji.isEmoji(emoji), 'Must be valid emoji variant value');
const reducedMotion = useReducedMotion();
const [springs] = useSprings(NUM_EMOJIS, i => ({
@ -80,7 +74,7 @@ export function AnimatedEmojiGalore({
),
}}
>
<FunStaticEmoji size={48} emoji={emojiVariant} role="presentation" />
<FunStaticEmoji size={48} emoji={emoji} role="presentation" />
</animated.div>
))}
</>

View File

@ -18,11 +18,12 @@ import { createPortal } from 'react-dom';
import { v4 as uuid } from 'uuid';
import { useIsMounted } from '../hooks/useIsMounted.std.ts';
import { CallReactionBurstEmoji } from './CallReactionBurstEmoji.dom.tsx';
import type { Emoji } from '../axo/emoji.std.ts';
const LIFETIME = 3000;
export type CallReactionBurstType = {
values: Array<string>;
values: Array<Emoji>;
};
type CallReactionBurstStateType = CallReactionBurstType & {

View File

@ -14,11 +14,12 @@ import lodash from 'lodash';
import { v4 as uuid } from 'uuid';
import { Emojify } from './conversation/Emojify.dom.tsx';
import { useReducedMotion } from '../hooks/useReducedMotion.dom.ts';
import type { Emoji } from '../axo/emoji.std.ts';
const { random } = lodash;
export type PropsType = {
values: Array<string>;
values: Array<Emoji>;
onAnimationEnd?: () => unknown;
};
@ -109,7 +110,7 @@ export function CallReactionBurstEmoji({ values }: PropsType): JSX.Element {
}
type AnimatedEmojiProps = {
value: string;
value: Emoji;
fromRotate: number;
fromX: number;
fromY: number;

View File

@ -24,7 +24,6 @@ import type { ConversationType } from '../state/ducks/conversations.preload.ts';
import { AvatarColors } from '../types/Colors.std.ts';
import type { PropsType } from './CallScreen.dom.tsx';
import { CallScreen as UnwrappedCallScreen } from './CallScreen.dom.tsx';
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants.std.ts';
import { missingCaseError } from '../util/missingCaseError.std.ts';
import {
getAvatarPath,
@ -38,6 +37,7 @@ import { MINUTE } from '../util/durations/index.std.ts';
import { strictAssert } from '../util/assert.std.ts';
import { generateAci } from '../test-helpers/serviceIdUtils.std.ts';
import { renderCallingParticipantMenu } from './CallingParticipantMenu.dom.stories.tsx';
import { Emoji } from '../axo/emoji.std.ts';
const { sample, shuffle, times } = lodash;
@ -834,7 +834,18 @@ export function GroupCallReactionsSkinTones(): JSX.Element {
const activeCall = useReactionsEmitter({
activeCall: props.activeCall as ActiveGroupCallType,
frequency: 500,
emojis: ['👍', '👍🏻', '👍🏼', '👍🏽', '👍🏾', '👍🏿', '❤️', '😂', '😮', '😢'],
emojis: [
Emoji.getVariant(Emoji.THUMBS_UP, Emoji.SkinTone.None),
Emoji.getVariant(Emoji.THUMBS_UP, Emoji.SkinTone.Type1),
Emoji.getVariant(Emoji.THUMBS_UP, Emoji.SkinTone.Type2),
Emoji.getVariant(Emoji.THUMBS_UP, Emoji.SkinTone.Type3),
Emoji.getVariant(Emoji.THUMBS_UP, Emoji.SkinTone.Type4),
Emoji.getVariant(Emoji.THUMBS_UP, Emoji.SkinTone.Type5),
Emoji.HEART,
Emoji.JOY,
Emoji.OPEN_MOUTH,
Emoji.CRY,
],
});
return <CallScreen {...props} activeCall={activeCall} />;
@ -845,10 +856,10 @@ export function GroupCallReactionsManyInOrder(): JSX.Element {
const remoteParticipants = allRemoteParticipants.slice(0, 5);
const reactions = remoteParticipants.map((participant, i) => {
const { demuxId } = participant;
const value =
DEFAULT_PREFERRED_REACTION_EMOJI[
i % DEFAULT_PREFERRED_REACTION_EMOJI.length
];
const defaults = Emoji.getDefaultPreferredReactionEmojis(
Emoji.SkinTone.None
);
const value = defaults[i % defaults.length];
strictAssert(value, 'Missing value');
return { timestamp, demuxId, value };
});
@ -868,12 +879,12 @@ function useReactionsEmitter({
activeCall,
frequency = 2000,
removeAfter = 5000,
emojis = DEFAULT_PREFERRED_REACTION_EMOJI,
emojis = Emoji.getDefaultPreferredReactionEmojis(Emoji.SkinTone.None),
}: {
activeCall: ActiveGroupCallType;
frequency?: number;
removeAfter?: number;
emojis?: Array<string>;
emojis?: ReadonlyArray<Emoji.Variant>;
}) {
const [call, setCall] = useState(activeCall);
useEffect(() => {

View File

@ -103,15 +103,6 @@ import { assertDev } from '../util/assert.std.ts';
import { CallingPendingParticipants } from './CallingPendingParticipants.dom.tsx';
import type { CallingImageDataCache } from './CallManager.dom.tsx';
import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx';
import {
getEmojiDebugLabel,
getEmojiParentByKey,
getEmojiParentKeyByVariantKey,
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from './fun/data/emojis.std.ts';
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.dom.tsx';
import {
BeforeNavigateResponse,
beforeNavigateService,
@ -124,6 +115,7 @@ import {
PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER,
} from './CallingPip.dom.tsx';
import type { PropsType as SmartCallingParticipantMenuProps } from '../state/smart/CallingParticipantMenu.preload.tsx';
import { Emoji } from '../axo/emoji.std.ts';
const { isEqual, noop } = lodash;
@ -1398,17 +1390,16 @@ function useReactionsToast(props: UseReactionsToastType): void {
Map<
string,
{
value: string;
originalValue: string;
value: Emoji;
originalValue: Emoji;
isBursted: boolean;
expireAt: number;
demuxId: number;
}
>
>(new Map());
const burstsShown = useRef<Map<string, number>>(new Map());
const burstsShown = useRef<Map<Emoji | Emoji.Variant, number>>(new Map());
const { showToast } = useCallingToasts();
const emojiLocalizer = useFunEmojiLocalizer();
useEffect(() => {
setPreviousReactions(reactions);
@ -1429,16 +1420,13 @@ function useReactionsToast(props: UseReactionsToastType): void {
const key = `reactions-${timestamp}-${demuxId}`;
if (!isEmojiVariantValue(value)) {
if (!Emoji.isEmoji(value)) {
log.error(
`Expected a valid emoji value, got ${getEmojiDebugLabel(value)}`
`Expected a valid emoji value, got ${Emoji.getDebugLabel(value)}`
);
return;
}
const emojiVariantKey = getEmojiVariantKeyByValue(value);
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
showToast({
key,
onlyShowOnce: true,
@ -1447,9 +1435,9 @@ function useReactionsToast(props: UseReactionsToastType): void {
<span className="CallingReactionsToasts__reaction">
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(emojiVariantKey)}
aria-label={Emoji.getDisplayLabel(value)}
size={28}
emoji={emojiVariant}
emoji={Emoji.ignorePreferredSkinTone(value)}
/>
{demuxId === localDemuxId ||
(ourServiceId && conversation?.serviceId === ourServiceId)
@ -1472,11 +1460,9 @@ function useReactionsToast(props: UseReactionsToastType): void {
);
// Normalize skin tone emoji to calculate burst threshold, but save original
// value to show in the burst animation
const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey);
const emojiParent = getEmojiParentByKey(emojiParentKey);
const normalizedValue = emojiParent.value;
const emojiParent = Emoji.getParent(value);
reactionsShown.current.set(key, {
value: normalizedValue,
value: emojiParent,
originalValue: value,
isBursted,
expireAt: timestamp + REACTIONS_BURST_WINDOW,
@ -1489,9 +1475,9 @@ function useReactionsToast(props: UseReactionsToastType): void {
return;
}
const unburstedEmojis = new Map<string, Set<string>>();
const unburstedEmojis = new Map<Emoji, Set<string>>();
const unburstedEmojisReactorIds = new Map<
string,
Emoji,
Set<ServiceIdString | number>
>();
reactionsShown.current.forEach(
@ -1505,6 +1491,10 @@ function useReactionsToast(props: UseReactionsToastType): void {
return;
}
if (!Emoji.isEmoji(value)) {
return;
}
const reactionKeys = unburstedEmojis.get(value) ?? new Set();
reactionKeys.add(key);
unburstedEmojis.set(value, reactionKeys);
@ -1540,7 +1530,7 @@ function useReactionsToast(props: UseReactionsToastType): void {
}
burstsShown.current.set(value, time);
const values: Array<string> = [];
const values: Array<Emoji> = [];
reactionKeys.forEach(key => {
const reactionShown = reactionsShown.current.get(key);
if (!reactionShown) {
@ -1565,7 +1555,6 @@ function useReactionsToast(props: UseReactionsToastType): void {
localDemuxId,
i18n,
ourServiceId,
emojiLocalizer,
]);
}

View File

@ -16,8 +16,8 @@ import type { ContactNameColorType } from '../types/Colors.std.ts';
import { ContactNameColors, ConversationColors } from '../types/Colors.std.ts';
import { getDefaultConversation } from '../test-helpers/getDefaultConversation.std.ts';
import { PaymentEventKind } from '../types/Payment.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { isNotNil } from '../util/isNotNil.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -29,12 +29,12 @@ const groupAdmins = [
},
{
member: getDefaultConversation(),
labelEmoji: '✅',
labelEmoji: Emoji.CHECKMARK,
labelString: 'Planner',
},
{
member: getDefaultConversation(),
labelEmoji: '#',
labelEmoji: Emoji.unsafeCastMaybeInvalidStringToVariant('#'),
labelString: 'Invalid Emoji',
},
{
@ -128,7 +128,7 @@ export default {
sortedGroupMembers: [],
// FunPicker
onSelectEmoji: action('onSelectEmoji'),
emojiSkinToneDefault: EmojiSkinTone.Type1,
emojiSkinToneDefault: Emoji.SkinTone.Type1,
pushPanelForConversation: action('pushPanelForConversation'),
sendStickerMessage: action('sendStickerMessage'),
// Message Requests

View File

@ -82,7 +82,6 @@ import type { FunGifSelection } from './fun/panels/FunPanelGifs.dom.tsx';
import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal.preload.tsx';
import { strictAssert } from '../util/assert.std.ts';
import { ConfirmationDialog } from './ConfirmationDialog.dom.tsx';
import type { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { FunPickerButton } from './fun/FunButton.dom.tsx';
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.tsx';
import { AxoIconButton } from '../axo/AxoIconButton.dom.tsx';
@ -92,6 +91,7 @@ import { PollCreateModal } from './PollCreateModal.dom.tsx';
import { useDocumentKeyDown } from '../hooks/useDocumentKeyDown.dom.ts';
import { hasDraft } from '../util/hasDraft.std.ts';
import type { ContactNameColorType } from '../types/Colors.std.ts';
import type { Emoji } from '../axo/emoji.std.ts';
export type OwnProps = Readonly<{
acceptedMessageRequest: boolean | null;
@ -123,7 +123,7 @@ export type OwnProps = Readonly<{
focusCounter: number;
groupAdmins: Array<{
member: ConversationType;
labelEmoji: string | undefined;
labelEmoji: Emoji.Variant | undefined;
labelString: string | undefined;
}>;
groupVersion: 1 | 2 | null;
@ -229,7 +229,7 @@ export type OwnProps = Readonly<{
) => void;
onSelectEmoji: (emojiSelection: FunEmojiSelection) => void;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
}>;
export type Props = Pick<

View File

@ -8,8 +8,8 @@ import { getDefaultConversation } from '../test-helpers/getDefaultConversation.s
import type { Props } from './CompositionInput.dom.tsx';
import { CompositionInput } from './CompositionInput.dom.tsx';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { generateAci } from '../test-helpers/serviceIdUtils.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -46,7 +46,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => {
sendCounter: 0,
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
emojiSkinToneDefault:
overrideProps.emojiSkinToneDefault ?? EmojiSkinTone.None,
overrideProps.emojiSkinToneDefault ?? Emoji.SkinTone.None,
theme: useContext(StorybookThemeContext),
inputApi: null,
shouldHidePopovers: null,

View File

@ -88,19 +88,14 @@ import type { AutoSubstituteAsciiEmojisOptions } from '../quill/auto-substitute-
import { AutoSubstituteAsciiEmojis } from '../quill/auto-substitute-ascii-emojis/index.dom.tsx';
import { dropNull } from '../util/dropNull.std.ts';
import { SimpleQuillWrapper } from './SimpleQuillWrapper.dom.tsx';
import {
getEmojiVariantByKey,
type EmojiSkinTone,
} from './fun/data/emojis.std.ts';
import { FUN_STATIC_EMOJI_CLASS } from './fun/FunEmoji.dom.tsx';
import { useFunEmojiSearch } from './fun/useFunEmojiSearch.dom.tsx';
import type { EmojiCompletionOptions } from '../quill/emoji/completion.dom.tsx';
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.dom.tsx';
import { MAX_BODY_ATTACHMENT_BYTE_LENGTH } from '../util/longAttachment.std.ts';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import { AxoSymbol } from '../axo/AxoSymbol.dom.tsx';
import { AxoTooltip } from '../axo/AxoTooltip.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
import type { Emoji } from '../axo/emoji.std.ts';
const log = createLogger('CompositionInput');
@ -145,7 +140,7 @@ export type Props = Readonly<{
isFormattingEnabled: boolean;
isActive: boolean;
sendCounter: number;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
draftText: string | null;
draftBodyRanges: HydratedBodyRangesType | null;
moduleClassName?: string;
@ -321,12 +316,10 @@ export function CompositionInput(props: Props): ReactElement {
return;
}
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
const delta = new Delta()
.retain(insertionRange.index)
.delete(insertionRange.length)
.insert({ emoji: { value: emojiVariant.value } });
.insert({ emoji: { value: emojiSelection.emoji } });
quill.updateContents(delta, 'user');
quill.setSelection(insertionRange.index + 1, 0, 'user');
@ -811,9 +804,6 @@ export function CompositionInput(props: Props): ReactElement {
const callbacksRef = useRef(unstaleCallbacks);
callbacksRef.current = unstaleCallbacks;
const emojiSearch = useFunEmojiSearch();
const emojiLocalizer = useFunEmojiLocalizer();
const reactQuill = useMemo(
() => {
const delta = generateDelta(draftText || '', draftBodyRanges || []);
@ -879,8 +869,6 @@ export function CompositionInput(props: Props): ReactElement {
onSelectEmoji: (emojiSelection: FunEmojiSelection) =>
callbacksRef.current.onSelectEmoji(emojiSelection),
emojiSkinToneDefault,
emojiSearch,
emojiLocalizer,
} satisfies EmojiCompletionOptions,
autoSubstituteAsciiEmojis: {
emojiSkinToneDefault,

View File

@ -14,8 +14,8 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges.prelo
import * as grapheme from '../util/grapheme.std.ts';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import type { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { FunEmojiPickerButton } from './fun/FunButton.dom.tsx';
import type { Emoji } from '../axo/emoji.std.ts';
export type CompositionTextAreaProps = {
bodyRanges: HydratedBodyRangesType | null;
@ -32,8 +32,8 @@ export type CompositionTextAreaProps = {
draftBodyRanges: HydratedBodyRangesType,
caretLocation?: number
) => void;
emojiSkinToneDefault: EmojiSkinTone;
onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void;
emojiSkinToneDefault: Emoji.SkinTone;
onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: Emoji.SkinTone) => void;
onSubmit: (
message: string,
draftBodyRanges: DraftBodyRanges,

View File

@ -16,6 +16,7 @@ import { getDefaultConversation } from '../test-helpers/getDefaultConversation.s
import { ThemeType } from '../types/Util.std.ts';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext.std.ts';
import { makeFakeLookupConversationWithoutServiceId } from '../test-helpers/fakeLookupConversationWithoutServiceId.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { times, omit } = lodash;
@ -349,7 +350,7 @@ export const ConversationWithDraft = (): JSX.Element =>
shouldShowDraft: true,
draftPreview: {
text: "I'm in the middle of typing this...",
prefix: '🎤',
prefix: Emoji.MICROPHONE,
bodyRanges: [],
},
});

View File

@ -5,10 +5,9 @@ import type { ComponentProps, JSX } from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants.std.ts';
import type { PropsType } from './CustomizingPreferredReactionsModal.dom.tsx';
import { CustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal.dom.tsx';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -22,19 +21,28 @@ const defaultProps: ComponentProps<typeof CustomizingPreferredReactionsModal> =
'cancelCustomizePreferredReactionsModal'
),
deselectDraftEmoji: action('deselectDraftEmoji'),
draftPreferredReactions: ['✨', '❇️', '🎇', '🦈', '💖', '🅿️'],
draftPreferredReactions: [
Emoji.SPARKLES,
Emoji.SPARKLE,
Emoji.FIREWORK_SPARKLER,
Emoji.SHARK,
Emoji.SPARKLING_HEART,
Emoji.PARKING,
],
hadSaveError: false,
i18n,
isSaving: false,
onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'),
originalPreferredReactions: DEFAULT_PREFERRED_REACTION_EMOJI,
recentEmojis: ['cake'],
originalPreferredReactions: Emoji.getDefaultPreferredReactionEmojis(
Emoji.SkinTone.None
),
recentEmojis: [Emoji.CAKE],
replaceSelectedDraftEmoji: action('replaceSelectedDraftEmoji'),
resetDraftEmoji: action('resetDraftEmoji'),
savePreferredReactions: action('savePreferredReactions'),
selectDraftEmojiToBeReplaced: action('selectDraftEmojiToBeReplaced'),
selectedDraftEmojiIndex: undefined,
emojiSkinToneDefault: EmojiSkinTone.Type4,
emojiSkinToneDefault: Emoji.SkinTone.Type4,
};
export function Default(): JSX.Element {

View File

@ -12,31 +12,26 @@ import {
ReactionPickerPickerEmojiButton,
ReactionPickerPickerStyle,
} from './ReactionPickerPicker.dom.tsx';
import { DEFAULT_PREFERRED_REACTION_EMOJI_PARENT_KEYS } from '../reactions/constants.std.ts';
import {
EmojiSkinTone,
getEmojiVariantByKey,
getEmojiVariantByParentKeyAndSkinTone,
} from './fun/data/emojis.std.ts';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import { Emoji } from '../axo/emoji.std.ts';
const { isEqual } = lodash;
export type PropsType = {
draftPreferredReactions: ReadonlyArray<string>;
draftPreferredReactions: ReadonlyArray<Emoji.Variant>;
hadSaveError: boolean;
i18n: LocalizerType;
isSaving: boolean;
originalPreferredReactions: ReadonlyArray<string>;
recentEmojis: ReadonlyArray<string>;
originalPreferredReactions: ReadonlyArray<Emoji.Variant>;
recentEmojis: ReadonlyArray<Emoji.Parent>;
selectedDraftEmojiIndex: undefined | number;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
cancelCustomizePreferredReactionsModal(): unknown;
deselectDraftEmoji(): unknown;
onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void;
replaceSelectedDraftEmoji(newEmoji: string): unknown;
onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: Emoji.SkinTone) => void;
replaceSelectedDraftEmoji(newEmoji: Emoji.Variant): unknown;
resetDraftEmoji(): unknown;
savePreferredReactions(): unknown;
selectDraftEmojiToBeReplaced(index: number): unknown;
@ -68,13 +63,9 @@ export function CustomizingPreferredReactionsModal({
const canReset =
!isSaving &&
!isEqual(
DEFAULT_PREFERRED_REACTION_EMOJI_PARENT_KEYS.map(parentKey => {
const variant = getEmojiVariantByParentKeyAndSkinTone(
parentKey,
emojiSkinToneDefault ?? EmojiSkinTone.None
);
return variant.value;
}),
Emoji.getDefaultPreferredReactionEmojis(
emojiSkinToneDefault ?? Emoji.SkinTone.None
),
draftPreferredReactions
);
const canSave = !isSaving && hasChanged;
@ -147,10 +138,7 @@ export function CustomizingPreferredReactionsModal({
deselectDraftEmoji();
}}
onSelectEmoji={emojiSelection => {
const emojiVariant = getEmojiVariantByKey(
emojiSelection.variantKey
);
replaceSelectedDraftEmoji(emojiVariant.value);
replaceSelectedDraftEmoji(emojiSelection.emoji);
}}
/>
);
@ -165,7 +153,7 @@ export function CustomizingPreferredReactionsModal({
}
function CustomizingPreferredReactionsModalItem(props: {
emoji: string;
emoji: Emoji.Variant;
isSelected: boolean;
onSelect: () => void;
onDeselect: () => void;

View File

@ -10,10 +10,10 @@ import {
import { ThemeType } from '../types/Util.std.ts';
import { CompositionTextArea } from './CompositionTextArea.dom.tsx';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea.preload.tsx';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { LoadingState } from '../util/loadable.std.ts';
import { VIDEO_MP4 } from '../types/MIME.std.ts';
import { drop } from '../util/drop.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -39,7 +39,7 @@ function RenderCompositionTextArea(props: SmartCompositionTextAreaProps) {
onTextTooLong={action('onTextTooLong')}
ourConversationId="me"
platform="darwin"
emojiSkinToneDefault={EmojiSkinTone.None}
emojiSkinToneDefault={Emoji.SkinTone.None}
convertDraftBodyRangesIntoHydrated={() => []}
/>
);

View File

@ -8,11 +8,11 @@ import { Modal } from './Modal.dom.tsx';
import type { HydratedBodyRangesType } from '../types/BodyRange.std.ts';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea.preload.tsx';
import type { ThemeType } from '../types/Util.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { FunGifPreview } from './fun/FunGif.dom.tsx';
import type { FunGifSelection } from './fun/panels/FunPanelGifs.dom.tsx';
import type { GifDownloadState } from '../state/smart/DraftGifMessageSendModal.preload.tsx';
import { LoadingState } from '../util/loadable.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
export type DraftGifMessageSendModalProps = Readonly<{
i18n: LocalizerType;
@ -98,7 +98,7 @@ export function DraftGifMessageSendModal(
onChange={props.onChange}
onSubmit={props.onSubmit}
theme={props.theme}
emojiSkinToneDefault={EmojiSkinTone.None}
emojiSkinToneDefault={Emoji.SkinTone.None}
/>
</Modal>
);

View File

@ -15,7 +15,7 @@ import { getDefaultConversation } from '../test-helpers/getDefaultConversation.s
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext.std.ts';
import { CompositionTextArea } from './CompositionTextArea.dom.tsx';
import type { MessageForwardDraft } from '../types/ForwardDraft.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const createAttachment = (
props: Partial<AttachmentForUIType> = {}
@ -69,7 +69,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
onTextTooLong={action('onTextTooLong')}
ourConversationId="me"
platform="darwin"
emojiSkinToneDefault={EmojiSkinTone.None}
emojiSkinToneDefault={Emoji.SkinTone.None}
convertDraftBodyRangesIntoHydrated={() => []}
/>
),

View File

@ -43,7 +43,7 @@ import {
} from '../types/ForwardDraft.std.ts';
import { missingCaseError } from '../util/missingCaseError.std.ts';
import { Theme } from '../util/theme.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
export enum ForwardMessagesModalType {
Forward,
@ -477,7 +477,7 @@ function ForwardMessageEditor({
onChange={onChange}
onSubmit={onSubmit}
theme={theme}
emojiSkinToneDefault={EmojiSkinTone.None}
emojiSkinToneDefault={Emoji.SkinTone.None}
/>
</div>
);

View File

@ -75,7 +75,7 @@ export function MultipleTagReplacement(
);
}
export function Emoji(
export function WithEmoji(
args: Props<'icu:Message__reaction-emoji-label--you'>
): JSX.Element {
return (

View File

@ -5,7 +5,7 @@ import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './MediaEditor.dom.tsx';
import { MediaEditor } from './MediaEditor.dom.tsx';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
const IMAGE_1 = '/fixtures/nathan-anderson-316188-unsplash.jpg';
@ -30,7 +30,7 @@ export default {
onSelectEmoji: action('onSelectEmoji'),
onTextTooLong: action('onTextTooLong'),
platform: 'darwin',
emojiSkinToneDefault: EmojiSkinTone.None,
emojiSkinToneDefault: Emoji.SkinTone.None,
convertDraftBodyRangesIntoHydrated: () => undefined,
},
} satisfies Meta<PropsType>;

View File

@ -17,6 +17,7 @@ import { HOUR } from '../util/durations/index.std.ts';
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.tsx';
import { AxoButton } from '../axo/AxoButton.dom.tsx';
import type { ConversationType } from '../state/ducks/conversations.preload.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -34,7 +35,7 @@ const threeProfiles = [
{
id: 'Weekday' as NotificationProfileIdString,
name: 'Weekday',
emoji: '😬',
emoji: Emoji.GRIMACING,
color: 0xffe3e3fe,
createdAtMs: Date.now(),
@ -63,7 +64,7 @@ const threeProfiles = [
{
id: 'Weekend' as NotificationProfileIdString,
name: 'Weekend',
emoji: '❤️‍🔥',
emoji: Emoji.HEART_ON_FIRE,
color: 0xffd7d7d9,
createdAtMs: Date.now(),

View File

@ -21,7 +21,6 @@ import { Toast } from './Toast.dom.tsx';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import { FunEmojiPickerButton } from './fun/FunButton.dom.tsx';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import { getEmojiVariantByKey } from './fun/data/emojis.std.ts';
import { strictAssert } from '../util/assert.std.ts';
import {
type PollCreateType,
@ -178,8 +177,7 @@ export function PollCreateModal({
strictAssert(inputEl, 'Missing input ref for option');
const { selectionStart, selectionEnd } = inputEl;
const variant = getEmojiVariantByKey(emojiSelection.variantKey);
const emoji = variant.value;
const emoji = emojiSelection.emoji;
const updatedOptions = options.map(opt => {
if (opt.id !== optionId) {

View File

@ -12,7 +12,6 @@ import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors.std.ts';
import { PhoneNumberSharingMode } from '../types/PhoneNumberSharingMode.std.ts';
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability.std.ts';
import { sleep } from '../util/sleep.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import {
DAY,
DurationInSeconds,
@ -61,6 +60,7 @@ import type { ExternalProps as SmartNotificationProfilesProps } from '../state/s
import type { NotificationProfileIdString } from '../types/NotificationProfile.std.ts';
import type { ExportResultType } from '../services/backups/types.std.ts';
import { BackupLevel } from '../services/backups/types.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { shuffle } = lodash;
@ -430,7 +430,7 @@ export default {
customColors: {},
defaultConversationColor: DEFAULT_CONVERSATION_COLOR,
deviceName: 'Work Windows ME',
emojiSkinToneDefault: EmojiSkinTone.None,
emojiSkinToneDefault: Emoji.SkinTone.None,
phoneNumber: '+1 555 123-4567',
hasAnyCurrentCustomChatFolders: false,
hasAudioNotifications: true,
@ -757,7 +757,7 @@ const threeProfiles = [
{
id: 'Weekday' as NotificationProfileIdString,
name: 'Weekday',
emoji: '😬',
emoji: Emoji.GRIMACING,
color: 0xffe3e3fe,
createdAtMs: Date.now(),
@ -786,7 +786,7 @@ const threeProfiles = [
{
id: 'Weekend' as NotificationProfileIdString,
name: 'Weekend',
emoji: '❤️‍🔥',
emoji: Emoji.HEART_ON_FIRE,
color: 0xffd7d7d9,
createdAtMs: Date.now(),

View File

@ -39,7 +39,6 @@ import { removeDiacritics } from '../util/removeDiacritics.std.ts';
import { assertDev } from '../util/assert.std.ts';
import { I18n } from './I18n.dom.tsx';
import { FunSkinTonesList } from './fun/FunSkinTones.dom.tsx';
import { EMOJI_PARENT_KEY_CONSTANTS } from './fun/data/emojis.std.ts';
import {
SettingsControl as Control,
FlowingSettingsControl as FlowingControl,
@ -48,14 +47,12 @@ import {
} from './PreferencesUtil.dom.tsx';
import { PreferencesBackups } from './PreferencesBackups.dom.tsx';
import { PreferencesInternal } from './PreferencesInternal.dom.tsx';
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider.dom.tsx';
import { Avatar, AvatarSize } from './Avatar.dom.tsx';
import { NavSidebar } from './NavSidebar.dom.tsx';
import type { SettingsLocation } from '../types/Nav.std.ts';
import { SettingsPage, ProfileEditorPage } from '../types/Nav.std.ts';
import { tw } from '../axo/tw.dom.tsx';
import { FullWidthButton } from './PreferencesNotificationProfiles.dom.tsx';
import type { EmojiSkinTone } from './fun/data/emojis.std.ts';
import type { MediaDeviceSettings } from '../types/Calling.std.ts';
import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.preload.ts';
import type {
@ -105,6 +102,7 @@ import { isDonationsPage } from './PreferencesDonations.dom.tsx';
import type { VisibleRemoteMegaphoneType } from '../types/Megaphone.std.ts';
import { TitlebarDragArea } from './TitlebarDragArea.dom.tsx';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { isNumber, noop, partition } = lodash;
@ -134,7 +132,7 @@ export type PropsDataType = {
customColors: Record<string, CustomColorType>;
defaultConversationColor: DefaultConversationColorType;
deviceName?: string;
emojiSkinToneDefault: EmojiSkinTone;
emojiSkinToneDefault: Emoji.SkinTone;
hasAnyCurrentCustomChatFolders: boolean;
hasAudioNotifications?: boolean;
hasAutoConvertEmoji: boolean;
@ -326,7 +324,7 @@ type PropsFunctionType = {
onCallRingtoneNotificationChange: CheckboxChangeHandlerType;
onContentProtectionChange: CheckboxChangeHandlerType;
onCountMutedConversationsChange: CheckboxChangeHandlerType;
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
onEmojiSkinToneDefaultChange: (emojiSkinTone: Emoji.SkinTone) => void;
onHasKeyTransparencyDisabledChanged: SelectChangeHandlerType<boolean>;
onHasStoriesDisabledChanged: SelectChangeHandlerType<boolean>;
onHideMenuBarChange: CheckboxChangeHandlerType;
@ -1254,7 +1252,7 @@ export function Preferences({
right={
<FunSkinTonesList
i18n={i18n}
emoji={EMOJI_PARENT_KEY_CONSTANTS.RAISED_HAND}
emoji={Emoji.HAND}
skinTone={emojiSkinToneDefault}
onSelectSkinTone={onEmojiSkinToneDefaultChange}
/>
@ -2585,7 +2583,7 @@ export function Preferences({
);
}
return (
<FunEmojiLocalizationProvider i18n={i18n}>
<>
<div className="Preferences">
<NavSidebar
title={i18n('icu:Preferences--header')}
@ -2828,7 +2826,7 @@ export function Preferences({
{content}
</div>
<TitlebarDragArea />
</FunEmojiLocalizationProvider>
</>
);
}

View File

@ -9,14 +9,8 @@ import { sample, isEqual, noop, range } from 'lodash';
import classNames from 'classnames';
import { Popper } from 'react-popper';
import {
isEmojiVariantValue,
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
} from './fun/data/emojis.std.ts';
import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.dom.tsx';
import { FunEmojiPickerButton } from './fun/FunButton.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
import { AxoButton } from '../axo/AxoButton.dom.tsx';
@ -46,8 +40,6 @@ import { useRefMerger } from '../hooks/useRefMerger.std.ts';
import { handleOutsideClick } from '../util/handleOutsideClick.dom.ts';
import { useEscapeHandling } from '../hooks/useEscapeHandling.dom.ts';
import { Modal } from './Modal.dom.tsx';
import type { EmojiVariantKey } from './fun/data/emojis.std.ts';
import type { LocalizerType } from '../types/I18N.std.ts';
import type { ThemeType } from '../types/Util.std.ts';
import type { ConversationType } from '../state/ducks/conversations.preload.ts';
@ -60,6 +52,7 @@ import type {
} from '../types/NotificationProfile.std.ts';
import type { SettingsLocation } from '../types/Nav.std.ts';
import { addLeadingZero } from '../util/timestamp.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
enum CreateFlowPage {
Name = 'Name',
@ -261,14 +254,6 @@ function getColorFromProfile(argb: number): string {
return `#${rgb.toString(16)}`;
}
function getEmojiVariantKey(value: string): EmojiVariantKey | undefined {
if (isEmojiVariantValue(value)) {
return getEmojiVariantKeyByValue(value);
}
return undefined;
}
type ProfileToSave = Omit<NotificationProfileType, 'id'>;
export function NotificationProfilesCreateFlow({
@ -284,7 +269,7 @@ export function NotificationProfilesCreateFlow({
const [page, setPage] = useState(CreateFlowPage.Name);
const [name, setName] = useState<string | undefined>();
const [emoji, setEmoji] = useState<string | undefined>();
const [emoji, setEmoji] = useState<Emoji.Variant | undefined>();
const [allowedMembers, setAllowedMembers] = useState<ReadonlySet<string>>(
new Set<string>()
);
@ -683,40 +668,39 @@ function NotificationProfilesNamePage({
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
i18n: LocalizerType;
initialEmoji: string | undefined;
initialEmoji: Emoji.Variant | undefined;
initialName?: string;
isEditing: boolean;
onBack: VoidFunction;
onNext: () => void;
onUpdate: (data: { emoji: string | undefined; name: string }) => void;
onUpdate: (data: { emoji: Emoji.Variant | undefined; name: string }) => void;
theme: ThemeType;
}) {
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const [name, setName] = useState(initialName);
const [emoji, setEmoji] = useState<string | undefined>(initialEmoji);
const emojiLocalizer = useFunEmojiLocalizer();
const [emoji, setEmoji] = useState<Emoji.Variant | undefined>(initialEmoji);
const isValid = Boolean(name);
const sampleProfileNames = useMemo(() => {
return [
{
emoji: '💪',
emoji: Emoji.getDefaultVariant(Emoji.MUSCLE),
text: i18n('icu:NotificationProfiles--sample-name__work'),
},
{
emoji: '😴',
emoji: Emoji.SLEEPING,
text: i18n('icu:NotificationProfiles--sample-name__sleep'),
},
{
emoji: '🚗',
emoji: Emoji.CAR,
text: i18n('icu:NotificationProfiles--sample-name__driving'),
},
{
emoji: '😊',
emoji: Emoji.BLUSH,
text: i18n('icu:NotificationProfiles--sample-name__downtime'),
},
{
emoji: '💡',
emoji: Emoji.BULB,
text: i18n('icu:NotificationProfiles--sample-name__focus'),
},
] as const;
@ -739,8 +723,6 @@ function NotificationProfilesNamePage({
[emoji, setEmoji, setName, onUpdate]
);
const emojiKey = emoji ? getEmojiVariantKey(emoji) : null;
return (
<>
<Header
@ -767,8 +749,7 @@ function NotificationProfilesNamePage({
onOpenChange={handleFunEmojiPickerOpenChange}
placement="bottom"
onSelectEmoji={data => {
const newEmoji = getEmojiVariantByKey(data.variantKey)?.value;
const newEmoji = data.emoji;
setEmoji(newEmoji);
if (name) {
onUpdate({ name, emoji: newEmoji });
@ -777,7 +758,7 @@ function NotificationProfilesNamePage({
closeOnSelect
theme={theme}
>
<FunEmojiPickerButton i18n={i18n} selectedEmoji={emojiKey} />
<FunEmojiPickerButton i18n={i18n} selectedEmoji={emoji} />
</FunEmojiPicker>
}
maxLengthCount={140}
@ -791,13 +772,6 @@ function NotificationProfilesNamePage({
/>
<div className={tw('mx-auto w-full max-w-[320px]')}>
{sampleProfileNames.map(item => {
const itemEmojiKey = getEmojiVariantKey(item.emoji);
strictAssert(
itemEmojiKey,
'Emoji for name defaults should exist'
);
const itemEmojiData = getEmojiVariantByKey(itemEmojiKey);
return (
<FullWidthButton
key={item.text}
@ -813,11 +787,9 @@ function NotificationProfilesNamePage({
>
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(
itemEmojiData.key
)}
aria-label={Emoji.getDisplayLabel(item.emoji)}
size={24}
emoji={itemEmojiData}
emoji={item.emoji}
/>
{item.text}
</FullWidthButton>
@ -1566,12 +1538,11 @@ function EmojiOrMoon({
i18n,
size,
}: {
emoji?: EmojiVariantKey | undefined;
emoji?: Emoji.Variant | undefined;
forceLightTheme?: boolean;
i18n: LocalizerType;
size: IconSize;
}) {
const emojiLocalizer = useFunEmojiLocalizer();
const sizeMap = useMemo(
() => ({
large: 48 as const,
@ -1605,17 +1576,15 @@ function EmojiOrMoon({
);
}
const emojiData = getEmojiVariantByKey(emoji);
return (
<span
className={tw('absolute inset-s-1/2 top-1/2 -translate-1/2 leading-0')}
>
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(emojiData.key)}
aria-label={Emoji.getDisplayLabel(emoji)}
size={sizeMap[size]}
emoji={emojiData}
emoji={emoji}
/>
</span>
);
@ -1775,7 +1744,6 @@ export function ProfileAvatar({
profile?: ProfileToSave;
size: IconSize;
}): ReactNode {
const emoji = profile?.emoji ? getEmojiVariantKey(profile.emoji) : undefined;
const backgroundColor = profile?.color
? getColorFromProfile(profile.color)
: undefined;
@ -1803,7 +1771,7 @@ export function ProfileAvatar({
style={{ backgroundColor }}
>
<EmojiOrMoon
emoji={emoji}
emoji={profile?.emoji}
forceLightTheme={forceLightTheme}
i18n={i18n}
size={size}

View File

@ -19,7 +19,7 @@ import {
} from '../state/ducks/usernameEnums.std.ts';
import { getRandomColor } from '../test-helpers/getRandomColor.std.ts';
import { SignalService as Proto } from '../protobuf/index.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -47,7 +47,7 @@ export default {
},
},
args: {
aboutEmoji: '',
aboutEmoji: undefined,
aboutText: casual.sentence,
profileAvatarUrl: undefined,
conversationId: generateUuid(),
@ -63,7 +63,7 @@ export default {
usernameEditState: UsernameEditState.Editing,
usernameLinkState: UsernameLinkState.Ready,
emojiSkinToneDefault: EmojiSkinTone.None,
emojiSkinToneDefault: Emoji.SkinTone.None,
userAvatarData: [],
username: undefined,
@ -118,7 +118,7 @@ const Template: StoryFn<PropsType> = args => {
export const FullSet = Template.bind({});
FullSet.args = {
aboutEmoji: '🙏',
aboutEmoji: Emoji.getDefaultVariant(Emoji.FOLDED_HANDS),
aboutText: 'Live. Laugh. Love',
familyName: casual.last_name,
firstName: casual.first_name,
@ -131,7 +131,7 @@ WithFullName.args = {
};
export const WithCustomAbout = Template.bind({});
WithCustomAbout.args = {
aboutEmoji: '🙏',
aboutEmoji: Emoji.getDefaultVariant(Emoji.FOLDED_HANDS),
aboutText: 'Live. Laugh. Love',
};

View File

@ -28,17 +28,8 @@ import { Tooltip, TooltipPlacement } from './Tooltip.dom.tsx';
import { offsetDistanceModifier } from '../util/popperUtil.std.ts';
import { useReducedMotion } from '../hooks/useReducedMotion.dom.ts';
import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx';
import {
EMOJI_PARENT_KEY_CONSTANTS,
EmojiSkinTone,
getEmojiVariantByKey,
getEmojiVariantByParentKeyAndSkinTone,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from './fun/data/emojis.std.ts';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import { FunEmojiPickerButton } from './fun/FunButton.dom.tsx';
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.dom.tsx';
import { PreferencesContent } from './Preferences.dom.tsx';
import { ProfileEditorPage } from '../types/Nav.std.ts';
@ -58,11 +49,11 @@ import type {
} from '../state/ducks/conversations.preload.ts';
import type { UsernameLinkState } from '../state/ducks/usernameEnums.std.ts';
import type { ShowToastAction } from '../state/ducks/toast.preload.ts';
import type { EmojiParentKey, EmojiVariantKey } from './fun/data/emojis.std.ts';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.tsx';
import { AxoButton } from '../axo/AxoButton.dom.tsx';
import { normalizeProfileName } from '../util/normalizeProfileName.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
import { AxoTextField } from '../axo/AxoTextField.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
@ -79,12 +70,12 @@ type PropsExternalType = {
};
export type PropsDataType = {
aboutEmoji?: string;
aboutEmoji?: Emoji.Variant;
aboutText?: string;
color?: AvatarColorType;
contentsRef: MutableRefObject<HTMLDivElement | null>;
conversationId: string;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
familyName?: string;
firstName: string;
hasCompletedUsernameLinkOnboarding: boolean;
@ -120,42 +111,40 @@ export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
type DefaultBio = {
i18nLabel: string;
emojiParentKey: EmojiParentKey;
emojiParent: Emoji.Parent;
};
function getDefaultBios(i18n: LocalizerType): Array<DefaultBio> {
return [
{
i18nLabel: i18n('icu:Bio--speak-freely'),
emojiParentKey: EMOJI_PARENT_KEY_CONSTANTS.WAVING_HAND,
emojiParent: Emoji.WAVE,
},
{
i18nLabel: i18n('icu:Bio--encrypted'),
emojiParentKey: EMOJI_PARENT_KEY_CONSTANTS.ZIPPER_MOUTH_FACE,
emojiParent: Emoji.ZIPPER_MOUTH_FACE,
},
{
i18nLabel: i18n('icu:Bio--free-to-chat'),
emojiParentKey: EMOJI_PARENT_KEY_CONSTANTS.THUMBS_UP,
emojiParent: Emoji.THUMBS_UP,
},
{
i18nLabel: i18n('icu:Bio--coffee-lover'),
emojiParentKey: EMOJI_PARENT_KEY_CONSTANTS.HOT_BEVERAGE,
emojiParent: Emoji.COFFEE,
},
{
i18nLabel: i18n('icu:Bio--taking-break'),
emojiParentKey: EMOJI_PARENT_KEY_CONSTANTS.MOBILE_PHONE_OFF,
emojiParent: Emoji.MOBILE_PHONE_OFF,
},
];
}
function BioEmoji(props: { emoji: EmojiVariantKey }) {
const emojiLocalizer = useFunEmojiLocalizer();
const emojiVariant = getEmojiVariantByKey(props.emoji);
function BioEmoji(props: { emoji: Emoji.Variant }) {
return (
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(props.emoji)}
emoji={emojiVariant}
aria-label={Emoji.getDisplayLabel(props.emoji)}
emoji={props.emoji}
size={24}
/>
);
@ -242,14 +231,14 @@ export function ProfileEditor({
const [isResettingUsernameLink, setIsResettingUsernameLink] = useState(false);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const stagedAboutEmojiVariantKey = useMemo(() => {
const stagedAboutEmojiVariant = useMemo(() => {
if (
stagedProfile.aboutEmoji == null ||
!isEmojiVariantValue(stagedProfile.aboutEmoji)
!Emoji.isEmoji(stagedProfile.aboutEmoji)
) {
return null;
}
return getEmojiVariantKeyByValue(stagedProfile.aboutEmoji);
return Emoji.ignorePreferredSkinTone(stagedProfile.aboutEmoji);
}, [stagedProfile.aboutEmoji]);
// Reset username edit state when leaving
@ -270,11 +259,9 @@ export function ProfileEditor({
const handleSelectEmoji = useCallback(
(emojiSelection: FunEmojiSelection) => {
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
setStagedProfile(profileData => ({
...profileData,
aboutEmoji: emojiVariant.value,
aboutEmoji: emojiSelection.emoji,
}));
},
[setStagedProfile]
@ -471,7 +458,7 @@ export function ProfileEditor({
>
<FunEmojiPickerButton
i18n={i18n}
selectedEmoji={stagedAboutEmojiVariantKey}
selectedEmoji={stagedAboutEmojiVariant}
/>
</FunEmojiPicker>
</div>
@ -501,25 +488,25 @@ export function ProfileEditor({
/>
{defaultBios.map(defaultBio => {
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
defaultBio.emojiParentKey,
emojiSkinToneDefault ?? EmojiSkinTone.None
const emojiVariant = Emoji.getVariant(
defaultBio.emojiParent,
emojiSkinToneDefault ?? Emoji.SkinTone.None
);
return (
<PanelRow
className="ProfileEditor__row"
key={defaultBio.emojiParentKey}
key={defaultBio.emojiParent}
icon={
<div className="ProfileEditor__icon--container">
<BioEmoji emoji={emojiVariant.key} />
<BioEmoji emoji={emojiVariant} />
</div>
}
label={defaultBio.i18nLabel}
onClick={() => {
setStagedProfile(profileData => ({
...profileData,
aboutEmoji: emojiVariant.value,
aboutEmoji: emojiVariant,
aboutText: defaultBio.i18nLabel,
}));
}}
@ -754,10 +741,10 @@ export function ProfileEditor({
<PanelRow
className="ProfileEditor__row"
icon={
fullBio.aboutEmoji && isEmojiVariantValue(fullBio.aboutEmoji) ? (
fullBio.aboutEmoji && Emoji.isEmoji(fullBio.aboutEmoji) ? (
<div className="ProfileEditor__icon--container">
<BioEmoji
emoji={getEmojiVariantKeyByValue(fullBio.aboutEmoji)}
emoji={Emoji.ignorePreferredSkinTone(fullBio.aboutEmoji)}
/>
</div>
) : (

View File

@ -4,16 +4,10 @@
import type { CSSProperties, ReactNode } from 'react';
import { forwardRef } from 'react';
import classNames from 'classnames';
import { Button } from 'react-aria-components';
import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx';
import {
getEmojiDebugLabel,
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from './fun/data/emojis.std.ts';
import { createLogger } from '../logging/log.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const log = createLogger('ReactionPickerPicker');
@ -25,7 +19,7 @@ export enum ReactionPickerPickerStyle {
export const ReactionPickerPickerEmojiButton = forwardRef<
HTMLButtonElement,
{
emoji: string;
emoji: Emoji.Variant;
isSelected: boolean;
onClick: () => unknown;
title?: string;
@ -34,16 +28,13 @@ export const ReactionPickerPickerEmojiButton = forwardRef<
{ emoji, onClick, isSelected, title },
ref
) {
if (!isEmojiVariantValue(emoji)) {
if (!Emoji.isEmoji(emoji)) {
log.error(
`Expected a valid emoji variant value, got ${getEmojiDebugLabel(emoji)}`
`Expected a valid emoji variant value, got ${Emoji.getDebugLabel(emoji)}`
);
return null;
}
const emojiVariantKey = getEmojiVariantKeyByValue(emoji);
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
return (
<Button
ref={ref}
@ -58,7 +49,7 @@ export const ReactionPickerPickerEmojiButton = forwardRef<
role="img"
aria-label={title ?? ''}
size={48}
emoji={emojiVariant}
emoji={emoji}
/>
</Button>
);

View File

@ -12,7 +12,7 @@ import {
getDefaultGroup,
} from '../test-helpers/getDefaultConversation.std.ts';
import { getFakeDistributionListsWithMembers } from '../test-helpers/getFakeDistributionLists.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -45,7 +45,7 @@ export default {
'setMyStoriesToAllSignalConnections'
),
signalConnections: Array.from(Array(42), getDefaultConversation),
emojiSkinToneDefault: EmojiSkinTone.None,
emojiSkinToneDefault: Emoji.SkinTone.None,
toggleSignalConnectionsModal: action('toggleSignalConnectionsModal'),
},
} satisfies Meta<PropsType>;

View File

@ -14,8 +14,7 @@ import { VIDEO_MP4 } from '../types/MIME.std.ts';
import { fakeAttachment } from '../test-helpers/fakeAttachment.std.ts';
import { getDefaultConversation } from '../test-helpers/getDefaultConversation.std.ts';
import { getFakeStoryView } from '../test-helpers/getFakeStory.dom.tsx';
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants.std.ts';
import { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -48,11 +47,13 @@ export default {
onTextTooLong: action('onTextTooLong'),
onSelectEmoji: action('onSelectEmoji'),
onMediaPlaybackStart: action('onMediaPlaybackStart'),
preferredReactionEmoji: DEFAULT_PREFERRED_REACTION_EMOJI,
preferredReactionEmoji: Emoji.getDefaultPreferredReactionEmojis(
Emoji.SkinTone.None
),
queueStoryDownload: action('queueStoryDownload'),
retryMessageSend: action('retryMessageSend'),
showToast: action('showToast'),
emojiSkinToneDefault: EmojiSkinTone.None,
emojiSkinToneDefault: Emoji.SkinTone.None,
story: getFakeStoryView(),
storyViewMode: StoryViewModeType.All,
viewStory: action('viewStory'),

View File

@ -51,9 +51,9 @@ import { MessageBody } from './conversation/MessageBody.dom.tsx';
import { RenderLocation } from './conversation/MessageTextRenderer.dom.tsx';
import { arrow } from '../util/keyboard.dom.ts';
import { StoryProgressSegment } from './StoryProgressSegment.dom.tsx';
import type { EmojiSkinTone } from './fun/data/emojis.std.ts';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import type { ContactModalStateType } from '../types/globalModals.std.ts';
import type { Emoji } from '../axo/emoji.std.ts';
const log = createLogger('StoryViewer');
@ -95,7 +95,7 @@ export type PropsType = {
onGoToConversation: (conversationId: string) => unknown;
onHideStory: (conversationId: string) => unknown;
onTextTooLong: () => unknown;
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
onReactToStory: (emoji: Emoji.Variant, story: StoryViewType) => unknown;
onReplyToStory: (
message: string,
bodyRanges: DraftBodyRanges,
@ -106,7 +106,7 @@ export type PropsType = {
onMediaPlaybackStart: () => void;
ourConversationId: string | undefined;
platform: string;
preferredReactionEmoji: ReadonlyArray<string>;
preferredReactionEmoji: ReadonlyArray<Emoji.Variant>;
queueStoryDownload: (storyId: string) => unknown;
replyState?: ReplyStateType;
retryMessageSend: (messageId: string) => unknown;
@ -114,7 +114,7 @@ export type PropsType = {
setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown;
showContactModal: (payload: ContactModalStateType) => void;
showToast: ShowToastAction;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
story: StoryViewType;
storyViewMode: StoryViewModeType;
viewStory: ViewStoryActionCreatorType;
@ -178,7 +178,9 @@ export function StoryViewer({
useState<boolean>(false);
const [storyDuration, setStoryDuration] = useState<number | undefined>();
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
const [reactionEmoji, setReactionEmoji] = useState<string | undefined>();
const [reactionEmoji, setReactionEmoji] = useState<
Emoji.Variant | undefined
>();
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
StoryViewType | undefined
>();

View File

@ -12,7 +12,7 @@ import { SendStatus } from '../messages/MessageSendState.std.ts';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal.dom.tsx';
import { getDefaultConversation } from '../test-helpers/getDefaultConversation.std.ts';
import { StoryViewTargetType } from '../types/Stories.std.ts';
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../reactions/constants.std.ts';
import { Emoji } from '../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -40,7 +40,9 @@ export default {
onReply: action('onReply'),
onTextTooLong: action('onTextTooLong'),
onSelectEmoji: action('onSelectEmoji'),
preferredReactionEmoji: DEFAULT_PREFERRED_REACTION_EMOJI,
preferredReactionEmoji: Emoji.getDefaultPreferredReactionEmojis(
Emoji.SkinTone.None
),
replies: [],
views: [],
viewTarget: StoryViewTargetType.Views,
@ -119,7 +121,7 @@ function getViewsAndReplies() {
author: p4,
conversationId: p4.id,
id: generateUuid(),
reactionEmoji: '❤️',
reactionEmoji: Emoji.HEART,
timestamp: Date.now() - 5 * durations.MINUTE,
},
{

View File

@ -31,7 +31,6 @@ import { WidthBreakpoint } from './_util.std.ts';
import { getAvatarColor } from '../types/Colors.std.ts';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled.std.ts';
import { ConfirmationDialog } from './ConfirmationDialog.dom.tsx';
import type { EmojiSkinTone } from './fun/data/emojis.std.ts';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import { FunEmojiPickerButton } from './fun/FunButton.dom.tsx';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
@ -40,6 +39,7 @@ import { AxoContextMenu } from '../axo/AxoContextMenu.dom.tsx';
import type { AxoMenuBuilder } from '../axo/AxoMenuBuilder.dom.tsx';
import { drop } from '../util/drop.std.ts';
import type { ContactModalStateType } from '../types/globalModals.std.ts';
import type { Emoji } from '../axo/emoji.std.ts';
const { noop, orderBy } = lodash;
@ -111,7 +111,7 @@ export type PropsType = {
isInternalUser?: boolean;
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
onClose: () => unknown;
onReact: (emoji: string) => unknown;
onReact: (emoji: Emoji.Variant) => unknown;
onReply: (
message: string,
bodyRanges: DraftBodyRanges,
@ -120,10 +120,10 @@ export type PropsType = {
onTextTooLong: () => unknown;
onSelectEmoji: (emojiSelection: FunEmojiSelection) => unknown;
ourConversationId: string | undefined;
preferredReactionEmoji: ReadonlyArray<string>;
preferredReactionEmoji: ReadonlyArray<Emoji.Variant>;
replies: ReadonlyArray<ReplyType>;
showContactModal: (payload: ContactModalStateType) => void;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
sortedGroupMembers?: ReadonlyArray<ConversationType>;
views: ReadonlyArray<StorySendStateType>;
viewTarget: StoryViewTargetType;

View File

@ -28,7 +28,6 @@ import { handleOutsideClick } from '../util/handleOutsideClick.dom.ts';
import { Spinner } from './Spinner.dom.tsx';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.tsx';
import { getEmojiVariantByKey } from './fun/data/emojis.std.ts';
import { FunEmojiPickerButton } from './fun/FunButton.dom.tsx';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.tsx';
@ -341,8 +340,7 @@ export function TextStoryCreator({
const handleSelectEmoji = useCallback(
(emojiSelection: FunEmojiSelection) => {
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
const emojiValue = emojiVariant.value;
const { emoji } = emojiSelection;
onSelectEmoji(emojiSelection);
@ -353,7 +351,7 @@ export function TextStoryCreator({
const before = originalText.substr(0, insertAt);
const after = originalText.substr(insertAt, originalText.length);
return `${before}${emojiValue}${after}`;
return `${before}${emoji}${after}`;
});
},
[onSelectEmoji]

View File

@ -8,6 +8,7 @@ import type { PropsType } from './AboutContactModal.dom.tsx';
import { AboutContactModal } from './AboutContactModal.dom.tsx';
import { type ComponentMeta } from '../../storybook/types.std.ts';
import { getDefaultConversation } from '../../test-helpers/getDefaultConversation.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -107,7 +108,7 @@ export function MeWithLabel(args: PropsType): JSX.Element {
<AboutContactModal
{...{
...args,
contactLabelEmoji: '🐝',
contactLabelEmoji: Emoji.BEE,
contactLabelString: 'Worker Bee',
contactNameColor: '270',
}}
@ -121,7 +122,7 @@ export function LongLabel(args: PropsType): JSX.Element {
<AboutContactModal
{...{
...args,
contactLabelEmoji: '🐝',
contactLabelEmoji: Emoji.BEE,
contactLabelString: '𒐫 𒐫 𒐫 𒐫 𒐫 𒐫 𒐫 𒐫 𒐫 𒐫 𒐫 𒐫 𒐫',
contactNameColor: '270',
}}
@ -135,7 +136,7 @@ export function LongLabelAllEmoji(args: PropsType): JSX.Element {
<AboutContactModal
{...{
...args,
contactLabelEmoji: '🐝',
contactLabelEmoji: Emoji.BEE,
contactLabelString: '🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝',
contactNameColor: '270',
}}
@ -149,7 +150,7 @@ export function MeWithInvalidLabelEmoji(args: PropsType): JSX.Element {
<AboutContactModal
{...{
...args,
contactLabelEmoji: '@',
contactLabelEmoji: Emoji.unsafeCastMaybeInvalidStringToVariant('@'),
contactLabelString: 'Worker Bee',
contactNameColor: '270',
}}

View File

@ -8,7 +8,6 @@ import {
type JSX,
type MouseEvent,
} from 'react';
import { isInSystemContacts } from '../../util/isInSystemContacts.std.ts';
import { Avatar, AvatarBlur, AvatarSize } from '../Avatar.dom.tsx';
import { Modal } from '../Modal.dom.tsx';
@ -18,17 +17,11 @@ import { About } from './About.dom.tsx';
import { I18n } from '../I18n.dom.tsx';
import { canHaveNicknameAndNote } from '../../util/nicknames.dom.ts';
import { Tooltip, TooltipPlacement } from '../Tooltip.dom.tsx';
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.tsx';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../fun/data/emojis.std.ts';
import { FunStaticEmoji } from '../fun/FunEmoji.dom.tsx';
import { missingEmojiPlaceholder } from '../../types/GroupMemberLabels.std.ts';
import type { ConversationType } from '../../state/ducks/conversations.preload.ts';
import type { LocalizerType } from '../../types/Util.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
function muted(parts: Array<string | JSX.Element>) {
return (
@ -40,7 +33,7 @@ export type PropsType = Readonly<{
i18n: LocalizerType;
canAddLabel: boolean;
contact: ConversationType;
contactLabelEmoji: string | undefined;
contactLabelEmoji: Emoji.Variant | undefined;
contactLabelString: string | undefined;
contactNameColor: string | undefined;
fromOrAddedByTrustedContact?: boolean;
@ -135,23 +128,20 @@ export function AboutContactModal({
const shouldShowLabel = isMe && hasLabel;
const shouldShowAddLabel =
isMe && !hasLabel && canAddLabel && isEditMemberLabelEnabled;
const emojiLocalizer = useFunEmojiLocalizer();
let labelEmojiElement;
if (
shouldShowLabel &&
contactLabelEmoji &&
isEmojiVariantValue(contactLabelEmoji)
Emoji.isEmoji(contactLabelEmoji)
) {
const emojiKey = getEmojiVariantKeyByValue(contactLabelEmoji);
const labelEmojiData = getEmojiVariantByKey(emojiKey);
labelEmojiElement = (
<>
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(labelEmojiData.key)}
aria-label={Emoji.getDisplayLabel(contactLabelEmoji)}
size={14}
emoji={labelEmojiData}
emoji={contactLabelEmoji}
/>{' '}
</>
);

View File

@ -12,6 +12,7 @@ import { ThemeType } from '../../types/Util.std.ts';
import { getDefaultConversation } from '../../test-helpers/getDefaultConversation.std.ts';
import { getFakeBadges } from '../../test-helpers/getFakeBadge.std.ts';
import { SignalService as Proto } from '../../protobuf/index.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
@ -85,7 +86,7 @@ AsNonAdmin.args = {
export const WithLabel = Template.bind({});
WithLabel.args = {
areWeAdmin: false,
contactLabelEmoji: '💪🏼',
contactLabelEmoji: Emoji.getVariant(Emoji.MUSCLE, Emoji.SkinTone.Type2),
contactLabelString: 'Strong',
contactNameColor: '180',
};
@ -100,28 +101,28 @@ WithLabelNoEmoji.args = {
export const WithLabelInvalidEmoji = Template.bind({});
WithLabelInvalidEmoji.args = {
areWeAdmin: false,
contactLabelEmoji: '%',
contactLabelEmoji: Emoji.unsafeCastMaybeInvalidStringToVariant('%'),
contactLabelString: 'Strong',
contactNameColor: '220',
};
export const LongLabel = Template.bind({});
LongLabel.args = {
contactLabelEmoji: '🐝',
contactLabelEmoji: Emoji.BEE,
contactLabelString: '𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫',
contactNameColor: '270',
};
export const LongLabel2 = Template.bind({});
LongLabel2.args = {
contactLabelEmoji: '🐝',
contactLabelEmoji: Emoji.BEE,
contactLabelString: '﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽﷽',
contactNameColor: '270',
};
export const LongLabelAllEmoji = Template.bind({});
LongLabelAllEmoji.args = {
contactLabelEmoji: '🐝',
contactLabelEmoji: Emoji.BEE,
contactLabelString: '🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝🐝',
contactNameColor: '270',
};

View File

@ -40,6 +40,7 @@ import { tw } from '../../axo/tw.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import type { RemoveClientType } from '../../types/Calling.std.ts';
import type { ContactNameColorType } from '../../types/Colors.std.ts';
import type { Emoji } from '../../axo/emoji.std.ts';
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
@ -51,7 +52,7 @@ export type PropsDataType = {
areWeAdmin: boolean;
badges: ReadonlyArray<BadgeType>;
contact?: ConversationType;
contactLabelEmoji: string | undefined;
contactLabelEmoji: Emoji.Variant | undefined;
contactLabelString: string | undefined;
contactNameColor: ContactNameColorType | undefined;
conversation?: ConversationType;

View File

@ -7,6 +7,7 @@ import type { Meta } from '@storybook/react';
import type { PropsType } from './ContactName.dom.tsx';
import { ContactName } from './ContactName.dom.tsx';
import { ContactNameColors } from '../../types/Colors.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
export default {
title: 'Components/Conversation/ContactName',
@ -32,7 +33,7 @@ export function WithLongLabel(): JSX.Element {
<ContactName
title="Troublemaker"
contactLabel={{
labelEmoji: '✅',
labelEmoji: Emoji.CHECKMARK,
labelString:
"this is a long label. really long. why don't we see what happens?",
}}
@ -48,7 +49,7 @@ export function WithLabelWithBigUnicode(): JSX.Element {
<ContactName
title="Troublemaker"
contactLabel={{
labelEmoji: '✅',
labelEmoji: Emoji.CHECKMARK,
labelString: '𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫𒐫',
}}
contactNameColor="140"
@ -77,7 +78,10 @@ export function ColorsWithLabels(): JSX.Element {
<ContactName
title={`Hello ${color}`}
contactNameColor={color}
contactLabel={{ labelEmoji: '✅', labelString: 'Task Wrangler' }}
contactLabel={{
labelEmoji: Emoji.CHECKMARK,
labelString: 'Task Wrangler',
}}
/>
</div>
))}
@ -113,7 +117,7 @@ export function ColorsWithInvalidLabelEmoji(): JSX.Element {
title={`Hello ${color}`}
contactNameColor={color}
contactLabel={{
labelEmoji: '&',
labelEmoji: Emoji.unsafeCastMaybeInvalidStringToVariant('&'),
labelString: 'Task Wrangler',
}}
/>

View File

@ -8,12 +8,6 @@ import type { ReactNode, JSX, MouseEvent } from 'react';
import { getClassNamesFor } from '../../util/getClassNamesFor.std.ts';
import { isSignalConversation as getIsSignalConversation } from '../../util/isSignalConversation.dom.ts';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../fun/data/emojis.std.ts';
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.tsx';
import { FunStaticEmoji } from '../fun/FunEmoji.dom.tsx';
import { missingEmojiPlaceholder } from '../../types/GroupMemberLabels.std.ts';
@ -23,10 +17,11 @@ import type { ContactNameColorType } from '../../types/Colors.std.ts';
import type { FunStaticEmojiSize } from '../fun/FunEmoji.dom.tsx';
import { UserText } from '../UserText.dom.tsx';
import { OfficialChatInlineBadge } from './OfficialChatInlineBadge.dom.tsx';
import { Emoji } from '../../axo/emoji.std.ts';
export type ContactNameData = {
contactNameColor?: ContactNameColorType;
contactLabel?: { labelString: string; labelEmoji: string | undefined };
contactLabel?: { labelString: string; labelEmoji: Emoji.Variant | undefined };
firstName?: string;
isSignalConversation?: boolean;
isMe?: boolean;
@ -134,7 +129,6 @@ export function GroupMemberLabel({
context: Context;
module?: string;
}): ReactNode {
const emojiLocalizer = useFunEmojiLocalizer();
const getClassName = getClassNamesFor('module-contact-name', module);
if (!contactLabel) {
@ -144,10 +138,7 @@ export function GroupMemberLabel({
const { labelEmoji, labelString } = contactLabel;
let emojiElement;
if (labelEmoji && isEmojiVariantValue(labelEmoji)) {
const emojiKey = getEmojiVariantKeyByValue(labelEmoji);
const emojiData = getEmojiVariantByKey(emojiKey);
if (labelEmoji && Emoji.isEmoji(labelEmoji)) {
emojiElement = (
<span
className={classNames(
@ -157,9 +148,9 @@ export function GroupMemberLabel({
>
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(emojiData.key)}
aria-label={Emoji.getDisplayLabel(labelEmoji)}
size={emojiSize}
emoji={emojiData}
emoji={Emoji.ignorePreferredSkinTone(labelEmoji)}
/>
</span>
);

View File

@ -12,6 +12,7 @@ import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext
import { getDefaultConversation } from '../../test-helpers/getDefaultConversation.std.ts';
import { ThemeType } from '../../types/Util.std.ts';
import type { GroupV2Membership } from './conversation-details/ConversationDetailsMembershipList.dom.tsx';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -29,7 +30,7 @@ const createMemberships = ({
return Array.from(new Array(count)).map(
(_, i): GroupV2Membership => ({
isAdmin: i % 3 === 0,
labelEmoji: i % 6 === 0 ? '🟢' : undefined,
labelEmoji: i % 6 === 0 ? Emoji.GREEN_CIRCLE : undefined,
labelString: i % 3 === 0 ? `Task Wrangler ${i}` : undefined,
member: unknownContactIndices.includes(i)
? getDefaultConversation({

View File

@ -1,20 +1,11 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties, JSX } from 'react';
import { useMemo } from 'react';
import type { RenderTextCallbackType } from '../../types/Util.std.ts';
import { splitByEmoji } from '../../util/emoji.std.ts';
import { missingCaseError } from '../../util/missingCaseError.std.ts';
import { FunInlineEmoji } from '../fun/FunEmoji.dom.tsx';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
isEmojiVariantValueNonQualified,
} from '../fun/data/emojis.std.ts';
import { createLogger } from '../../logging/log.std.ts';
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.tsx';
const log = createLogger('Emojify');
import { Emoji } from '../../axo/emoji.std.ts';
export type Props = {
fontSizeOverride?: number | null;
@ -34,43 +25,31 @@ export function Emojify({
renderNonEmoji = defaultRenderNonEmoji,
style,
}: Props): JSX.Element {
const emojiLocalizer = useFunEmojiLocalizer();
const segments = useMemo(() => {
return Array.from(Emoji.getSegments(text));
}, [text]);
return (
<>
{splitByEmoji(text).map(({ type, value: match }, index) => {
if (type === 'emoji') {
// If we don't recognize the emoji, render it as text.
if (!isEmojiVariantValue(match)) {
log.warn('Found emoji that we did not recognize', match.length);
return renderNonEmoji({ text: match, key: index });
}
// Render emoji as text if they are a non-qualified emoji value.
if (isEmojiVariantValueNonQualified(match)) {
return renderNonEmoji({ text: match, key: index });
}
const variantKey = getEmojiVariantKeyByValue(match);
const variant = getEmojiVariantByKey(variantKey);
{segments.map(segment => {
if (segment.kind === 'emoji') {
return (
<FunInlineEmoji
// oxlint-disable-next-line react/no-array-index-key
key={index}
key={segment.offset}
role="img"
aria-label={emojiLocalizer.getLocaleShortName(variantKey)}
emoji={variant}
aria-label={Emoji.getDisplayLabel(segment.value)}
emoji={segment.value}
size={fontSizeOverride}
style={style}
/>
);
}
if (type === 'text') {
return renderNonEmoji({ text: match, key: index });
if (segment.kind === 'text') {
return renderNonEmoji({ text: segment.value, key: segment.offset });
}
throw missingCaseError(type);
throw missingCaseError(segment);
})}
</>
);

View File

@ -10,8 +10,8 @@ import {
isLinkSneaky,
shouldLinkifyMessage,
} from '../../types/LinkPreview.std.ts';
import { splitByEmoji } from '../../util/emoji.std.ts';
import { missingCaseError } from '../../util/missingCaseError.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
export const linkify = new LinkifyIt()
// This is all TLDs in place in 2010, according to [IANA's root zone database][0]
@ -326,6 +326,11 @@ export const SUPPORTED_PROTOCOLS = /^(http|https):/i;
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;
type ChunkDataItem = Readonly<{
chunk: string;
matchData: ReadonlyArray<LinkifyItMatch>;
}>;
export function Linkify(props: Props): JSX.Element {
const { text, renderNonLink = defaultRenderNonLink } = props;
@ -333,19 +338,20 @@ export function Linkify(props: Props): JSX.Element {
return <>{renderNonLink({ text, key: 1 })}</>;
}
const chunkData: Array<{
chunk: string;
matchData: ReadonlyArray<LinkifyItMatch>;
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
if (type === 'text') {
const segments = Array.from(Emoji.getSegments(text));
const chunkData = segments.map((segment): ChunkDataItem => {
const chunk = segment.value;
if (segment.kind === 'text') {
return { chunk, matchData: linkify.match(chunk) || [] };
}
if (type === 'emoji') {
if (segment.kind === 'emoji') {
return { chunk, matchData: [] };
}
throw missingCaseError(type);
throw missingCaseError(segment);
});
const results: Array<JSX.Element | string> = [];

View File

@ -116,23 +116,14 @@ import { TapToViewNotAvailableType } from '../TapToViewNotAvailableModal.dom.tsx
import type { DataPropsType as TapToViewNotAvailablePropsType } from '../TapToViewNotAvailableModal.dom.tsx';
import { FileThumbnail } from '../FileThumbnail.dom.tsx';
import { FunStaticEmoji } from '../fun/FunEmoji.dom.tsx';
import {
type EmojifyData,
getEmojiDebugLabel,
getEmojifyData,
getEmojiParentByKey,
getEmojiParentKeyByVariantKey,
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../fun/data/emojis.std.ts';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.dom.ts';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.std.ts';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.tsx';
import { AxoSymbol } from '../../axo/AxoSymbol.dom.tsx';
import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.tsx';
import type { MemberLabelType } from '../../types/GroupMemberLabels.std.ts';
import type { ContactModalStateType } from '../../types/globalModals.std.ts';
import { tw } from '../../axo/tw.dom.tsx';
import { Emoji } from '../../axo/emoji.std.ts';
const { drop, take, unescape } = lodash;
@ -202,25 +193,18 @@ export type GiftBadgeType =
state: GiftBadgeStates.Failed;
};
function ReactionEmoji(props: { emojiVariantValue: string }) {
if (!isEmojiVariantValue(props.emojiVariantValue)) {
log.error(
`Expected a valid emoji variant value, got ${getEmojiDebugLabel(props.emojiVariantValue)}`
);
return null;
}
const emojiVariantKey = getEmojiVariantKeyByValue(props.emojiVariantValue);
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey);
const emojiParent = getEmojiParentByKey(emojiParentKey);
type EmojifyData = Readonly<{
text: string;
count: Emoji.JumboEmojiCount;
}>;
function ReactionEmoji(props: { emoji: Emoji.Variant }) {
return (
<FunStaticEmoji
role="img"
aria-label={emojiParent.englishShortNameDefault}
aria-label={Emoji.getDisplayLabel(props.emoji)}
size={16}
emoji={emojiVariant}
emoji={props.emoji}
/>
);
}
@ -300,7 +284,7 @@ export type PropsData = {
authorTitle: string;
conversationColor: ConversationColorType;
customColor?: CustomColorType;
emoji?: string;
emoji?: Emoji.Variant;
isFromMe: boolean;
rawAttachment?: QuotedAttachmentForUIType;
storyId?: string;
@ -465,7 +449,7 @@ const MessageReactions = forwardRef(function MessageReactions(
}: MessageReactionsProps,
parentRef
): JSX.Element {
const ordered = useGroupedAndOrderedReactions(reactions, 'parentKey');
const ordered = useGroupedAndOrderedReactions(reactions, 'parent');
const reactionsContainerRefMerger = useRef(createRefMerger());
@ -578,7 +562,7 @@ const MessageReactions = forwardRef(function MessageReactions(
</span>
) : (
<>
<ReactionEmoji emojiVariantValue={re.emoji} />
<ReactionEmoji emoji={re.emoji} />
{re.count > 1 ? (
<span
className={classNames(
@ -1027,15 +1011,14 @@ export class Message extends PureComponent<Props, State> {
this.#cachedEmojifyData == null ||
this.#cachedEmojifyData.text !== text
) {
this.#cachedEmojifyData = getEmojifyData(text);
this.#cachedEmojifyData = {
text,
count: Emoji.getJumboEmojiCount(text),
};
}
const emojifyData = this.#cachedEmojifyData;
if (
!emojifyData.isEmojiOnlyText ||
emojifyData.emojiCount === 0 ||
emojifyData.emojiCount >= 6
) {
if (emojifyData.count == null) {
return false;
}

View File

@ -12,30 +12,30 @@ import type { RenderLocation } from './MessageTextRenderer.dom.tsx';
import { UserText } from '../UserText.dom.tsx';
import { shouldLinkifyMessage } from '../../types/LinkPreview.std.ts';
import { FunJumboEmojiSize } from '../fun/FunEmoji.dom.tsx';
import { getEmojifyData } from '../fun/data/emojis.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
import { missingCaseError } from '../../util/missingCaseError.std.ts';
function getSizeClass(str: string): FunJumboEmojiSize | null {
const emojifyData = getEmojifyData(str);
// Do we have non-emoji characters?
if (!emojifyData.isEmojiOnlyText) {
const count = Emoji.getJumboEmojiCount(str);
if (count == null) {
return null;
}
if (emojifyData.emojiCount === 1) {
if (count === 1) {
return FunJumboEmojiSize.Max;
}
if (emojifyData.emojiCount === 2) {
if (count === 2) {
return FunJumboEmojiSize.ExtraLarge;
}
if (emojifyData.emojiCount === 3) {
if (count === 3) {
return FunJumboEmojiSize.Large;
}
if (emojifyData.emojiCount === 4) {
if (count === 4) {
return FunJumboEmojiSize.Medium;
}
if (emojifyData.emojiCount === 5) {
if (count === 5) {
return FunJumboEmojiSize.Small;
}
return null;
throw missingCaseError(count);
}
export type Props = {

View File

@ -4,7 +4,6 @@
import { useMemo } from 'react';
import type { ReactElement, JSX } from 'react';
import classNames from 'classnames';
import emojiRegex from 'emoji-regex';
import lodash from 'lodash';
import { linkify, SUPPORTED_PROTOCOLS } from './Linkify.dom.tsx';
@ -26,10 +25,10 @@ import { Emojify } from './Emojify.dom.tsx';
import { AddNewLines } from './AddNewLines.dom.tsx';
import type { LocalizerType } from '../../types/Util.std.ts';
import type { FunJumboEmojiSize } from '../fun/FunEmoji.dom.tsx';
import { Emoji } from '../../axo/emoji.std.ts';
const { sortBy } = lodash;
const EMOJI_REGEXP = emojiRegex();
export enum RenderLocation {
ConversationList = 'ConversationList',
Quote = 'Quote',
@ -444,7 +443,7 @@ function extractLinks(
// to support emojis immediately before links
// we replace emojis with a space for each byte
const matches = linkify.match(
originalMessageText.replace(EMOJI_REGEXP, s => ' '.repeat(s.length))
Emoji.replaceEmojiWithSpaces(originalMessageText)
);
if (matches == null) {

View File

@ -26,6 +26,7 @@ import { getDefaultConversation } from '../../test-helpers/getDefaultConversatio
import { WidthBreakpoint } from '../_util.std.ts';
import { ThemeType } from '../../types/Util.std.ts';
import { PaymentEventKind } from '../../types/Payment.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -232,7 +233,7 @@ IncomingByAnotherWithLabel.args = {
authorTitle: getDefaultConversation().title,
isIncoming: true,
authorLabel: {
labelEmoji: '1⃣',
labelEmoji: Emoji.ONE,
labelString: 'First',
},
};
@ -251,7 +252,7 @@ export function IncomingOutgoingColors(args: Props): JSX.Element {
...args,
conversationColor: color,
authorLabel: {
labelEmoji: '1⃣',
labelEmoji: Emoji.ONE,
labelString: 'First',
},
})
@ -630,7 +631,7 @@ IsStoryReplyEmoji.args = {
url: pngUrl,
},
},
reactionEmoji: '🏋️',
reactionEmoji: Emoji.getDefaultVariant(Emoji.WEIGHT_LIFTER),
};
export const Payment = Template.bind({});

View File

@ -33,6 +33,7 @@ import type {
import type { AnyPaymentEvent } from '../../types/Payment.std.ts';
import type { QuotedAttachmentType } from '../../model-types.d.ts';
import type { MemberLabelType } from '../../types/GroupMemberLabels.std.ts';
import type { Emoji } from '../../axo/emoji.std.ts';
const { noop } = lodash;
@ -64,7 +65,7 @@ export type Props = {
payment?: AnyPaymentEvent;
isGiftBadge: boolean;
isViewOnce: boolean;
reactionEmoji?: string;
reactionEmoji?: Emoji.Variant;
referencedMessageNotFound: boolean;
doubleCheckMissingQuoteReference?: () => unknown;
};

View File

@ -7,7 +7,7 @@ import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { Props as ReactionPickerProps } from './ReactionPicker.dom.tsx';
import { ReactionPicker } from './ReactionPicker.dom.tsx';
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../reactions/constants.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -20,7 +20,9 @@ export function Base(): JSX.Element {
<ReactionPicker
i18n={i18n}
onPick={action('onPick')}
preferredReactionEmoji={DEFAULT_PREFERRED_REACTION_EMOJI}
preferredReactionEmoji={Emoji.getDefaultPreferredReactionEmojis(
Emoji.SkinTone.None
)}
/>
);
}
@ -28,13 +30,23 @@ export function Base(): JSX.Element {
export function SelectedReaction(): JSX.Element {
return (
<>
{['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => (
{[
Emoji.HEART,
Emoji.getDefaultVariant(Emoji.THUMBS_UP),
Emoji.getDefaultVariant(Emoji.THUMBS_DOWN),
Emoji.JOY,
Emoji.OPEN_MOUTH,
Emoji.CRY,
Emoji.RAGE,
].map(e => (
<div key={e} style={{ height: '100px' }}>
<ReactionPicker
i18n={i18n}
selected={e}
onPick={action('onPick')}
preferredReactionEmoji={DEFAULT_PREFERRED_REACTION_EMOJI}
preferredReactionEmoji={Emoji.getDefaultPreferredReactionEmojis(
Emoji.SkinTone.None
)}
/>
</div>
))}

View File

@ -16,19 +16,18 @@ import {
ReactionPickerPickerEmojiButton,
ReactionPickerPickerStyle,
} from '../ReactionPickerPicker.dom.tsx';
import type { EmojiVariantKey } from '../fun/data/emojis.std.ts';
import { getEmojiVariantByKey } from '../fun/data/emojis.std.ts';
import { FunEmojiPicker } from '../fun/FunEmojiPicker.dom.tsx';
import type { FunEmojiSelection } from '../fun/panels/FunPanelEmojis.dom.tsx';
import type { Emoji } from '../../axo/emoji.std.ts';
export type OwnProps = {
i18n: LocalizerType;
selected?: string;
selected?: Emoji.Variant;
onClose?: () => unknown;
onPick: (emoji: string) => unknown;
preferredReactionEmoji: ReadonlyArray<string>;
onPick: (emoji: Emoji.Variant) => unknown;
preferredReactionEmoji: ReadonlyArray<Emoji.Variant>;
theme?: ThemeType;
messageEmojis?: ReadonlyArray<EmojiVariantKey>;
messageEmojis?: ReadonlyArray<Emoji.Variant>;
};
export type Props = OwnProps & Pick<HTMLProps<HTMLDivElement>, 'style'>;
@ -70,8 +69,7 @@ export const ReactionPicker = forwardRef<HTMLDivElement, Props>(
const onSelectEmoji = useCallback(
(emojiSelection: FunEmojiSelection) => {
const variant = getEmojiVariantByKey(emojiSelection.variantKey);
onPick(variant.value);
onPick(emojiSelection.emoji);
},
[onPick]
);

View File

@ -9,6 +9,7 @@ import type { Props } from './ReactionViewer.dom.tsx';
import { ReactionViewer } from './ReactionViewer.dom.tsx';
import { getDefaultConversation } from '../../test-helpers/getDefaultConversation.std.ts';
import { ThemeType } from '../../types/Util.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -30,7 +31,7 @@ export function AllReactions(): JSX.Element {
const props = createProps({
reactions: [
{
emoji: '❤️',
emoji: Emoji.HEART,
timestamp: 1,
from: getDefaultConversation({
id: '+14155552671',
@ -40,7 +41,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '❤️',
emoji: Emoji.HEART,
timestamp: 2,
from: getDefaultConversation({
id: '+14155552672',
@ -49,7 +50,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '❤️',
emoji: Emoji.HEART,
timestamp: 3,
from: getDefaultConversation({
id: '+14155552673',
@ -58,7 +59,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '❤️',
emoji: Emoji.HEART,
timestamp: 4,
from: getDefaultConversation({
id: '+14155552674',
@ -67,7 +68,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
timestamp: 9,
from: getDefaultConversation({
id: '+14155552678',
@ -77,7 +78,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '👎',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_DOWN),
timestamp: 10,
from: getDefaultConversation({
id: '+14155552673',
@ -86,7 +87,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '😂',
emoji: Emoji.JOY,
timestamp: 11,
from: getDefaultConversation({
id: '+14155552674',
@ -95,7 +96,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '😮',
emoji: Emoji.OPEN_MOUTH,
timestamp: 12,
from: getDefaultConversation({
id: '+14155552675',
@ -104,7 +105,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '😢',
emoji: Emoji.CRY,
timestamp: 13,
from: getDefaultConversation({
id: '+14155552676',
@ -113,7 +114,7 @@ export function AllReactions(): JSX.Element {
}),
},
{
emoji: '😡',
emoji: Emoji.RAGE,
timestamp: 14,
from: getDefaultConversation({
id: '+14155552676',
@ -128,10 +129,10 @@ export function AllReactions(): JSX.Element {
export function PickedReaction(): JSX.Element {
const props = createProps({
pickedReaction: '❤️',
pickedReaction: Emoji.HEART,
reactions: [
{
emoji: '❤️',
emoji: Emoji.HEART,
from: getDefaultConversation({
id: '+14155552671',
name: 'Amelia Briggs',
@ -141,7 +142,7 @@ export function PickedReaction(): JSX.Element {
timestamp: Date.now(),
},
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
id: '+14155552671',
phoneNumber: '+14155552671',
@ -157,10 +158,10 @@ export function PickedReaction(): JSX.Element {
export function PickedMissingReaction(): JSX.Element {
const props = createProps({
pickedReaction: '😡',
pickedReaction: Emoji.RAGE,
reactions: [
{
emoji: '❤️',
emoji: Emoji.HEART,
from: getDefaultConversation({
id: '+14155552671',
name: 'Amelia Briggs',
@ -170,7 +171,7 @@ export function PickedMissingReaction(): JSX.Element {
timestamp: Date.now(),
},
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
id: '+14155552671',
phoneNumber: '+14155552671',
@ -184,18 +185,16 @@ export function PickedMissingReaction(): JSX.Element {
return <ReactionViewer {...props} />;
}
const skinTones = [
'\u{1F3FB}',
'\u{1F3FC}',
'\u{1F3FD}',
'\u{1F3FE}',
'\u{1F3FF}',
];
const thumbsUpHands = skinTones.map(skinTone => `👍${skinTone}`);
const okHands = skinTones.map(skinTone => `👌${skinTone}`).reverse();
const thumbsUpHands = Emoji.SKIN_TONE_ORDER.map(skinTone => {
return Emoji.getVariant(Emoji.THUMBS_UP, skinTone);
});
const okHands = Emoji.SKIN_TONE_ORDER.map(skinTone => {
return Emoji.getVariant(Emoji.OK_HAND, skinTone);
}).toReversed();
const createReaction = (
emoji: string,
emoji: Emoji.Variant,
name: string,
timestamp = Date.now()
) => ({
@ -210,7 +209,7 @@ const createReaction = (
export function ReactionSkinTones(): JSX.Element {
const props = createProps({
pickedReaction: '😡',
pickedReaction: Emoji.RAGE,
reactions: [
...thumbsUpHands.map((emoji, n) =>
createReaction(emoji, `Thumbs Up ${n + 1}`, Date.now() + n * 1000)

View File

@ -19,29 +19,14 @@ import type { ConversationType } from '../../state/ducks/conversations.preload.t
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges.preload.ts';
import { useEscapeHandling } from '../../hooks/useEscapeHandling.dom.ts';
import type { ThemeType } from '../../types/Util.std.ts';
import type {
EmojiParentKey,
EmojiVariantKey,
} from '../fun/data/emojis.std.ts';
import {
EMOJI_PARENT_KEY_CONSTANTS,
getEmojiDebugLabel,
getEmojiParentKeyByVariantKey,
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../fun/data/emojis.std.ts';
import { strictAssert } from '../../util/assert.std.ts';
import { FunStaticEmoji } from '../fun/FunEmoji.dom.tsx';
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.tsx';
import { createLogger } from '../../logging/log.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { mapValues, orderBy } = lodash;
const log = createLogger('ReactionViewer');
export type Reaction = {
emoji: string;
emoji: Emoji.Variant;
timestamp: number;
from: Pick<
ConversationType,
@ -60,7 +45,7 @@ export type Reaction = {
export type OwnProps = {
getPreferredBadge: PreferredBadgeSelectorType;
reactions: Array<Reaction>;
pickedReaction?: string;
pickedReaction?: Emoji.Variant;
onClose?: () => unknown;
theme: ThemeType;
};
@ -70,49 +55,38 @@ export type Props = OwnProps &
Pick<AvatarProps, 'i18n'>;
const DEFAULT_EMOJI_ORDER = [
EMOJI_PARENT_KEY_CONSTANTS.RED_HEART,
EMOJI_PARENT_KEY_CONSTANTS.THUMBS_UP,
EMOJI_PARENT_KEY_CONSTANTS.THUMBS_DOWN,
EMOJI_PARENT_KEY_CONSTANTS.FACE_WITH_TEARS_OF_JOY,
EMOJI_PARENT_KEY_CONSTANTS.FACE_WITH_OPEN_MOUTH,
EMOJI_PARENT_KEY_CONSTANTS.CRYING_FACE,
EMOJI_PARENT_KEY_CONSTANTS.ENRAGED_FACE,
Emoji.HEART,
Emoji.THUMBS_UP,
Emoji.THUMBS_DOWN,
Emoji.JOY,
Emoji.OPEN_MOUTH,
Emoji.CRY,
Emoji.RAGE,
];
type ReactionCategory = {
count: number;
emoji?: string;
emoji?: Emoji.Variant;
id: string;
index: number;
};
type ReactionWithEmojiData = Reaction &
Readonly<{
parentKey: EmojiParentKey;
variantKey: EmojiVariantKey;
parent: Emoji.Parent;
variant: Emoji.Variant;
}>;
function ReactionViewerEmoji(props: {
emojiVariantValue: string | undefined;
emoji: Emoji.Variant | null;
}): JSX.Element | null {
const emojiLocalizer = useFunEmojiLocalizer();
strictAssert(props.emojiVariantValue != null, 'Expected an emoji');
if (!isEmojiVariantValue(props.emojiVariantValue)) {
log.error(
`Must be valid emoji variant value, got ${getEmojiDebugLabel(props.emojiVariantValue)}`
);
return null;
}
const emojiVariantKey = getEmojiVariantKeyByValue(props.emojiVariantValue);
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
strictAssert(props.emoji != null, 'Missing emoji');
return (
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(emojiVariantKey)}
aria-label={Emoji.getDisplayLabel(props.emoji)}
size={18}
emoji={emojiVariant}
emoji={props.emoji}
/>
);
}
@ -133,13 +107,10 @@ export const ReactionViewer = forwardRef<HTMLDivElement, Props>(
const reactionsWithEmojiData = useMemo(
() =>
reactions
.map(reaction => {
if (!isEmojiVariantValue(reaction.emoji)) {
return null;
}
const variantKey = getEmojiVariantKeyByValue(reaction.emoji);
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
return { ...reaction, parentKey, variantKey };
.map((reaction): ReactionWithEmojiData | null => {
const variant = reaction.emoji;
const parent = Emoji.getParent(reaction.emoji);
return { ...reaction, parent, variant };
})
.filter((data): data is ReactionWithEmojiData => {
return data != null;
@ -149,7 +120,7 @@ export const ReactionViewer = forwardRef<HTMLDivElement, Props>(
const groupedAndSortedReactions = useMemo(() => {
const groups = Object.groupBy(reactionsWithEmojiData, data => {
return data.parentKey;
return data.parent;
});
return mapValues(
@ -177,9 +148,9 @@ export const ReactionViewer = forwardRef<HTMLDivElement, Props>(
const firstReaction = localUserReaction || groupedReactions[0];
strictAssert(firstReaction, 'Missing firstReaction');
return {
id: firstReaction.parentKey,
index: DEFAULT_EMOJI_ORDER.includes(firstReaction.parentKey)
? DEFAULT_EMOJI_ORDER.indexOf(firstReaction.parentKey)
id: firstReaction.parent,
index: DEFAULT_EMOJI_ORDER.includes(firstReaction.parent)
? DEFAULT_EMOJI_ORDER.indexOf(firstReaction.parent)
: Infinity,
emoji: firstReaction.emoji,
count: groupedReactions.length,
@ -259,7 +230,7 @@ export const ReactionViewer = forwardRef<HTMLDivElement, Props>(
</span>
) : (
<>
<ReactionViewerEmoji emojiVariantValue={emoji} />
<ReactionViewerEmoji emoji={emoji ?? null} />
<span className="module-reaction-viewer__header__button__count">
{count}
</span>
@ -300,7 +271,7 @@ export const ReactionViewer = forwardRef<HTMLDivElement, Props>(
)}
</div>
<div className="module-reaction-viewer__body__row__emoji">
<ReactionViewerEmoji emojiVariantValue={emoji} />
<ReactionViewerEmoji emoji={emoji} />
</div>
</div>
))}

View File

@ -44,6 +44,7 @@ import { PaymentEventKind } from '../../types/Payment.std.ts';
import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.tsx';
import type { PollVoteWithUserType } from '../../state/selectors/message.preload.ts';
import { generateAci } from '../../test-helpers/serviceIdUtils.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { isBoolean, noop } = lodash;
@ -98,7 +99,7 @@ const messageIdToAudioUrl = {
function getJoyReaction() {
return {
emoji: '😂',
emoji: Emoji.JOY,
from: getDefaultConversation({
id: '+14155552674',
phoneNumber: '+14155552674',
@ -608,7 +609,7 @@ export function ReactionsWiderMessage(): JSX.Element {
timestamp: Date.now() - 180 * 24 * 60 * 60 * 1000,
reactions: [
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
isMe: true,
id: '+14155552672',
@ -619,7 +620,7 @@ export function ReactionsWiderMessage(): JSX.Element {
timestamp: Date.now() - 10,
},
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
id: '+14155552672',
phoneNumber: '+14155552672',
@ -629,7 +630,7 @@ export function ReactionsWiderMessage(): JSX.Element {
timestamp: Date.now() - 10,
},
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
id: '+14155552673',
phoneNumber: '+14155552673',
@ -639,7 +640,7 @@ export function ReactionsWiderMessage(): JSX.Element {
timestamp: Date.now() - 10,
},
{
emoji: '😂',
emoji: Emoji.JOY,
from: getDefaultConversation({
id: '+14155552674',
phoneNumber: '+14155552674',
@ -649,7 +650,7 @@ export function ReactionsWiderMessage(): JSX.Element {
timestamp: Date.now() - 10,
},
{
emoji: '😡',
emoji: Emoji.RAGE,
from: getDefaultConversation({
id: '+14155552677',
phoneNumber: '+14155552677',
@ -659,7 +660,7 @@ export function ReactionsWiderMessage(): JSX.Element {
timestamp: Date.now() - 10,
},
{
emoji: '👎',
emoji: Emoji.getVariant(Emoji.THUMBS_DOWN, Emoji.SkinTone.None),
from: getDefaultConversation({
id: '+14155552678',
phoneNumber: '+14155552678',
@ -669,7 +670,7 @@ export function ReactionsWiderMessage(): JSX.Element {
timestamp: Date.now() - 10,
},
{
emoji: '❤️',
emoji: Emoji.HEART,
from: getDefaultConversation({
id: '+14155552679',
phoneNumber: '+14155552679',
@ -693,7 +694,7 @@ export function ReactionsShortMessage(): JSX.Element {
reactions: [
...joyReactions,
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
isMe: true,
id: '+14155552672',
@ -704,7 +705,7 @@ export function ReactionsShortMessage(): JSX.Element {
timestamp: Date.now(),
},
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
id: '+14155552672',
phoneNumber: '+14155552672',
@ -714,7 +715,7 @@ export function ReactionsShortMessage(): JSX.Element {
timestamp: Date.now(),
},
{
emoji: '👍',
emoji: Emoji.getDefaultVariant(Emoji.THUMBS_UP),
from: getDefaultConversation({
id: '+14155552673',
phoneNumber: '+14155552673',
@ -724,7 +725,7 @@ export function ReactionsShortMessage(): JSX.Element {
timestamp: Date.now(),
},
{
emoji: '😡',
emoji: Emoji.RAGE,
from: getDefaultConversation({
id: '+14155552677',
phoneNumber: '+14155552677',
@ -734,7 +735,7 @@ export function ReactionsShortMessage(): JSX.Element {
timestamp: Date.now(),
},
{
emoji: '👎',
emoji: Emoji.getVariant(Emoji.THUMBS_DOWN, Emoji.SkinTone.None),
from: getDefaultConversation({
id: '+14155552678',
phoneNumber: '+14155552678',
@ -744,7 +745,7 @@ export function ReactionsShortMessage(): JSX.Element {
timestamp: Date.now(),
},
{
emoji: '❤️',
emoji: Emoji.HEART,
from: getDefaultConversation({
id: '+14155552679',
phoneNumber: '+14155552679',
@ -783,7 +784,7 @@ LabelInGroup.args = {
text: 'Hello it is me, the saxophone.',
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelEmoji: Emoji.POULTRY_LEG,
labelString: 'Chicken Taster',
},
};
@ -799,7 +800,7 @@ LabelInGroupWithLongName.args = {
},
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelEmoji: Emoji.POULTRY_LEG,
labelString: 'Chicken Taster',
},
};
@ -815,7 +816,7 @@ LabelInGroupWithLongNameAndLongMessage.args = {
},
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelEmoji: Emoji.POULTRY_LEG,
labelString: 'Chicken Taster',
},
};
@ -868,7 +869,7 @@ StickerWithLabelInGroup.args = {
status: 'sent',
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelEmoji: Emoji.POULTRY_LEG,
labelString: 'Chicken Taster',
},
};
@ -893,7 +894,7 @@ StickerWithLongNameAndLabelInGroup.args = {
},
contactNameColor: '280',
contactLabel: {
labelEmoji: '🍗',
labelEmoji: Emoji.POULTRY_LEG,
labelString: 'Chicken Taster',
},
};
@ -3408,7 +3409,7 @@ export const StoryReplyEmoji = (): JSX.Element => {
storyReplyContext: {
authorTitle: conversation.firstName || conversation.title,
conversationColor: ConversationColors[0],
emoji: '💄',
emoji: Emoji.LIPSTICK,
isFromMe: false,
rawAttachment: fakeAttachment({
url: '/fixtures/snow.jpg',

View File

@ -32,11 +32,12 @@ import type {
import { useScrollerLock } from '../../hooks/useScrollLock.dom.tsx';
import { MessageContextMenu } from './MessageContextMenu.dom.tsx';
import { ForwardMessagesModalType } from '../ForwardMessagesModal.dom.tsx';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.dom.ts';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.std.ts';
import { isNotNil } from '../../util/isNotNil.std.ts';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.tsx';
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.tsx';
import { useDocumentKeyDown } from '../../hooks/useDocumentKeyDown.dom.ts';
import type { Emoji } from '../../axo/emoji.std.ts';
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
@ -53,7 +54,7 @@ export type PropsData = {
canReact: boolean;
canReply: boolean;
canPinMessage: boolean;
selectedReaction?: string;
selectedReaction?: Emoji.Variant;
isTargeted?: boolean;
isSignalConversation: boolean;
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
@ -66,7 +67,7 @@ export type PropsActions = {
endPoll: (id: string) => void;
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
{ emoji, remove }: { emoji: Emoji.Variant; remove: boolean }
) => void;
retryMessageSend: (id: string) => void;
sendPollVote: (params: {
@ -309,13 +310,13 @@ export function TimelineMessage(props: Props): JSX.Element {
const groupedReactions = useGroupedAndOrderedReactions(
props.reactions,
'variantKey'
'variant'
);
const messageEmojis = useMemo(() => {
return groupedReactions
.map(groupedReaction => {
return groupedReaction?.[0]?.variantKey;
return groupedReaction?.[0]?.variant;
})
.filter(isNotNil);
}, [groupedReactions]);

View File

@ -22,6 +22,7 @@ import type { ContactNameColorType } from '../../../types/Colors.std.ts';
import { ContactNameColors } from '../../../types/Colors.std.ts';
import { isNotNil } from '../../../util/isNotNil.std.ts';
import { strictAssert } from '../../../util/assert.std.ts';
import { Emoji } from '../../../axo/emoji.std.ts';
const { times } = lodash;
@ -48,7 +49,7 @@ const createProps = (
): Props => {
const memberships = times(32, i => ({
isAdmin: i === 1,
labelEmoji: i % 6 === 0 ? '🟢' : undefined,
labelEmoji: i % 6 === 0 ? Emoji.GREEN_CIRCLE : undefined,
labelString: i % 3 === 0 ? `Task Wrangler ${i}` : undefined,
member: getDefaultConversation({
isMe: i === 2,
@ -220,7 +221,7 @@ export function AsLastAdmin(): JSX.Element {
isAdmin
memberships={times(32, i => ({
isAdmin: i === 2,
labelEmoji: i % 6 === 0 ? '🟢' : undefined,
labelEmoji: i % 6 === 0 ? Emoji.GREEN_CIRCLE : undefined,
labelString: i % 3 === 0 ? `Last Admin ${i}` : undefined,
member: getDefaultConversation({
isMe: i === 2,

View File

@ -15,6 +15,7 @@ import type {
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList.dom.tsx';
import type { ContactNameColorType } from '../../../types/Colors.std.ts';
import { ContactNameColors } from '../../../types/Colors.std.ts';
import { Emoji } from '../../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -24,7 +25,7 @@ const createMemberships = (
return Array.from(new Array(numberOfMemberships)).map(
(_, i): GroupV2Membership => ({
isAdmin: i % 4 === 0,
labelEmoji: i % 6 === 0 ? '🟢' : undefined,
labelEmoji: i % 6 === 0 ? Emoji.GREEN_CIRCLE : undefined,
labelString: i % 3 === 0 ? `Task Wrangler ${i}` : undefined,
member: getDefaultConversation({
isMe: i === 2,

View File

@ -20,11 +20,12 @@ import { GroupMemberLabel } from '../ContactName.dom.tsx';
import { AriaClickable } from '../../../axo/AriaClickable.dom.tsx';
import type { ContactModalStateType } from '../../../types/globalModals.std.ts';
import type { ContactNameColorType } from '../../../types/Colors.std.ts';
import type { Emoji } from '../../../axo/emoji.std.ts';
export type GroupV2Membership = {
isAdmin: boolean;
member: ConversationType;
labelEmoji: string | undefined;
labelEmoji: Emoji.Variant | undefined;
labelString: string | undefined;
};

View File

@ -16,16 +16,36 @@ import { SECOND } from '../../../util/durations/constants.std.ts';
import { sleep } from '../../../util/sleep.std.ts';
import { SignalService as Proto } from '../../../protobuf/index.std.ts';
import { ContactNameColors } from '../../../types/Colors.std.ts';
import { Emoji } from '../../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
function getRandomEmoji() {
return sample([
Emoji.BLACK_CIRCLE,
Emoji.HEART,
Emoji.DOTTED_LINE_FACE,
Emoji.WHITE_HEART,
Emoji.TWO,
Emoji.THREE,
Emoji.CLINKING_GLASSES,
Emoji.CONFETTI_BALL,
Emoji.PLUS,
Emoji.FACE_WITH_SPIRAL_EYES,
Emoji.BIKE,
Emoji.DOG,
Emoji.CAT,
Emoji.HOUSE,
]);
}
export default {
title: 'Components/Conversation/ConversationDetails/GroupMemberLabelEditor',
} satisfies Meta<PropsType>;
const createProps = (): PropsType => ({
canAddLabel: true,
existingLabelEmoji: '🐘',
existingLabelEmoji: Emoji.ELEPHANT,
existingLabelString: 'Good Memory',
getPreferredBadge: () => undefined,
group: getDefaultConversation({ type: 'group' }),
@ -134,22 +154,7 @@ export function AFewMembersWithLabel(): JSX.Element {
(contactNameColor, i) => ({
member: getDefaultConversation(),
isAdmin: i <= 2,
labelEmoji: sample([
'⚫',
'❤️',
'🫥',
'🤍',
'2⃣',
'3⃣',
'🥂',
'🎊',
'',
'😵‍💫',
'🚲',
'🐶',
'🐱',
'🏠',
]),
labelEmoji: getRandomEmoji(),
labelString:
i % 2 === 0
? `Label number long long long long long long long long long ${i}`
@ -170,22 +175,7 @@ export function LotsOfMembersWithLabel(): JSX.Element {
membersWithLabel={ContactNameColors.map((contactNameColor, i) => ({
member: getDefaultConversation(),
isAdmin: i <= 6,
labelEmoji: sample([
'⚫',
'❤️',
'🫥',
'🤍',
'2⃣',
'3⃣',
'🥂',
'🎊',
'',
'😵‍💫',
'🚲',
'🐶',
'🐱',
'🏠',
]),
labelEmoji: getRandomEmoji(),
labelString:
i % 2 === 0
? `Label number long long long long long long long long long ${i}`

View File

@ -6,11 +6,6 @@ import { noop } from 'lodash';
import { Input } from '../../Input.dom.tsx';
import { FunEmojiPicker } from '../../fun/FunEmojiPicker.dom.tsx';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../../fun/data/emojis.std.ts';
import { FunEmojiPickerButton } from '../../fun/FunButton.dom.tsx';
import { tw } from '../../../axo/tw.dom.tsx';
import { AxoButton } from '../../../axo/AxoButton.dom.tsx';
@ -33,8 +28,6 @@ import { GroupMemberLabel } from '../ContactName.dom.tsx';
import { useConfirmDiscard } from '../../../hooks/useConfirmDiscard.dom.tsx';
import { NavTab } from '../../../types/Nav.std.ts';
import { PanelType } from '../../../types/Panels.std.ts';
import type { EmojiVariantKey } from '../../fun/data/emojis.std.ts';
import type {
ConversationType,
UpdateGroupMemberLabelType,
@ -43,10 +36,11 @@ import type { LocalizerType, ThemeType } from '../../../types/Util.std.ts';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.preload.ts';
import type { Location } from '../../../types/Nav.std.ts';
import { usePrevious } from '../../../hooks/usePrevious.std.ts';
import type { Emoji } from '../../../axo/emoji.std.ts';
export type PropsDataType = {
canAddLabel: boolean;
existingLabelEmoji: string | undefined;
existingLabelEmoji: Emoji.Variant | undefined;
existingLabelString: string | undefined;
group: ConversationType;
i18n: LocalizerType;
@ -55,7 +49,7 @@ export type PropsDataType = {
membersWithLabel: Array<{
contactNameColor: ContactNameColorType;
isAdmin: boolean;
labelEmoji: string | undefined;
labelEmoji: Emoji.Variant | undefined;
labelString: string;
member: ConversationType;
}>;
@ -84,14 +78,6 @@ export function getLeafPanelOnly(
);
}
function getEmojiVariantKey(value: string): EmojiVariantKey | undefined {
if (isEmojiVariantValue(value)) {
return getEmojiVariantKeyByValue(value);
}
return undefined;
}
export function GroupMemberLabelEditor({
canAddLabel,
group,
@ -118,7 +104,6 @@ export function GroupMemberLabelEditor({
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const emojiKey = labelEmoji ? getEmojiVariantKey(labelEmoji) : null;
const [isSaving, setIsSaving] = useState(false);
const labelStringForSave = labelString ? labelString.trim() : labelString;
@ -185,14 +170,12 @@ export function GroupMemberLabelEditor({
onOpenChange={(open: boolean) => setEmojiPickerOpen(open)}
placement="bottom"
onSelectEmoji={data => {
const newEmoji = getEmojiVariantByKey(data.variantKey)?.value;
setLabelEmoji(newEmoji);
setLabelEmoji(data.emoji);
}}
closeOnSelect
theme={theme}
>
<FunEmojiPickerButton i18n={i18n} selectedEmoji={emojiKey} />
<FunEmojiPickerButton i18n={i18n} selectedEmoji={labelEmoji} />
</FunEmojiPicker>
}
maxLengthCount={STRING_GRAPHEME_LIMIT}

View File

@ -1,15 +1,11 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useMemo, type JSX } from 'react';
import { type JSX } from 'react';
import { VisuallyHidden } from 'react-aria';
import { Button } from 'react-aria-components';
import type { LocalizerType } from '../../types/I18N.std.ts';
import {
type EmojiVariantKey,
getEmojiVariantByKey,
} from './data/emojis.std.ts';
import { FunStaticEmoji } from './FunEmoji.dom.tsx';
import { useFunEmojiLocalizer } from './useFunEmojiLocalizer.dom.tsx';
import { Emoji } from '../../axo/emoji.std.ts';
/**
* Fun Picker Button
@ -34,7 +30,7 @@ export function FunPickerButton(props: FunPickerButtonProps): JSX.Element {
*/
export type FunEmojiPickerButtonProps = Readonly<{
selectedEmoji?: EmojiVariantKey | null;
selectedEmoji?: Emoji.Variant | null;
i18n: LocalizerType;
}>;
@ -42,26 +38,15 @@ export function FunEmojiPickerButton(
props: FunEmojiPickerButtonProps
): JSX.Element {
const { i18n } = props;
const emojiLocalizer = useFunEmojiLocalizer();
const emojiVarant = useMemo(() => {
if (props.selectedEmoji == null) {
return null;
}
const variantKey = props.selectedEmoji;
const variant = getEmojiVariantByKey(variantKey);
return variant;
}, [props.selectedEmoji]);
return (
<Button className="FunButton">
{emojiVarant ? (
{props.selectedEmoji != null ? (
<FunStaticEmoji
role="img"
size={20}
aria-label={emojiLocalizer.getLocaleShortName(emojiVarant.key)}
emoji={emojiVarant}
aria-label={Emoji.getDisplayLabel(props.selectedEmoji)}
emoji={props.selectedEmoji}
/>
) : (
<span className="FunButton__Icon FunButton__Icon--EmojiPicker" />

View File

@ -6,13 +6,7 @@ import { useCallback, useEffect, useRef, type JSX } from 'react';
import { type ComponentMeta } from '../../storybook/types.std.ts';
import type { FunStaticEmojiProps } from './FunEmoji.dom.tsx';
import { FunInlineEmoji, FunStaticEmoji } from './FunEmoji.dom.tsx';
import {
_getAllEmojiVariantKeys,
EMOJI_VARIANT_KEY_CONSTANTS,
getEmojiParentByKey,
getEmojiParentKeyByVariantKey,
getEmojiVariantByKey,
} from './data/emojis.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { chunk } = lodash;
@ -31,9 +25,11 @@ const COLUMNS = 8;
type AllProps = Pick<FunStaticEmojiProps, 'size'>;
const ALL_VARIANTS = Array.from(Emoji.iterateAllVariants());
export function All(props: AllProps): JSX.Element {
const scrollerRef = useRef<HTMLDivElement>(null);
const data = Array.from(_getAllEmojiVariantKeys());
const data = ALL_VARIANTS;
const rows = chunk(data, COLUMNS);
const getScrollElement = useCallback(() => {
@ -95,19 +91,15 @@ export function All(props: AllProps): JSX.Element {
alignItems: 'center',
}}
>
{row.map(emojiVariantKey => {
const variant = getEmojiVariantByKey(emojiVariantKey);
const parentKey =
getEmojiParentKeyByVariantKey(emojiVariantKey);
const parent = getEmojiParentByKey(parentKey);
{row.map(variant => {
return (
<div
key={emojiVariantKey}
key={variant}
style={{ display: 'flex', outline: '1px solid' }}
>
<FunStaticEmoji
role="img"
aria-label={parent.englishShortNameDefault}
aria-label={Emoji.getDisplayLabel(variant)}
size={props.size}
emoji={variant}
/>
@ -122,10 +114,6 @@ export function All(props: AllProps): JSX.Element {
);
}
const FRIED_SHRIMP = getEmojiVariantByKey(
EMOJI_VARIANT_KEY_CONSTANTS.FRIED_SHRIMP
);
export function Inline(): JSX.Element {
return (
<div style={{ userSelect: 'none' }}>
@ -133,7 +121,7 @@ export function Inline(): JSX.Element {
<FunInlineEmoji
role="img"
aria-label="Fried Shrimp"
emoji={FRIED_SHRIMP}
emoji={Emoji.FRIED_SHRIMP}
/>{' '}
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Repellat
voluptates, mollitia tempora alias libero repudiandae nesciunt. Deleniti
@ -142,7 +130,7 @@ export function Inline(): JSX.Element {
<FunInlineEmoji
role="img"
aria-label="Fried Shrimp"
emoji={FRIED_SHRIMP}
emoji={Emoji.FRIED_SHRIMP}
/>{' '}
Consectetur quibusdam accusantium magni ipsum nemo eligendi quisquam
dolor, recusandae vero dolore reiciendis doloribus ducimus officiis
@ -151,7 +139,7 @@ export function Inline(): JSX.Element {
<FunInlineEmoji
role="img"
aria-label="Fried Shrimp"
emoji={FRIED_SHRIMP}
emoji={Emoji.FRIED_SHRIMP}
/>
</p>
</div>

View File

@ -4,14 +4,9 @@ import classNames from 'classnames';
import type { CSSProperties, JSX } from 'react';
import { useMemo, useState, useCallback } from 'react';
import MANIFEST from '../../../build/jumbomoji.json';
import {
getEmojiDebugLabel,
isSafeEmojifyEmoji,
type EmojiVariantData,
type EmojiVariantValue,
} from './data/emojis.std.ts';
import type { FunImageAriaProps } from './types.dom.tsx';
import { createLogger } from '../../logging/log.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const log = createLogger('FunEmoji');
@ -28,14 +23,14 @@ const KNOWN_JUMBOMOJI = new Set<string>(Object.values(MANIFEST).flat());
const MIN_JUMBOMOJI_SIZE = 33;
function getEmojiJumboUrl(
emoji: EmojiVariantData,
emoji: Emoji.Variant,
size: number | undefined
): string | null {
if (size != null && size < MIN_JUMBOMOJI_SIZE) {
return null;
}
if (KNOWN_JUMBOMOJI.has(emoji.value)) {
return `emoji://jumbo?emoji=${encodeURIComponent(emoji.value)}`;
if (KNOWN_JUMBOMOJI.has(emoji)) {
return `emoji://jumbo?emoji=${encodeURIComponent(emoji)}`;
}
return null;
}
@ -84,7 +79,7 @@ const funStaticEmojiSizeClasses = {
export type FunStaticEmojiProps = FunImageAriaProps &
Readonly<{
size: FunStaticEmojiSize;
emoji: EmojiVariantData;
emoji: Emoji.Variant;
}>;
export function FunStaticEmoji(props: FunStaticEmojiProps): JSX.Element {
@ -103,8 +98,7 @@ export function FunStaticEmoji(props: FunStaticEmojiProps): JSX.Element {
height={props.size}
role={props.role}
aria-label={props['aria-label']}
data-emoji-key={props.emoji.key}
data-emoji-value={props.emoji.value}
data-emoji={props.emoji}
className={classNames(
FUN_STATIC_EMOJI_CLASS,
funStaticEmojiSizeClasses[props.size]
@ -122,8 +116,7 @@ export function FunStaticEmoji(props: FunStaticEmojiProps): JSX.Element {
<div
role={props.role}
aria-label={props['aria-label']}
data-emoji-key={props.emoji.key}
data-emoji-value={props.emoji.value}
data-emoji={props.emoji}
className={classNames(
FUN_STATIC_EMOJI_CLASS,
FUN_STATIC_EMOJI_TEXT_CLASS,
@ -135,7 +128,7 @@ export function FunStaticEmoji(props: FunStaticEmojiProps): JSX.Element {
} as CSSProperties
}
>
{props.emoji.value}
{props.emoji}
</div>
)}
</>
@ -162,16 +155,15 @@ export function createStaticEmojiBlot(
node.setAttribute('aria-label', props['aria-label']);
}
// Needed to lookup emoji value in `matchEmojiBlot`
node.dataset.emojiKey = props.emoji.key;
node.dataset.emojiValue = props.emoji.value;
node.dataset.emoji = props.emoji;
node.innerText = props.emoji.value;
node.innerText = props.emoji;
}
export type FunInlineEmojiProps = FunImageAriaProps &
Readonly<{
size?: number | null;
emoji: EmojiVariantData;
emoji: Emoji.Variant;
style?: CSSProperties;
}>;
@ -210,8 +202,7 @@ export function FunInlineEmoji(props: FunInlineEmojiProps): JSX.Element {
className={FUN_INLINE_EMOJI_CLASS}
aria-label={props['aria-label']}
// Needed to lookup emoji value in `matchEmojiBlot`
data-emoji-key={props.emoji.key}
data-emoji-value={props.emoji.value}
data-emoji={props.emoji}
style={
{
'--fun-inline-emoji-size':
@ -220,9 +211,9 @@ export function FunInlineEmoji(props: FunInlineEmojiProps): JSX.Element {
} as CSSProperties
}
>
<div className={FUN_INLINE_EMOJI_SMALL_CLASS}>{props.emoji.value}</div>
<div className={FUN_INLINE_EMOJI_SMALL_CLASS}>{props.emoji}</div>
<div className={FUN_INLINE_EMOJI_JUMBO_CLASS}>
{!isLoaded && props.emoji.value}
{!isLoaded && props.emoji}
{img}
</div>
</div>
@ -238,23 +229,23 @@ function isFunEmojiElement(element: HTMLElement): boolean {
export function getFunEmojiElementValue(
element: HTMLElement
): EmojiVariantValue | null {
): Emoji.Variant | null {
if (!isFunEmojiElement(element)) {
return null;
}
const value = element.dataset.emojiValue;
const value = element.dataset.emoji;
if (value == null) {
log.error('Missing a data-emoji-value attribute on emoji element');
log.error('Missing a data-emoji attribute on emoji element');
return null;
}
if (!isSafeEmojifyEmoji(value)) {
if (!Emoji.isEmoji(value)) {
log.error(
`Expected a valid emoji variant value, got ${getEmojiDebugLabel(value)}`
`Expected a valid emoji variant value, got ${Emoji.getDebugLabel(value)}`
);
return null;
}
return value;
return Emoji.ignorePreferredSkinTone(value);
}

View File

@ -1,152 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
createContext,
memo,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
type JSX,
} from 'react';
import type { LocaleEmojiListType } from '../../types/emoji.std.ts';
import { createLogger } from '../../logging/log.std.ts';
import * as Errors from '../../types/errors.std.ts';
import { drop } from '../../util/drop.std.ts';
import {
getEmojiDefaultEnglishLocalizerIndex,
getEmojiDefaultEnglishSearchIndex,
} from './data/emojis.std.ts';
import {
createFunEmojiLocalizerIndex,
type FunEmojiLocalizerIndex,
} from './useFunEmojiLocalizer.dom.tsx';
import {
createFunEmojiSearchIndex,
type FunEmojiSearchIndex,
} from './useFunEmojiSearch.dom.tsx';
import type { LocalizerType } from '../../types/I18N.std.ts';
import { strictAssert } from '../../util/assert.std.ts';
import { isTestOrMockEnvironment } from '../../environment.std.ts';
const log = createLogger('FunEmojiLocalizationProvider');
export type FunEmojiLocalizationContextType = Readonly<{
emojiSearchIndex: FunEmojiSearchIndex;
emojiLocalizerIndex: FunEmojiLocalizerIndex;
}>;
const FunEmojiLocalizationContext =
createContext<FunEmojiLocalizationContextType | null>(null);
export function useFunEmojiLocalization(): FunEmojiLocalizationContextType {
const fun = useContext(FunEmojiLocalizationContext);
strictAssert(
fun != null,
'Must be wrapped with <FunEmojiLocalizationProvider>'
);
return fun;
}
export type FunEmojiLocalizationProviderProps = Readonly<{
i18n: LocalizerType;
children: ReactNode;
}>;
export const FunEmojiLocalizationProvider = memo(
function FunEmojiLocalizationProvider(
props: FunEmojiLocalizationProviderProps
) {
const localeEmojiList = useLocaleEmojiList(props.i18n);
const emojiSearchIndex = useFunEmojiSearchIndex(localeEmojiList);
const emojiLocalizerIndex = useFunEmojiLocalizerIndex(localeEmojiList);
const context = useMemo((): FunEmojiLocalizationContextType => {
return { emojiSearchIndex, emojiLocalizerIndex };
}, [emojiSearchIndex, emojiLocalizerIndex]);
return (
<FunEmojiLocalizationContext.Provider value={context}>
{props.children}
</FunEmojiLocalizationContext.Provider>
);
}
);
export type FunEmptyEmojiLocalizationProviderProps = Readonly<{
children: ReactNode;
}>;
export function FunDefaultEnglishEmojiLocalizationProvider(
props: FunEmptyEmojiLocalizationProviderProps
): JSX.Element {
const context = useMemo(() => {
return {
emojiSearchIndex: getEmojiDefaultEnglishSearchIndex(),
emojiLocalizerIndex: getEmojiDefaultEnglishLocalizerIndex(),
};
}, []);
return (
<FunEmojiLocalizationContext.Provider value={context}>
{props.children}
</FunEmojiLocalizationContext.Provider>
);
}
function useLocaleEmojiList(i18n: LocalizerType): LocaleEmojiListType | null {
const locale = useMemo(() => i18n.getLocale(), [i18n]);
const [localeEmojiList, setLocaleEmojiList] =
useState<LocaleEmojiListType | null>(null);
useEffect(() => {
let canceled = false;
async function run(): Promise<void> {
if (isTestOrMockEnvironment()) {
return;
}
try {
const list = await window.SignalContext.getLocalizedEmojiList(locale);
if (!canceled) {
setLocaleEmojiList(list);
}
} catch (error) {
log.error(
`FunProvider: Failed to get localized emoji list for "${locale}"`,
Errors.toLogFormat(error)
);
}
}
drop(run());
return () => {
canceled = true;
};
}, [locale]);
return localeEmojiList;
}
function useFunEmojiSearchIndex(
localeEmojiList: LocaleEmojiListType | null
): FunEmojiSearchIndex {
const funEmojiSearchIndex = useMemo(() => {
const defaultSearchIndex = getEmojiDefaultEnglishSearchIndex();
return localeEmojiList != null
? createFunEmojiSearchIndex(localeEmojiList, defaultSearchIndex)
: defaultSearchIndex;
}, [localeEmojiList]);
return funEmojiSearchIndex;
}
function useFunEmojiLocalizerIndex(
localeEmojiList: LocaleEmojiListType | null
): FunEmojiLocalizerIndex {
const funEmojiLocalizerIndex = useMemo(() => {
const defaultSearchIndex = getEmojiDefaultEnglishLocalizerIndex();
return localeEmojiList != null
? createFunEmojiLocalizerIndex(localeEmojiList, defaultSearchIndex)
: defaultSearchIndex;
}, [localeEmojiList]);
return funEmojiLocalizerIndex;
}

View File

@ -12,8 +12,8 @@ import {
} from '../../test-helpers/funPickerMocks.dom.tsx';
import { FunProvider } from './FunProvider.dom.tsx';
import { packs, recentStickers } from '../../test-helpers/stickersMocks.std.ts';
import { EmojiSkinTone } from './data/emojis.std.ts';
import { Select } from '../Select.dom.tsx';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -23,24 +23,24 @@ type TemplateProps = Omit<
>;
const skinToneOptions = [
{ value: EmojiSkinTone.None, text: 'Default' },
{ value: EmojiSkinTone.Type1, text: 'Light Skin Tone' },
{ value: EmojiSkinTone.Type2, text: 'Medium-Light Skin Tone' },
{ value: EmojiSkinTone.Type3, text: 'Medium Skin Tone' },
{ value: EmojiSkinTone.Type4, text: 'Medium-Dark Skin Tone' },
{ value: EmojiSkinTone.Type5, text: 'Dark Skin Tone' },
{ value: Emoji.SkinTone.None, text: 'Default' },
{ value: Emoji.SkinTone.Type1, text: 'Light Skin Tone' },
{ value: Emoji.SkinTone.Type2, text: 'Medium-Light Skin Tone' },
{ value: Emoji.SkinTone.Type3, text: 'Medium Skin Tone' },
{ value: Emoji.SkinTone.Type4, text: 'Medium-Dark Skin Tone' },
{ value: Emoji.SkinTone.Type5, text: 'Dark Skin Tone' },
];
function Template(props: TemplateProps): JSX.Element {
const [open, setOpen] = useState(true);
const [skinTone, setSkinTone] = useState(EmojiSkinTone.None);
const [skinTone, setSkinTone] = useState(Emoji.SkinTone.None);
const handleOpenChange = useCallback((openState: boolean) => {
setOpen(openState);
}, []);
const handleSkinToneChange = useCallback((value: string) => {
setSkinTone(value as EmojiSkinTone);
setSkinTone(value as Emoji.SkinTone);
}, []);
return (

View File

@ -10,7 +10,7 @@ import { FunPanelEmojis } from './panels/FunPanelEmojis.dom.tsx';
import { useFunContext } from './FunProvider.dom.tsx';
import type { ThemeType } from '../../types/Util.std.ts';
import { FunErrorBoundary } from './base/FunErrorBoundary.dom.tsx';
import type { EmojiVariantKey } from './data/emojis.std.ts';
import type { Emoji } from '../../axo/emoji.std.ts';
export type FunEmojiPickerProps = Readonly<{
open: boolean;
@ -21,7 +21,7 @@ export type FunEmojiPickerProps = Readonly<{
showCustomizePreferredReactionsButton?: boolean;
closeOnSelect: boolean;
children: ReactNode;
messageEmojis?: ReadonlyArray<EmojiVariantKey>;
messageEmojis?: ReadonlyArray<Emoji.Variant>;
}>;
export const FunEmojiPicker = memo(function FunEmojiPicker(

View File

@ -12,7 +12,7 @@ import {
MOCK_GIFS_PAGINATED_ONE_PAGE,
MOCK_RECENT_EMOJIS,
} from '../../test-helpers/funPickerMocks.dom.tsx';
import { EmojiSkinTone } from './data/emojis.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -33,7 +33,7 @@ function Template(props: TemplateProps) {
recentStickers={recentStickers}
recentGifs={[]}
// Emojis
emojiSkinToneDefault={EmojiSkinTone.None}
emojiSkinToneDefault={Emoji.SkinTone.None}
onEmojiSkinToneDefaultChange={() => null}
onOpenCustomizePreferredReactionsModal={() => null}
onSelectEmoji={() => null}

View File

@ -62,15 +62,15 @@ export const FunPicker = memo(function FunPicker(
useEffect(() => {
const onKeyDown = createKeybindingsHandler({
'$mod+Shift+J': () => {
onChangeTab(FunPickerTabKey.Emoji);
onChangeTab(FunPickerTabKey.EmojisTab);
handleOpenChange(true);
},
'$mod+Shift+O': () => {
onChangeTab(FunPickerTabKey.Stickers);
onChangeTab(FunPickerTabKey.StickersTab);
handleOpenChange(true);
},
'$mod+Shift+G': () => {
onChangeTab(FunPickerTabKey.Gifs);
onChangeTab(FunPickerTabKey.GifsTab);
handleOpenChange(true);
},
});
@ -86,17 +86,17 @@ export const FunPicker = memo(function FunPicker(
<FunPopover placement={props.placement} theme={props.theme}>
<FunTabs value={fun.tab} onChange={fun.onChangeTab}>
<FunTabList>
<FunPickerTab id={FunPickerTabKey.Emoji}>
<FunPickerTab id={FunPickerTabKey.EmojisTab}>
{i18n('icu:FunPicker__Tab--Emojis')}
</FunPickerTab>
<FunPickerTab id={FunPickerTabKey.Stickers}>
<FunPickerTab id={FunPickerTabKey.StickersTab}>
{i18n('icu:FunPicker__Tab--Stickers')}
</FunPickerTab>
<FunPickerTab id={FunPickerTabKey.Gifs}>
<FunPickerTab id={FunPickerTabKey.GifsTab}>
{i18n('icu:FunPicker__Tab--Gifs')}
</FunPickerTab>
</FunTabList>
<FunTabPanel id={FunPickerTabKey.Emoji}>
<FunTabPanel id={FunPickerTabKey.EmojisTab}>
<FunErrorBoundary>
<FunPanelEmojis
onSelectEmoji={props.onSelectEmoji}
@ -106,7 +106,7 @@ export const FunPicker = memo(function FunPicker(
/>
</FunErrorBoundary>
</FunTabPanel>
<FunTabPanel id={FunPickerTabKey.Stickers}>
<FunTabPanel id={FunPickerTabKey.StickersTab}>
<FunErrorBoundary>
<FunPanelStickers
showTimeStickers={false}
@ -116,7 +116,7 @@ export const FunPicker = memo(function FunPicker(
/>
</FunErrorBoundary>
</FunTabPanel>
<FunTabPanel id={FunPickerTabKey.Gifs}>
<FunTabPanel id={FunPickerTabKey.GifsTab}>
<FunErrorBoundary>
<FunPanelGifs
onSelectGif={props.onSelectGif}

View File

@ -20,24 +20,23 @@ import type {
fetchGiphySearch,
fetchGiphyTrending,
} from '../../state/smart/fun/giphy.preload.ts';
import type { EmojiSkinTone, EmojiParentKey } from './data/emojis.std.ts';
import type { FunGifSelection, GifType } from './panels/FunPanelGifs.dom.tsx';
import { FunPickerTabKey } from './constants.dom.tsx';
import type { FunEmojiSelection } from './panels/FunPanelEmojis.dom.tsx';
import type { FunStickerSelection } from './panels/FunPanelStickers.dom.tsx';
import { FunEmojiLocalizationProvider } from './FunEmojiLocalizationProvider.dom.tsx';
import type { Emoji } from '../../axo/emoji.std.ts';
export type FunContextSmartProps = Readonly<{
i18n: LocalizerType;
// Recents
recentEmojis: ReadonlyArray<EmojiParentKey>;
recentEmojis: ReadonlyArray<Emoji.Parent>;
recentStickers: ReadonlyArray<StickerType>;
recentGifs: ReadonlyArray<GifType>;
// Emojis
emojiSkinToneDefault: EmojiSkinTone | null;
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
emojiSkinToneDefault: Emoji.SkinTone | null;
onEmojiSkinToneDefaultChange: (emojiSkinTone: Emoji.SkinTone) => void;
onOpenCustomizePreferredReactionsModal: () => void;
onSelectEmoji: (emojiSelection: FunEmojiSelection) => void;
@ -99,7 +98,7 @@ export const FunProvider = memo(function FunProvider(
props: FunProviderProps
): JSX.Element {
// Current Tab
const [tab, setTab] = useState<FunPickerTabKey>(FunPickerTabKey.Emoji);
const [tab, setTab] = useState<FunPickerTabKey>(FunPickerTabKey.EmojisTab);
const handleChangeTab = useCallback((key: FunPickerTabKey) => {
setTab(key);
}, []);
@ -130,44 +129,42 @@ export const FunProvider = memo(function FunProvider(
}, []);
return (
<FunEmojiLocalizationProvider i18n={props.i18n}>
<FunProviderInner
i18n={props.i18n}
// Open state
onOpenChange={handleOpenChange}
// Current Tab
tab={tab}
onChangeTab={handleChangeTab}
// Search Input
storedSearchInput={storedSearchInput}
onStoredSearchInputChange={handleStoredSearchInputChange}
shouldAutoFocus={shouldAutoFocus}
onChangeShouldAutoFocus={handleChangeShouldAutofocus}
// Recents
recentEmojis={props.recentEmojis}
recentStickers={props.recentStickers}
recentGifs={props.recentGifs}
// Emojis
emojiSkinToneDefault={props.emojiSkinToneDefault}
onEmojiSkinToneDefaultChange={props.onEmojiSkinToneDefaultChange}
onOpenCustomizePreferredReactionsModal={
props.onOpenCustomizePreferredReactionsModal
}
onSelectEmoji={props.onSelectEmoji}
// Stickers
installedStickerPacks={props.installedStickerPacks}
showStickerPickerHint={props.showStickerPickerHint}
onClearStickerPickerHint={props.onClearStickerPickerHint}
onSelectSticker={props.onSelectSticker}
// GIFs
fetchGiphyTrending={props.fetchGiphyTrending}
fetchGiphySearch={props.fetchGiphySearch}
fetchGiphyFile={props.fetchGiphyFile}
onRemoveRecentGif={props.onRemoveRecentGif}
onSelectGif={props.onSelectGif}
>
{props.children}
</FunProviderInner>
</FunEmojiLocalizationProvider>
<FunProviderInner
i18n={props.i18n}
// Open state
onOpenChange={handleOpenChange}
// Current Tab
tab={tab}
onChangeTab={handleChangeTab}
// Search Input
storedSearchInput={storedSearchInput}
onStoredSearchInputChange={handleStoredSearchInputChange}
shouldAutoFocus={shouldAutoFocus}
onChangeShouldAutoFocus={handleChangeShouldAutofocus}
// Recents
recentEmojis={props.recentEmojis}
recentStickers={props.recentStickers}
recentGifs={props.recentGifs}
// Emojis
emojiSkinToneDefault={props.emojiSkinToneDefault}
onEmojiSkinToneDefaultChange={props.onEmojiSkinToneDefaultChange}
onOpenCustomizePreferredReactionsModal={
props.onOpenCustomizePreferredReactionsModal
}
onSelectEmoji={props.onSelectEmoji}
// Stickers
installedStickerPacks={props.installedStickerPacks}
showStickerPickerHint={props.showStickerPickerHint}
onClearStickerPickerHint={props.onClearStickerPickerHint}
onSelectSticker={props.onSelectSticker}
// GIFs
fetchGiphyTrending={props.fetchGiphyTrending}
fetchGiphySearch={props.fetchGiphySearch}
fetchGiphyFile={props.fetchGiphyFile}
onRemoveRecentGif={props.onRemoveRecentGif}
onSelectGif={props.onSelectGif}
>
{props.children}
</FunProviderInner>
);
});

View File

@ -3,20 +3,16 @@
import { useCallback, useMemo, type JSX } from 'react';
import type { Selection } from 'react-aria-components';
import { ListBox, ListBoxItem } from 'react-aria-components';
import type { EmojiParentKey } from './data/emojis.std.ts';
import {
EmojiSkinTone,
getEmojiVariantByParentKeyAndSkinTone,
} from './data/emojis.std.ts';
import { strictAssert } from '../../util/assert.std.ts';
import { FunStaticEmoji } from './FunEmoji.dom.tsx';
import type { LocalizerType } from '../../types/I18N.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
export type FunSkinTonesListProps = Readonly<{
i18n: LocalizerType;
emoji: EmojiParentKey;
skinTone: EmojiSkinTone | null;
onSelectSkinTone: (skinTone: EmojiSkinTone) => void;
emoji: Emoji.Parent;
skinTone: Emoji.SkinTone | null;
onSelectSkinTone: (skinTone: Emoji.SkinTone) => void;
}>;
export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element {
@ -27,7 +23,7 @@ export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element {
strictAssert(keys !== 'all', 'Expected single selection');
strictAssert(keys.size === 1, 'Expected single selection');
const [first] = keys.values();
onSelectSkinTone(first as EmojiSkinTone);
onSelectSkinTone(first as Emoji.SkinTone);
},
[onSelectSkinTone]
);
@ -45,31 +41,31 @@ export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element {
<FunSkinTonesListItem
emoji={props.emoji}
aria-label={i18n('icu:FunSkinTones__ListItem--None')}
skinTone={EmojiSkinTone.None}
skinTone={Emoji.SkinTone.None}
/>
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type1}
skinTone={Emoji.SkinTone.Type1}
aria-label={i18n('icu:FunSkinTones__ListItem--Light')}
/>
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type2}
skinTone={Emoji.SkinTone.Type2}
aria-label={i18n('icu:FunSkinTones__ListItem--MediumLight')}
/>
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type3}
skinTone={Emoji.SkinTone.Type3}
aria-label={i18n('icu:FunSkinTones__ListItem--Medium')}
/>
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type4}
skinTone={Emoji.SkinTone.Type4}
aria-label={i18n('icu:FunSkinTones__ListItem--MediumDark')}
/>
<FunSkinTonesListItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type5}
skinTone={Emoji.SkinTone.Type5}
aria-label={i18n('icu:FunSkinTones__ListItem--Dark')}
/>
</ListBox>
@ -77,14 +73,14 @@ export function FunSkinTonesList(props: FunSkinTonesListProps): JSX.Element {
}
type FunSkinTonesListItemProps = Readonly<{
emoji: EmojiParentKey;
emoji: Emoji.Parent;
'aria-label': string;
skinTone: EmojiSkinTone;
skinTone: Emoji.SkinTone;
}>;
function FunSkinTonesListItem(props: FunSkinTonesListItemProps) {
const variant = useMemo(() => {
return getEmojiVariantByParentKeyAndSkinTone(props.emoji, props.skinTone);
return Emoji.getVariant(props.emoji, props.skinTone);
}, [props.emoji, props.skinTone]);
return (

View File

@ -9,7 +9,7 @@ import { FunStickerPicker } from './FunStickerPicker.dom.tsx';
import { MOCK_RECENT_EMOJIS } from '../../test-helpers/funPickerMocks.dom.tsx';
import { FunProvider } from './FunProvider.dom.tsx';
import { packs, recentStickers } from '../../test-helpers/stickersMocks.std.ts';
import { EmojiSkinTone } from './data/emojis.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -33,7 +33,7 @@ function Template(props: TemplateProps): JSX.Element {
recentStickers={recentStickers}
recentGifs={[]}
// Emojis
emojiSkinToneDefault={EmojiSkinTone.None}
emojiSkinToneDefault={Emoji.SkinTone.None}
onEmojiSkinToneDefaultChange={() => null}
onOpenCustomizePreferredReactionsModal={() => null}
onSelectEmoji={() => null}

View File

@ -1,13 +1,13 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { EmojiPickerCategory } from './data/emojis.std.ts';
import type { StickerPackType } from '../../state/ducks/stickers.preload.ts';
import { Emoji } from '../../axo/emoji.std.ts';
export enum FunPickerTabKey {
Emoji = 'Emoji',
Stickers = 'Stickers',
Gifs = 'Gifs',
EmojisTab = 'EmojisTab',
StickersTab = 'Stickers',
GifsTab = 'Gifs',
}
export enum FunGifsCategory {
@ -54,7 +54,7 @@ export function toFunStickersPackSection(
export type FunEmojisSection =
| FunSectionCommon
| EmojiPickerCategory
| Emoji.Category
| FunEmojisBase;
export type FunStickersSection =
| FunSectionCommon
@ -63,16 +63,16 @@ export type FunStickersSection =
export type FunGifsSection = FunSectionCommon | FunGifsCategory;
export const FunEmojisSectionOrder: ReadonlyArray<
FunSectionCommon.Recents | FunEmojisBase.ThisMessage | EmojiPickerCategory
FunSectionCommon.Recents | FunEmojisBase.ThisMessage | Emoji.Category
> = [
FunEmojisBase.ThisMessage,
FunSectionCommon.Recents,
EmojiPickerCategory.SmileysAndPeople,
EmojiPickerCategory.AnimalsAndNature,
EmojiPickerCategory.FoodAndDrink,
EmojiPickerCategory.Activities,
EmojiPickerCategory.TravelAndPlaces,
EmojiPickerCategory.Objects,
EmojiPickerCategory.Symbols,
EmojiPickerCategory.Flags,
Emoji.Category.SMILIES_AND_PEOPLE,
Emoji.Category.ANIMALS_AND_NATURE,
Emoji.Category.FOOD_AND_DRINK,
Emoji.Category.ACTIVITIES,
Emoji.Category.TRAVEL_AND_PLACES,
Emoji.Category.OBJECTS,
Emoji.Category.SYMBOLS,
Emoji.Category.FLAGS,
];

View File

@ -1,695 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import emojiRegex from 'emoji-regex';
import { strictAssert } from '../../../util/assert.std.ts';
import type {
FunEmojiSearchIndex,
FunEmojiSearchIndexEntry,
} from '../useFunEmojiSearch.dom.tsx';
import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer.dom.tsx';
import { removeDiacritics } from '../../../util/removeDiacritics.std.ts';
import { createLogger } from '../../../logging/log.std.ts';
import { EmojiSkinTone } from '../../../types/emoji.std.ts';
import RAW_EMOJI_DATA from 'emoji-datasource';
export { EmojiSkinTone } from '../../../types/emoji.std.ts';
const log = createLogger('fun/data/emojis');
/**
* Types
*/
export enum EmojiUnicodeCategory {
SmileysAndEmotion = 'EmojiUnicodeCategory.SmileysAndEmotion',
PeopleAndBody = 'EmojiUnicodeCategory.PeopleAndBody',
Component = 'EmojiUnicodeCategory.Component',
AnimalsAndNature = 'EmojiUnicodeCategory.AnimalsAndNature',
FoodAndDrink = 'EmojiUnicodeCategory.FoodAndDrink',
TravelAndPlaces = 'EmojiUnicodeCategory.TravelAndPlaces',
Activities = 'EmojiUnicodeCategory.Activities',
Objects = 'EmojiUnicodeCategory.Objects',
Symbols = 'EmojiUnicodeCategory.Symbols',
Flags = 'EmojiUnicodeCategory.Flags',
}
export enum EmojiPickerCategory {
SmileysAndPeople = 'EmojiPickerCategory.SmileysAndPeople',
AnimalsAndNature = 'EmojiPickerCategory.AnimalsAndNature',
FoodAndDrink = 'EmojiPickerCategory.FoodAndDrink',
TravelAndPlaces = 'EmojiPickerCategory.TravelAndPlaces',
Activities = 'EmojiPickerCategory.Activities',
Objects = 'EmojiPickerCategory.Objects',
Symbols = 'EmojiPickerCategory.Symbols',
Flags = 'EmojiPickerCategory.Flags',
}
export function isValidEmojiSkinTone(value: unknown): value is EmojiSkinTone {
return (
typeof value === 'string' &&
EMOJI_SKIN_TONE_ORDER.includes(value as EmojiSkinTone)
);
}
export const EMOJI_SKIN_TONE_ORDER: ReadonlyArray<EmojiSkinTone> = [
EmojiSkinTone.None,
EmojiSkinTone.Type1,
EmojiSkinTone.Type2,
EmojiSkinTone.Type3,
EmojiSkinTone.Type4,
EmojiSkinTone.Type5,
];
/** @deprecated We should use `EmojiSkinTone` everywhere */
const KEY_TO_EMOJI_SKIN_TONE = new Map<string, EmojiSkinTone>([
['1F3FB', EmojiSkinTone.Type1],
['1F3FC', EmojiSkinTone.Type2],
['1F3FD', EmojiSkinTone.Type3],
['1F3FE', EmojiSkinTone.Type4],
['1F3FF', EmojiSkinTone.Type5],
]);
export type EmojiParentKey = string & { EmojiParentKey: never };
export type EmojiVariantKey = string & { EmojiVariantKey: never };
export type EmojiParentValue = string & { EmojiParentValue: never };
export type EmojiVariantValue = string & { EmojiVariantValue: never };
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
export type EmojiEnglishShortName = string & { EmojiEnglishShortName: never };
export type EmojiVariantData = Readonly<{
key: EmojiVariantKey;
value: EmojiVariantValue;
valueNonqualified: EmojiVariantValue | null;
sheetX: number;
sheetY: number;
}>;
type EmojiDefaultSkinToneVariants = Record<EmojiSkinTone, EmojiVariantKey>;
export type EmojiParentData = Readonly<{
key: EmojiParentKey;
value: EmojiParentValue;
valueNonqualified: EmojiParentValue | null;
unicodeCategory: EmojiUnicodeCategory;
pickerCategory: EmojiPickerCategory | null;
defaultVariant: EmojiVariantKey;
defaultSkinToneVariants: EmojiDefaultSkinToneVariants | null;
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
englishShortNameDefault: EmojiEnglishShortName;
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
englishShortNames: ReadonlyArray<EmojiEnglishShortName>;
emoticonDefault: string | null;
emoticons: ReadonlyArray<string>;
}>;
/**
* Schemas
*/
const RAW_UNICODE_CATEGORY_MAP: Record<string, EmojiUnicodeCategory> = {
'Smileys & Emotion': EmojiUnicodeCategory.SmileysAndEmotion,
'People & Body': EmojiUnicodeCategory.PeopleAndBody,
Component: EmojiUnicodeCategory.Component,
'Animals & Nature': EmojiUnicodeCategory.AnimalsAndNature,
'Food & Drink': EmojiUnicodeCategory.FoodAndDrink,
'Travel & Places': EmojiUnicodeCategory.TravelAndPlaces,
Activities: EmojiUnicodeCategory.Activities,
Objects: EmojiUnicodeCategory.Objects,
Symbols: EmojiUnicodeCategory.Symbols,
Flags: EmojiUnicodeCategory.Flags,
};
const RAW_PICKER_CATEGORY_MAP: Record<string, EmojiPickerCategory | null> = {
'Smileys & Emotion': EmojiPickerCategory.SmileysAndPeople, // merged
'People & Body': EmojiPickerCategory.SmileysAndPeople, // merged
Component: null, // dropped
'Animals & Nature': EmojiPickerCategory.AnimalsAndNature,
'Food & Drink': EmojiPickerCategory.FoodAndDrink,
'Travel & Places': EmojiPickerCategory.TravelAndPlaces,
Activities: EmojiPickerCategory.Activities,
Objects: EmojiPickerCategory.Objects,
Symbols: EmojiPickerCategory.Symbols,
Flags: EmojiPickerCategory.Flags,
};
/**
* Data Normalization
*/
function toEmojiUnicodeCategory(category: string): EmojiUnicodeCategory {
const result = RAW_UNICODE_CATEGORY_MAP[category];
strictAssert(result != null, `Unknown category: ${category}`);
return result;
}
function toEmojiPickerCategory(category: string): EmojiPickerCategory | null {
const result = RAW_PICKER_CATEGORY_MAP[category];
strictAssert(
typeof result !== 'undefined',
`Unknown picker category: ${category}`
);
return result;
}
function toEmojiParentKey(unified: string): EmojiParentKey {
return unified as EmojiParentKey;
}
function toEmojiVariantKey(unified: string): EmojiVariantKey {
return unified as EmojiVariantKey;
}
function encodeUnified(unified: string): string {
return unified
.split('-')
.map(char => String.fromCodePoint(Number.parseInt(char, 16)))
.join('');
}
function toEmojiParentValue(unified: string): EmojiParentValue {
return encodeUnified(unified) as EmojiParentValue;
}
function toEmojiVariantValue(unified: string): EmojiVariantValue {
return encodeUnified(unified) as EmojiVariantValue;
}
const WOMAN = '\u{1F469}';
const MAN = '\u{1F468}';
const GIRL = '\u{1F467}';
const BOY = '\u{1F466}';
const ZWJ = '\u{200D}';
/**
* Deprecated unicode emoji should continue to be rendered when used,
* but should be hidden from emoji pickers.
*/
const UNICODE_DEPRECATED_EMOJI = new Set<EmojiParentValue>([
/**
* 2022 - Family Emoji Redesign: Gender Inclusive Variants
* https://www.unicode.org/L2/L2023/23029-family-emoji.pdf
* https://www.unicode.org/L2/L2022/22276-family-emoji-guidelines.pdf
*/
// 1 ADULT, 1 CHILD
`${WOMAN}${ZWJ}${GIRL}`,
`${WOMAN}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${GIRL}`,
`${MAN}${ZWJ}${BOY}`,
// 1 ADULT, 2 CHILDREN
`${WOMAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
`${WOMAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
`${WOMAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
`${MAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
// 2 ADULTS, 1 CHILD
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}`,
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}`,
`${MAN}${ZWJ}${WOMAN}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${MAN}${ZWJ}${GIRL}`,
`${MAN}${ZWJ}${MAN}${ZWJ}${BOY}`,
// 2 ADULTS, 2 CHILDREN
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
`${MAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${WOMAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${MAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
`${MAN}${ZWJ}${MAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
`${MAN}${ZWJ}${MAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
] as Array<EmojiParentValue>);
/** @internal */
type EmojiIndex = Readonly<{
// raw data
parentByKey: Map<EmojiParentKey, EmojiParentData>;
parentKeysByName: Map<EmojiEnglishShortName, EmojiParentKey>;
parentKeysByValue: Map<EmojiParentValue, EmojiParentKey>;
parentKeysByValueNonQualified: Map<EmojiParentValue, EmojiParentKey>;
parentKeysByVariantKeys: Map<EmojiVariantKey, EmojiParentKey>;
variantByKey: Map<EmojiVariantKey, EmojiVariantData>;
variantKeysByValue: Map<EmojiVariantValue, EmojiVariantKey>;
variantKeysByValueNonQualified: Map<EmojiVariantValue, EmojiVariantKey>;
variantKeyToSkinTone: Map<EmojiVariantKey, EmojiSkinTone>;
unicodeCategories: Record<EmojiUnicodeCategory, Array<EmojiParentKey>>;
pickerCategories: Record<EmojiPickerCategory, Array<EmojiParentKey>>;
defaultEnglishSearchIndex: Array<FunEmojiSearchIndexEntry>;
defaultEnglishLocalizerIndex: {
parentKeyToLocaleShortName: Map<EmojiParentKey, string>;
localeShortNameToParentKey: Map<string, EmojiParentKey>;
};
}>;
/** @internal */
const EMOJI_INDEX: EmojiIndex = {
parentByKey: new Map(),
parentKeysByValue: new Map(),
parentKeysByValueNonQualified: new Map(),
parentKeysByName: new Map(),
parentKeysByVariantKeys: new Map(),
variantByKey: new Map(),
variantKeysByValue: new Map(),
variantKeysByValueNonQualified: new Map(),
variantKeyToSkinTone: new Map(),
unicodeCategories: {
[EmojiUnicodeCategory.SmileysAndEmotion]: [],
[EmojiUnicodeCategory.PeopleAndBody]: [],
[EmojiUnicodeCategory.Component]: [],
[EmojiUnicodeCategory.AnimalsAndNature]: [],
[EmojiUnicodeCategory.FoodAndDrink]: [],
[EmojiUnicodeCategory.TravelAndPlaces]: [],
[EmojiUnicodeCategory.Activities]: [],
[EmojiUnicodeCategory.Objects]: [],
[EmojiUnicodeCategory.Symbols]: [],
[EmojiUnicodeCategory.Flags]: [],
},
pickerCategories: {
[EmojiPickerCategory.SmileysAndPeople]: [],
[EmojiPickerCategory.AnimalsAndNature]: [],
[EmojiPickerCategory.FoodAndDrink]: [],
[EmojiPickerCategory.TravelAndPlaces]: [],
[EmojiPickerCategory.Activities]: [],
[EmojiPickerCategory.Objects]: [],
[EmojiPickerCategory.Symbols]: [],
[EmojiPickerCategory.Flags]: [],
},
defaultEnglishSearchIndex: [],
defaultEnglishLocalizerIndex: {
parentKeyToLocaleShortName: new Map(),
localeShortNameToParentKey: new Map(),
},
};
function addParent(parent: EmojiParentData, rank: number) {
const isDeprecated = UNICODE_DEPRECATED_EMOJI.has(parent.value);
EMOJI_INDEX.parentByKey.set(parent.key, parent);
EMOJI_INDEX.parentKeysByValue.set(parent.value, parent.key);
if (parent.valueNonqualified != null) {
EMOJI_INDEX.parentKeysByValue.set(parent.valueNonqualified, parent.key);
EMOJI_INDEX.parentKeysByValueNonQualified.set(
parent.valueNonqualified,
parent.key
);
}
EMOJI_INDEX.parentKeysByName.set(parent.englishShortNameDefault, parent.key);
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
if (parent.pickerCategory != null && !isDeprecated) {
EMOJI_INDEX.pickerCategories[parent.pickerCategory].push(parent.key);
}
for (const englishShortName of parent.englishShortNames) {
EMOJI_INDEX.parentKeysByName.set(englishShortName, parent.key);
}
if (!isDeprecated) {
EMOJI_INDEX.defaultEnglishSearchIndex.push({
key: parent.key,
rank,
shortName: parent.englishShortNameDefault,
shortNames: parent.englishShortNames,
emoticon: parent.emoticonDefault,
emoticons: parent.emoticons,
});
}
EMOJI_INDEX.defaultEnglishLocalizerIndex.parentKeyToLocaleShortName.set(
parent.key,
parent.englishShortNameDefault
);
EMOJI_INDEX.defaultEnglishLocalizerIndex.localeShortNameToParentKey.set(
parent.englishShortNameDefault,
parent.key
);
}
function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
EMOJI_INDEX.parentKeysByVariantKeys.set(variant.key, parentKey);
EMOJI_INDEX.variantByKey.set(variant.key, variant);
EMOJI_INDEX.variantKeysByValue.set(variant.value, variant.key);
if (variant.valueNonqualified) {
EMOJI_INDEX.variantKeysByValue.set(variant.valueNonqualified, variant.key);
EMOJI_INDEX.variantKeysByValueNonQualified.set(
variant.valueNonqualified,
variant.key
);
}
}
const SORTED_EMOJI_DATA = RAW_EMOJI_DATA.toSorted((a, b) => {
return a.sort_order - b.sort_order;
});
for (const rawEmoji of SORTED_EMOJI_DATA) {
if (!rawEmoji.has_img_apple) {
continue;
}
const parentKey = toEmojiParentKey(rawEmoji.unified);
const defaultVariant: EmojiVariantData = {
key: toEmojiVariantKey(rawEmoji.unified),
value: toEmojiVariantValue(rawEmoji.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiVariantValue(rawEmoji.non_qualified)
: null,
sheetX: rawEmoji.sheet_x,
sheetY: rawEmoji.sheet_y,
};
addVariant(parentKey, defaultVariant);
let defaultSkinToneVariants: EmojiDefaultSkinToneVariants | null = null;
if (rawEmoji.skin_variations != null) {
const map = new Map<string, EmojiVariantKey>();
for (const [key, value] of Object.entries(rawEmoji.skin_variations)) {
if (!value.has_img_apple) {
continue;
}
const variantKey = toEmojiVariantKey(value.unified);
map.set(key, variantKey);
const skinToneVariant: EmojiVariantData = {
key: variantKey,
value: toEmojiVariantValue(value.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiVariantValue(rawEmoji.non_qualified)
: null,
sheetX: value.sheet_x,
sheetY: value.sheet_y,
};
addVariant(parentKey, skinToneVariant);
}
const result: Partial<EmojiDefaultSkinToneVariants> = {};
for (const [key, skinTone] of KEY_TO_EMOJI_SKIN_TONE) {
const one = map.get(key) ?? null;
const two = map.get(`${key}-${key}`) ?? null;
const variantKey = one ?? two;
if (variantKey == null) {
const keys = Object.keys(rawEmoji.skin_variations);
throw new Error(
`Missing variant key ${parentKey} -> ${key} (${keys.join(', ')})`
);
}
result[skinTone] = variantKey;
EMOJI_INDEX.variantKeyToSkinTone.set(variantKey, skinTone);
}
defaultSkinToneVariants = result as EmojiDefaultSkinToneVariants;
}
const parent: EmojiParentData = {
key: toEmojiParentKey(rawEmoji.unified),
value: toEmojiParentValue(rawEmoji.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiParentValue(rawEmoji.non_qualified)
: null,
unicodeCategory: toEmojiUnicodeCategory(rawEmoji.category),
pickerCategory: toEmojiPickerCategory(rawEmoji.category),
defaultVariant: defaultVariant.key,
defaultSkinToneVariants,
englishShortNameDefault: rawEmoji.short_name as EmojiEnglishShortName,
englishShortNames: rawEmoji.short_names as Array<EmojiEnglishShortName>,
emoticonDefault: rawEmoji.text ?? null,
emoticons: rawEmoji.texts ?? [],
};
addParent(parent, rawEmoji.sort_order);
}
export function getEmojiDebugLabel(input: string): string {
return Array.from(input.slice(0, 12), char => {
const num = char.codePointAt(0) ?? 0;
const hex = num.toString(16).toUpperCase().padStart(4, '0');
return `U+${hex}`;
}).join(' ');
}
export function isEmojiParentValueDeprecated(input: EmojiParentValue): boolean {
return UNICODE_DEPRECATED_EMOJI.has(input);
}
export function isEmojiVariantKey(input: string): input is EmojiVariantKey {
return EMOJI_INDEX.variantByKey.has(input as EmojiVariantKey);
}
export function isEmojiParentValue(input: string): input is EmojiParentValue {
return EMOJI_INDEX.parentKeysByValue.has(input as EmojiParentValue);
}
export function isEmojiVariantValue(input: string): input is EmojiVariantValue {
return EMOJI_INDEX.variantKeysByValue.has(input as EmojiVariantValue);
}
export function isEmojiVariantValueNonQualified(
input: EmojiVariantValue
): boolean {
return EMOJI_INDEX.variantKeysByValueNonQualified.has(input);
}
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
export function isEmojiEnglishShortName(
input: string
): input is EmojiEnglishShortName {
return EMOJI_INDEX.parentKeysByName.has(input as EmojiEnglishShortName);
}
export function getEmojiParentByKey(key: EmojiParentKey): EmojiParentData {
const data = EMOJI_INDEX.parentByKey.get(key);
strictAssert(data, `Missing emoji parent data for key "${key}"`);
return data;
}
export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
const data = EMOJI_INDEX.variantByKey.get(key);
strictAssert(data, `Missing emoji variant data for key "${key}"`);
return data;
}
export function getEmojiParentKeyByValue(
value: EmojiParentValue
): EmojiParentKey {
const key = EMOJI_INDEX.parentKeysByValue.get(value);
strictAssert(key, `Missing emoji parent key for value "${value}"`);
return key;
}
export function getEmojiVariantKeyByValue(
value: EmojiVariantValue
): EmojiVariantKey {
const key = EMOJI_INDEX.variantKeysByValue.get(value);
strictAssert(key, `Missing emoji variant key for value "${value}"`);
return key;
}
export function getEmojiParentKeyByVariantKey(
key: EmojiVariantKey
): EmojiParentKey {
const parentKey = EMOJI_INDEX.parentKeysByVariantKeys.get(key);
strictAssert(parentKey, `Missing parent key for variant key "${key}"`);
return parentKey;
}
export function getEmojiPickerCategoryParentKeys(
category: EmojiPickerCategory
): ReadonlyArray<EmojiParentKey> {
const parents = EMOJI_INDEX.pickerCategories[category];
strictAssert(parents, `Missing category emojis for ${category}`);
return parents;
}
/**
* Apply a skin tone (if possible) to any parent key.
*/
export function getEmojiVariantKeyByParentKeyAndSkinTone(
key: EmojiParentKey,
skinTone: EmojiSkinTone
): EmojiVariantKey {
const parent = getEmojiParentByKey(key);
const skinToneVariants = parent.defaultSkinToneVariants;
if (skinTone === EmojiSkinTone.None || skinToneVariants == null) {
return parent.defaultVariant;
}
const variantKey = skinToneVariants[skinTone];
strictAssert(variantKey, `Missing skin tone variant for ${skinTone}`);
return variantKey;
}
export function getEmojiVariantByParentKeyAndSkinTone(
key: EmojiParentKey,
skinTone: EmojiSkinTone
): EmojiVariantData {
return getEmojiVariantByKey(
getEmojiVariantKeyByParentKeyAndSkinTone(key, skinTone)
);
}
/** @deprecated */
export function getEmojiParentKeyByEnglishShortName(
englishShortName: EmojiEnglishShortName
): EmojiParentKey {
const emojiKey = EMOJI_INDEX.parentKeysByName.get(englishShortName);
strictAssert(emojiKey, `Missing emoji info for ${englishShortName}`);
return emojiKey;
}
export function getEmojiDefaultEnglishSearchIndex(): FunEmojiSearchIndex {
return EMOJI_INDEX.defaultEnglishSearchIndex;
}
export function getEmojiDefaultEnglishLocalizerIndex(): FunEmojiLocalizerIndex {
return EMOJI_INDEX.defaultEnglishLocalizerIndex;
}
/** @testexport */
export function _getAllEmojiVariantKeys(): Iterable<EmojiVariantKey> {
return EMOJI_INDEX.variantByKey.keys();
}
function emojiParentKeyConstant(input: string): EmojiParentKey {
strictAssert(
isEmojiParentValue(input),
`Missing emoji parent for value "${input}"`
);
return getEmojiParentKeyByValue(input);
}
function emojiVariantKeyConstant(input: string): EmojiVariantKey {
strictAssert(
isEmojiVariantValue(input),
`Missing emoji variant for value "${input}"`
);
return getEmojiVariantKeyByValue(input);
}
export const EMOJI_PARENT_KEY_CONSTANTS = {
RED_HEART: emojiParentKeyConstant('\u{2764}\u{FE0F}'),
CRYING_FACE: emojiParentKeyConstant('\u{1F622}'),
FACE_WITH_TEARS_OF_JOY: emojiParentKeyConstant('\u{1F602}'),
FACE_WITH_OPEN_MOUTH: emojiParentKeyConstant('\u{1F62E}'),
ENRAGED_FACE: emojiParentKeyConstant('\u{1F621}'),
SLIGHTLY_SMILING_FACE: emojiParentKeyConstant('\u{1F642}'),
SLIGHTLY_FROWNING_FACE: emojiParentKeyConstant('\u{1F641}'),
GRINNING_FACE: emojiParentKeyConstant('\u{1F600}'),
FACE_BLOWING_A_KISS: emojiParentKeyConstant('\u{1F618}'),
FACE_WITH_STUCK_OUT_TONGUE: emojiParentKeyConstant('\u{1F61B}'),
CONFUSED_FACE: emojiParentKeyConstant('\u{1F615}'),
NEUTRAL_FACE: emojiParentKeyConstant('\u{1F610}'),
WINKING_FACE: emojiParentKeyConstant('\u{1F609}'),
ZIPPER_MOUTH_FACE: emojiParentKeyConstant('\u{1F910}'),
THUMBS_UP: emojiParentKeyConstant('\u{1F44D}'),
THUMBS_DOWN: emojiParentKeyConstant('\u{1F44E}'),
RAISED_HAND: emojiParentKeyConstant('\u{270B}'),
WAVING_HAND: emojiParentKeyConstant('\u{1F44B}'),
HOT_BEVERAGE: emojiParentKeyConstant('\u{2615}'),
MOBILE_PHONE_OFF: emojiParentKeyConstant('\u{1F4F4}'),
} as const;
export const EMOJI_VARIANT_KEY_CONSTANTS = {
SLIGHTLY_FROWNING_FACE: emojiVariantKeyConstant('\u{1F641}'),
GRINNING_FACE_WITH_SMILING_EYES: emojiVariantKeyConstant('\u{1F604}'),
GRINNING_CAT_WITH_SMILING_EYES: emojiVariantKeyConstant('\u{1F638}'),
FRIED_SHRIMP: emojiVariantKeyConstant('\u{1F364}'),
} as const;
/**
* Completions
*/
/** For displaying in the ui */
export function normalizeShortNameCompletionDisplay(shortName: string): string {
return shortName
.normalize('NFD')
.replaceAll(/[\s,]+/gi, '_')
.toLowerCase();
}
/** For matching in search utils */
export function normalizeShortNameCompletionQuery(query: string): string {
return removeDiacritics(query)
.normalize('NFD')
.replaceAll(/(?<!^)[\s,_-]+/gi, ' ')
.toLowerCase();
}
/**
* Emojify
*/
export function isSafeEmojifyEmoji(value: string): value is EmojiVariantValue {
return isEmojiVariantValue(value) && !isEmojiVariantValueNonQualified(value);
}
export type EmojifyData = Readonly<{
text: string;
emojiCount: number;
isEmojiOnlyText: boolean;
}>;
export function getEmojifyData(input: string): EmojifyData {
// Fast path, and treat empty strings like they have non-emoji text
if (input === '' || input.trim() === '') {
return { text: input, emojiCount: 0, isEmojiOnlyText: false };
}
let hasEmojis = false;
let hasNonEmojis = false;
let emojiCount = 0;
const regex = emojiRegex();
let match = regex.exec(input);
let lastIndex = 0;
while (match) {
const value = match[0];
// Only consider safe emojis as matches
if (!isSafeEmojifyEmoji(value)) {
log.warn(
`Expected a valid emoji variant value, got ${getEmojiDebugLabel(value)}`
);
} else {
const { index } = match;
hasEmojis = true;
// Track if we skipped over any text
if (index > lastIndex) {
hasNonEmojis = true;
lastIndex += index;
}
emojiCount += 1;
// Needs to be the value.length not the match.length
lastIndex = index + value.length;
}
match = regex.exec(input);
}
// Track if we had any remaining text
if (lastIndex === 0 || lastIndex < input.length) {
hasNonEmojis = true;
}
return {
text: input,
emojiCount,
isEmojiOnlyText: hasEmojis && !hasNonEmojis,
};
}

View File

@ -48,21 +48,6 @@ import {
FunSubNavListBox,
FunSubNavListBoxItem,
} from '../base/FunSubNav.dom.tsx';
import type { EmojiVariantKey } from '../data/emojis.std.ts';
import {
EmojiSkinTone,
EmojiPickerCategory,
getEmojiParentByKey,
getEmojiPickerCategoryParentKeys,
getEmojiVariantByParentKeyAndSkinTone,
normalizeShortNameCompletionDisplay,
isEmojiVariantKey,
getEmojiParentKeyByVariantKey,
getEmojiVariantByKey,
EMOJI_PARENT_KEY_CONSTANTS,
EMOJI_VARIANT_KEY_CONSTANTS,
} from '../data/emojis.std.ts';
import { useFunEmojiSearch } from '../useFunEmojiSearch.dom.tsx';
import { FunKeyboard } from '../keyboard/FunKeyboard.dom.tsx';
import type { GridKeyboardState } from '../keyboard/GridKeyboardDelegate.dom.tsx';
import { GridKeyboardDelegate } from '../keyboard/GridKeyboardDelegate.dom.tsx';
@ -76,8 +61,8 @@ import { FunSkinTonesList } from '../FunSkinTones.dom.tsx';
import { FunStaticEmoji } from '../FunEmoji.dom.tsx';
import { useFunContext } from '../FunProvider.dom.tsx';
import { FunResults, FunResultsHeader } from '../base/FunResults.dom.tsx';
import { useFunEmojiLocalizer } from '../useFunEmojiLocalizer.dom.tsx';
import { FunTooltip } from '../base/FunTooltip.dom.tsx';
import { Emoji } from '../../../axo/emoji.std.ts';
function getTitleForSection(
i18n: LocalizerType,
@ -92,28 +77,28 @@ function getTitleForSection(
if (section === FunEmojisBase.ThisMessage) {
return i18n('icu:FunPanelEmojis__SectionTitle--ThisMessage');
}
if (section === EmojiPickerCategory.SmileysAndPeople) {
if (section === Emoji.Category.SMILIES_AND_PEOPLE) {
return i18n('icu:FunPanelEmojis__SectionTitle--SmileysAndPeople');
}
if (section === EmojiPickerCategory.AnimalsAndNature) {
if (section === Emoji.Category.ANIMALS_AND_NATURE) {
return i18n('icu:FunPanelEmojis__SectionTitle--AnimalsAndNature');
}
if (section === EmojiPickerCategory.FoodAndDrink) {
if (section === Emoji.Category.FOOD_AND_DRINK) {
return i18n('icu:FunPanelEmojis__SectionTitle--FoodAndDrink');
}
if (section === EmojiPickerCategory.TravelAndPlaces) {
if (section === Emoji.Category.TRAVEL_AND_PLACES) {
return i18n('icu:FunPanelEmojis__SectionTitle--TravelAndPlaces');
}
if (section === EmojiPickerCategory.Activities) {
if (section === Emoji.Category.ACTIVITIES) {
return i18n('icu:FunPanelEmojis__SectionTitle--Activities');
}
if (section === EmojiPickerCategory.Objects) {
if (section === Emoji.Category.OBJECTS) {
return i18n('icu:FunPanelEmojis__SectionTitle--Objects');
}
if (section === EmojiPickerCategory.Symbols) {
if (section === Emoji.Category.SYMBOLS) {
return i18n('icu:FunPanelEmojis__SectionTitle--Symbols');
}
if (section === EmojiPickerCategory.Flags) {
if (section === Emoji.Category.FLAGS) {
return i18n('icu:FunPanelEmojis__SectionTitle--Flags');
}
throw missingCaseError(section);
@ -129,7 +114,7 @@ const EMOJI_GRID_ROW_SIZE = EMOJI_GRID_CELL_HEIGHT;
function toGridSectionNode(
section: FunEmojisSection,
emojiKeys: ReadonlyArray<EmojiVariantKey>
emojis: ReadonlyArray<Emoji.Variant>
): GridSectionNode {
return {
id: section,
@ -137,10 +122,10 @@ function toGridSectionNode(
header: {
key: `header-${section}`,
},
cells: emojiKeys.map(emojiKey => {
cells: emojis.map(emoji => {
return {
key: `cell-${section}-${emojiKey}`,
value: emojiKey,
key: `cell-${section}-${emoji}`,
value: emoji,
};
}),
};
@ -157,7 +142,7 @@ function getSelectedSection(
return FunSectionCommon.Recents;
}
return EmojiPickerCategory.SmileysAndPeople;
return Emoji.Category.SMILIES_AND_PEOPLE;
}
function isKeyboardPointerEvent(event: PointerEvent): boolean {
@ -167,7 +152,7 @@ function isKeyboardPointerEvent(event: PointerEvent): boolean {
}
export type FunEmojiSelection = Readonly<{
variantKey: EmojiVariantKey;
emoji: Emoji.Variant;
}>;
export type FunPanelEmojisProps = Readonly<{
@ -175,7 +160,7 @@ export type FunPanelEmojisProps = Readonly<{
onClose: () => void;
showCustomizePreferredReactionsButton: boolean;
closeOnSelect: boolean;
messageEmojis?: ReadonlyArray<EmojiVariantKey>;
messageEmojis?: ReadonlyArray<Emoji.Variant>;
}>;
export function FunPanelEmojis({
@ -213,20 +198,15 @@ export function FunPanelEmojis({
return getSelectedSection(hasSearchQuery, hasRecentEmojis);
});
const searchEmojis = useFunEmojiSearch();
const sections = useMemo(() => {
const skinTone = fun.emojiSkinToneDefault ?? EmojiSkinTone.None;
const skinTone = fun.emojiSkinToneDefault ?? Emoji.SkinTone.None;
if (searchQuery !== '') {
return [
toGridSectionNode(
FunSectionCommon.SearchResults,
searchEmojis(searchQuery).map(result => {
return getEmojiVariantByParentKeyAndSkinTone(
result.parentKey,
skinTone
).key;
Emoji.search(searchQuery).map(result => {
return Emoji.getVariant(result, skinTone);
})
),
];
@ -248,37 +228,27 @@ export function FunPanelEmojis({
result.push(
toGridSectionNode(
FunSectionCommon.Recents,
recentEmojis.map(parentKey => {
return getEmojiVariantByParentKeyAndSkinTone(
parentKey,
skinTone
).key;
recentEmojis.map(parent => {
return Emoji.getVariant(parent, skinTone);
})
)
);
}
continue;
}
const emojiKeys = getEmojiPickerCategoryParentKeys(section);
const emojis = Emoji.getCategoryParents(section);
result.push(
toGridSectionNode(
section,
emojiKeys.map(parentKey => {
return getEmojiVariantByParentKeyAndSkinTone(parentKey, skinTone)
.key;
emojis.map(parent => {
return Emoji.getVariant(parent, skinTone);
})
)
);
}
return result;
}, [
fun.emojiSkinToneDefault,
searchQuery,
searchEmojis,
messageEmojis,
recentEmojis,
]);
}, [fun.emojiSkinToneDefault, searchQuery, messageEmojis, recentEmojis]);
const [virtualizer, layout] = useFunVirtualGrid({
scrollerRef,
@ -406,7 +376,7 @@ export function FunPanelEmojis({
</FunSubNavListBoxItem>
)}
<FunSubNavListBoxItem
id={EmojiPickerCategory.SmileysAndPeople}
id={Emoji.Category.SMILIES_AND_PEOPLE}
label={i18n(
'icu:FunPanelEmojis__SubNavCategoryLabel--SmileysAndPeople'
)}
@ -414,7 +384,7 @@ export function FunPanelEmojis({
<FunSubNavIcon iconClassName="FunSubNav__Icon--SmileysAndPeople" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={EmojiPickerCategory.AnimalsAndNature}
id={Emoji.Category.ANIMALS_AND_NATURE}
label={i18n(
'icu:FunPanelEmojis__SubNavCategoryLabel--AnimalsAndNature'
)}
@ -422,7 +392,7 @@ export function FunPanelEmojis({
<FunSubNavIcon iconClassName="FunSubNav__Icon--AnimalsAndNature" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={EmojiPickerCategory.FoodAndDrink}
id={Emoji.Category.FOOD_AND_DRINK}
label={i18n(
'icu:FunPanelEmojis__SubNavCategoryLabel--FoodAndDrink'
)}
@ -430,7 +400,7 @@ export function FunPanelEmojis({
<FunSubNavIcon iconClassName="FunSubNav__Icon--FoodAndDrink" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={EmojiPickerCategory.Activities}
id={Emoji.Category.ACTIVITIES}
label={i18n(
'icu:FunPanelEmojis__SubNavCategoryLabel--Activities'
)}
@ -438,7 +408,7 @@ export function FunPanelEmojis({
<FunSubNavIcon iconClassName="FunSubNav__Icon--Activities" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={EmojiPickerCategory.TravelAndPlaces}
id={Emoji.Category.TRAVEL_AND_PLACES}
label={i18n(
'icu:FunPanelEmojis__SubNavCategoryLabel--TravelAndPlaces'
)}
@ -446,19 +416,19 @@ export function FunPanelEmojis({
<FunSubNavIcon iconClassName="FunSubNav__Icon--TravelAndPlaces" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={EmojiPickerCategory.Objects}
id={Emoji.Category.OBJECTS}
label={i18n('icu:FunPanelEmojis__SubNavCategoryLabel--Objects')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Objects" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={EmojiPickerCategory.Symbols}
id={Emoji.Category.SYMBOLS}
label={i18n('icu:FunPanelEmojis__SubNavCategoryLabel--Symbols')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Symbols" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={EmojiPickerCategory.Flags}
id={Emoji.Category.FLAGS}
label={i18n('icu:FunPanelEmojis__SubNavCategoryLabel--Flags')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Flags" />
@ -481,9 +451,7 @@ export function FunPanelEmojis({
<FunStaticEmoji
size={16}
role="presentation"
emoji={getEmojiVariantByKey(
EMOJI_VARIANT_KEY_CONSTANTS.SLIGHTLY_FROWNING_FACE
)}
emoji={Emoji.SLIGHTLY_FROWNING_FACE}
/>
</FunResultsHeader>
</FunResults>
@ -519,8 +487,7 @@ export function FunPanelEmojis({
section.id as FunEmojisSection
)}
</FunGridHeaderText>
{section.id ===
EmojiPickerCategory.SmileysAndPeople && (
{section.id === Emoji.Category.SMILIES_AND_PEOPLE && (
<SectionSkinToneHeaderPopover
i18n={i18n}
open={skinTonePopoverOpen}
@ -573,12 +540,12 @@ type RowProps = Readonly<{
rowIndex: number;
cells: ReadonlyArray<CellLayoutNode>;
focusedCellKey: CellKey | null;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
onSelectEmoji: (
emojiSelection: FunEmojiSelection,
shouldClose: boolean
) => void;
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
onEmojiSkinToneDefaultChange: (emojiSkinTone: Emoji.SkinTone) => void;
}>;
const Row = memo(function Row(props: RowProps): JSX.Element {
@ -593,7 +560,7 @@ const Row = memo(function Row(props: RowProps): JSX.Element {
<Cell
key={cell.key}
i18n={props.i18n}
value={cell.value}
emoji={Emoji.unsafeCastMaybeInvalidStringToVariant(cell.value)}
cellKey={cell.key}
rowIndex={cell.rowIndex}
colIndex={cell.colIndex}
@ -610,27 +577,27 @@ const Row = memo(function Row(props: RowProps): JSX.Element {
type CellProps = Readonly<{
i18n: LocalizerType;
value: string;
emoji: Emoji.Variant;
cellKey: CellKey;
colIndex: number;
rowIndex: number;
isTabbable: boolean;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
onSelectEmoji: (
emojiSelection: FunEmojiSelection,
shouldClose: boolean
) => void;
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
onEmojiSkinToneDefaultChange: (emojiSkinTone: Emoji.SkinTone) => void;
}>;
const Cell = memo(function Cell(props: CellProps): JSX.Element {
const {
i18n,
emoji,
emojiSkinToneDefault,
onSelectEmoji,
onEmojiSkinToneDefaultChange,
} = props;
const emojiLocalizer = useFunEmojiLocalizer();
const popoverTriggerRef = useRef<HTMLButtonElement>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
@ -639,26 +606,12 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
}, []);
const emojiParent = useMemo(() => {
const isVariantKey = isEmojiVariantKey(props.value);
strictAssert(isVariantKey, 'Cell value is not a variant key');
const parentKey = getEmojiParentKeyByVariantKey(props.value);
return getEmojiParentByKey(parentKey);
}, [props.value]);
return Emoji.getParent(emoji);
}, [emoji]);
const emojiHasSkinToneVariants = useMemo(() => {
return emojiParent.defaultSkinToneVariants != null;
}, [emojiParent.defaultSkinToneVariants]);
const emojiVariant = useMemo(() => {
const isVariantKey = isEmojiVariantKey(props.value);
strictAssert(isVariantKey, 'Cell value is not a variant key');
return getEmojiVariantByKey(props.value);
}, [props.value]);
return Emoji.hasSkinToneVariants(emojiParent);
}, [emojiParent]);
const handleClick = useCallback(
(event: PointerEvent) => {
@ -667,18 +620,13 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
return;
}
const emojiSelection: FunEmojiSelection = {
variantKey: emojiVariant.key,
emoji,
};
const shouldClose =
isKeyboardPointerEvent(event) && !(event.ctrlKey || event.metaKey);
onSelectEmoji(emojiSelection, shouldClose);
},
[
emojiHasSkinToneVariants,
emojiSkinToneDefault,
emojiVariant.key,
onSelectEmoji,
]
[emojiHasSkinToneVariants, emojiSkinToneDefault, emoji, onSelectEmoji]
);
const handleLongPress = useCallback(() => {
@ -699,28 +647,25 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
);
const handleSelectSkinTone = useCallback(
(skinToneSelection: EmojiSkinTone) => {
const variant = getEmojiVariantByParentKeyAndSkinTone(
emojiParent.key,
skinToneSelection
);
(skinToneSelection: Emoji.SkinTone) => {
const variant = Emoji.getVariant(emojiParent, skinToneSelection);
onEmojiSkinToneDefaultChange(skinToneSelection);
const emojiSelection: FunEmojiSelection = {
variantKey: variant.key,
emoji: variant,
};
const shouldClose = true;
onSelectEmoji(emojiSelection, shouldClose);
},
[onEmojiSkinToneDefaultChange, emojiParent.key, onSelectEmoji]
[onEmojiSkinToneDefaultChange, emojiParent, onSelectEmoji]
);
const emojiName = useMemo(() => {
return emojiLocalizer.getLocaleShortName(emojiVariant.key);
}, [emojiVariant.key, emojiLocalizer]);
const emojiDisplayLabel = useMemo(() => {
return Emoji.getDisplayLabel(emoji);
}, [emoji]);
const emojiShortNameDisplay = useMemo(() => {
return normalizeShortNameCompletionDisplay(emojiName);
}, [emojiName]);
const emojiCompletionLabel = useMemo(() => {
return Emoji.getCompletionLabel(emoji);
}, [emoji]);
return (
<FunGridCell
@ -730,7 +675,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
>
<FunTooltip
side="top"
content={`:${emojiShortNameDisplay}:`}
content={`:${emojiCompletionLabel}:`}
collisionBoundarySelector=".FunScroller__Viewport"
collisionPadding={6}
// `skipDelayDuration=0` doesn't work with `disableHoverableContent`
@ -740,7 +685,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
<FunItemButton
ref={popoverTriggerRef}
excludeFromTabOrder={!props.isTabbable}
aria-label={emojiName}
aria-label={emojiDisplayLabel}
onClick={handleClick}
onLongPress={handleLongPress}
onContextMenu={handleContextMenu}
@ -748,7 +693,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
'icu:FunPanelEmojis__SkinTonePicker__LongPressAccessibilityDescription'
)}
>
<FunStaticEmoji role="presentation" size={32} emoji={emojiVariant} />
<FunStaticEmoji role="presentation" size={32} emoji={emoji} />
</FunItemButton>
</FunTooltip>
{emojiHasSkinToneVariants && (
@ -771,13 +716,13 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
<Heading slot="title">
{i18n(
'icu:FunPanelEmojis__SkinTonePicker__SelectSkinToneForSelectedEmoji',
{ emojiName }
{ emojiName: emojiDisplayLabel }
)}
</Heading>
</VisuallyHidden>
<FunSkinTonesList
i18n={i18n}
emoji={emojiParent.key}
emoji={emojiParent}
skinTone={null}
onSelectSkinTone={handleSelectSkinTone}
/>
@ -792,7 +737,7 @@ type SectionSkinToneHeaderPopoverProps = Readonly<{
i18n: LocalizerType;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectSkinTone: (emojiSkinTone: EmojiSkinTone) => void;
onSelectSkinTone: (emojiSkinTone: Emoji.SkinTone) => void;
}>;
function SectionSkinToneHeaderPopover(
@ -801,7 +746,7 @@ function SectionSkinToneHeaderPopover(
const { i18n, onOpenChange, onSelectSkinTone } = props;
const handleSelectSkinTone = useCallback(
(emojiSkinTone: EmojiSkinTone) => {
(emojiSkinTone: Emoji.SkinTone) => {
onSelectSkinTone(emojiSkinTone);
onOpenChange(false);
},
@ -821,7 +766,7 @@ function SectionSkinToneHeaderPopover(
</FunGridHeaderPopoverHeader>
<FunSkinTonesList
i18n={i18n}
emoji={EMOJI_PARENT_KEY_CONSTANTS.RAISED_HAND}
emoji={Emoji.HAND}
skinTone={null}
onSelectSkinTone={handleSelectSkinTone}
/>

View File

@ -64,10 +64,7 @@ import type { LocalizerType } from '../../../types/I18N.std.ts';
import { isAbortError } from '../../../util/isAbortError.std.ts';
import { createLogger } from '../../../logging/log.std.ts';
import * as Errors from '../../../types/errors.std.ts';
import {
EMOJI_VARIANT_KEY_CONSTANTS,
getEmojiVariantByKey,
} from '../data/emojis.std.ts';
import { Emoji } from '../../../axo/emoji.std.ts';
import type { fetchGiphyFile } from '../../../state/smart/fun/giphy.preload.ts';
import {
getGifCdnUrlOrigin,
@ -561,9 +558,7 @@ export function FunPanelGifs({
<FunStaticEmoji
size={16}
role="presentation"
emoji={getEmojiVariantByKey(
EMOJI_VARIANT_KEY_CONSTANTS.SLIGHTLY_FROWNING_FACE
)}
emoji={Emoji.SLIGHTLY_FROWNING_FACE}
/>
</FunResultsHeader>
)}

View File

@ -48,13 +48,6 @@ import {
FunSubNavListBoxItem,
FunSubNavScroller,
} from '../base/FunSubNav.dom.tsx';
import {
EMOJI_VARIANT_KEY_CONSTANTS,
type EmojiParentKey,
getEmojiParentKeyByValue,
getEmojiVariantByKey,
isEmojiParentValue,
} from '../data/emojis.std.ts';
import { FunKeyboard } from '../keyboard/FunKeyboard.dom.tsx';
import type { GridKeyboardState } from '../keyboard/GridKeyboardDelegate.dom.tsx';
import { GridKeyboardDelegate } from '../keyboard/GridKeyboardDelegate.dom.tsx';
@ -77,7 +70,7 @@ import {
import { FunSticker } from '../FunSticker.dom.tsx';
import { getAnalogTime } from '../../../util/getAnalogTime.std.ts';
import { getDateTimeFormatter } from '../../../util/formatTimestamp.dom.ts';
import { useFunEmojiSearch } from '../useFunEmojiSearch.dom.tsx';
import { Emoji } from '../../../axo/emoji.std.ts';
const STICKER_GRID_COLUMNS = 4;
const STICKER_GRID_CELL_WIDTH = 80;
@ -246,26 +239,19 @@ export function FunPanelStickers({
);
});
const searchEmojis = useFunEmojiSearch();
const sections = useMemo(() => {
if (searchQuery !== '') {
const emojiKeys = new Set<EmojiParentKey>();
for (const result of searchEmojis(searchQuery)) {
emojiKeys.add(result.parentKey);
}
const emojis = new Set<Emoji.Parent>(Emoji.search(searchQuery));
const allStickers = installedStickerPacks.flatMap(pack => pack.stickers);
const matchingStickers = allStickers.filter(sticker => {
if (sticker.emoji == null) {
return false;
}
if (!isEmojiParentValue(sticker.emoji)) {
if (!Emoji.isParent(sticker.emoji)) {
return false;
}
const parentKey = getEmojiParentKeyByValue(sticker.emoji);
return emojiKeys.has(parentKey);
return emojis.has(sticker.emoji);
});
return [
@ -304,13 +290,7 @@ export function FunPanelStickers({
}
return result;
}, [
showTimeStickers,
recentStickers,
installedStickerPacks,
searchEmojis,
searchQuery,
]);
}, [showTimeStickers, recentStickers, installedStickerPacks, searchQuery]);
const [virtualizer, layout] = useFunVirtualGrid({
scrollerRef,
@ -480,9 +460,7 @@ export function FunPanelStickers({
<FunStaticEmoji
size={16}
role="presentation"
emoji={getEmojiVariantByKey(
EMOJI_VARIANT_KEY_CONSTANTS.SLIGHTLY_FROWNING_FACE
)}
emoji={Emoji.SLIGHTLY_FROWNING_FACE}
/>
</FunResultsHeader>
</FunResults>

View File

@ -1,92 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useMemo } from 'react';
import type { EmojiParentKey, EmojiVariantKey } from './data/emojis.std.ts';
import {
getEmojiParentByKey,
getEmojiParentKeyByVariantKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from './data/emojis.std.ts';
import type { LocaleEmojiListType } from '../../types/emoji.std.ts';
import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider.dom.tsx';
export type FunEmojiLocalizerIndex = Readonly<{
parentKeyToLocaleShortName: ReadonlyMap<EmojiParentKey, string>;
localeShortNameToParentKey: ReadonlyMap<string, EmojiParentKey>;
}>;
export type FunEmojiLocalizer = Readonly<{
getLocaleShortName: (key: EmojiVariantKey) => string;
getParentKeyForText: (text: string) => EmojiParentKey | null;
}>;
export function createFunEmojiLocalizerIndex(
localeEmojiList: LocaleEmojiListType,
defaultLocalizerIndex?: FunEmojiLocalizerIndex
): FunEmojiLocalizerIndex {
const parentKeyToLocaleShortName = new Map<EmojiParentKey, string>();
const localeShortNameToParentKey = new Map<string, EmojiParentKey>();
for (const entry of localeEmojiList) {
// Sadly some localized emoji are not present in our spritesheets
if (!isEmojiVariantValue(entry.emoji)) {
continue;
}
const variantKey = getEmojiVariantKeyByValue(entry.emoji);
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
const localizedShortName = entry.tags.at(0) ?? entry.shortName;
parentKeyToLocaleShortName.set(parentKey, localizedShortName);
localeShortNameToParentKey.set(localizedShortName, parentKey);
}
if (defaultLocalizerIndex != null) {
for (const [
parentKey,
defaultShortName,
] of defaultLocalizerIndex.parentKeyToLocaleShortName) {
if (parentKeyToLocaleShortName.has(parentKey)) {
continue;
}
parentKeyToLocaleShortName.set(parentKey, defaultShortName);
localeShortNameToParentKey.set(defaultShortName, parentKey);
}
}
return { parentKeyToLocaleShortName, localeShortNameToParentKey };
}
/** @internal exported for tests */
export function _createFunEmojiLocalizer(
emojiLocalizerIndex: FunEmojiLocalizerIndex
): FunEmojiLocalizer {
function getLocaleShortName(variantKey: EmojiVariantKey) {
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
const localeShortName =
emojiLocalizerIndex.parentKeyToLocaleShortName.get(parentKey);
if (localeShortName != null) {
return localeShortName;
}
// Fallback to english short name
const parent = getEmojiParentByKey(parentKey);
return parent.englishShortNameDefault;
}
function getParentKeyForText(text: string): EmojiParentKey | null {
const parentKey = emojiLocalizerIndex.localeShortNameToParentKey.get(text);
return parentKey ?? null;
}
return { getLocaleShortName, getParentKeyForText };
}
export function useFunEmojiLocalizer(): FunEmojiLocalizer {
const { emojiLocalizerIndex } = useFunEmojiLocalization();
const emojiLocalizer = useMemo(() => {
return _createFunEmojiLocalizer(emojiLocalizerIndex);
}, [emojiLocalizerIndex]);
return emojiLocalizer;
}

View File

@ -1,157 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Fuse from 'fuse.js';
import type { FuseOptionKey, IFuseOptions } from 'fuse.js';
import lodash from 'lodash';
import { useMemo } from 'react';
import type { EmojiParentKey } from './data/emojis.std.ts';
import {
getEmojiParentByKey,
getEmojiParentKeyByValue,
isEmojiParentValue,
isEmojiParentValueDeprecated,
normalizeShortNameCompletionQuery,
} from './data/emojis.std.ts';
import type { LocaleEmojiListType } from '../../types/emoji.std.ts';
import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider.dom.tsx';
const { sortBy } = lodash;
export type FunEmojiSearchIndexEntry = Readonly<{
key: EmojiParentKey;
rank: number | null;
shortName: string;
shortNames: ReadonlyArray<string>;
emoticon: string | null;
emoticons: ReadonlyArray<string>;
}>;
export type FunEmojiSearchIndex = ReadonlyArray<FunEmojiSearchIndexEntry>;
export type FunEmojiSearchResult = Readonly<{
parentKey: EmojiParentKey;
}>;
export type FunEmojiSearch = (
query: string,
limit?: number
) => ReadonlyArray<FunEmojiSearchResult>;
export function createFunEmojiSearchIndex(
localeEmojiList: LocaleEmojiListType,
defaultSearchIndex: ReadonlyArray<FunEmojiSearchIndexEntry> = []
): FunEmojiSearchIndex {
const results: Array<FunEmojiSearchIndexEntry> = [];
const localizedKeys = new Set<string>();
for (const localeEmoji of localeEmojiList) {
if (!isEmojiParentValue(localeEmoji.emoji)) {
// Skipping unknown emoji, most likely apple doesn't support it
continue;
}
if (isEmojiParentValueDeprecated(localeEmoji.emoji)) {
// Skipping deprecated emoji
continue;
}
const parentKey = getEmojiParentKeyByValue(localeEmoji.emoji);
const emoji = getEmojiParentByKey(parentKey);
localizedKeys.add(parentKey);
results.push({
key: parentKey,
rank: localeEmoji.rank,
shortName: normalizeShortNameCompletionQuery(localeEmoji.shortName),
shortNames: localeEmoji.tags.map(tag => {
return normalizeShortNameCompletionQuery(tag);
}),
emoticon: emoji.emoticonDefault,
emoticons: emoji.emoticons,
});
}
for (const defaultEntry of defaultSearchIndex) {
if (!localizedKeys.has(defaultEntry.key)) {
results.push(defaultEntry);
}
}
return results;
}
const FuseKeys: Array<FuseOptionKey<FunEmojiSearchIndexEntry>> = [
{ name: 'shortName', weight: 100 },
{ name: 'shortNames', weight: 1 },
{ name: 'emoticon', weight: 50 },
{ name: 'emoticons', weight: 1 },
];
const FuseFuzzyOptions: IFuseOptions<FunEmojiSearchIndexEntry> = {
shouldSort: false,
threshold: 0.2,
minMatchCharLength: 1,
keys: FuseKeys,
includeScore: true,
includeMatches: true,
};
const FuseExactOptions: IFuseOptions<FunEmojiSearchIndexEntry> = {
shouldSort: false,
threshold: 0,
minMatchCharLength: 1,
keys: FuseKeys,
includeScore: true,
includeMatches: true,
};
/** @internal exported for tests */
export function _createFunEmojiSearch(
emojiSearchIndex: FunEmojiSearchIndex
): FunEmojiSearch {
const fuseIndex = Fuse.createIndex(FuseKeys, emojiSearchIndex);
const fuseFuzzy = new Fuse(emojiSearchIndex, FuseFuzzyOptions, fuseIndex);
const fuseExact = new Fuse(emojiSearchIndex, FuseExactOptions, fuseIndex);
return function emojiSearch(rawQuery, limit = 200) {
const query = normalizeShortNameCompletionQuery(rawQuery);
// Prefer exact matches at 2 characters
const fuse = query.length < 2 ? fuseExact : fuseFuzzy;
const rawResults = fuse.search(query.substring(0, 32));
// Note: lodash's sortBy() only calls each iteratee once
const sortedResults = sortBy(rawResults, result => {
const rank = result.item.rank ?? 1e9;
const localizedQueryMatch =
result.item.shortNames.at(0) ?? result.item.shortName;
// Exact prefix matches in [0,1] range
if (localizedQueryMatch.startsWith(query)) {
// Note: localizedQueryMatch will always be <= in length to the query
const matchRatio = query.length / localizedQueryMatch.length; // 1-0
return 1 - matchRatio;
}
const queryScore = result.score ?? 0; // 0-1
const rankScore = rank / emojiSearchIndex.length; // 0-1
// Other matches in [1,], ordered by score and rank
return 1 + queryScore + rankScore;
});
return sortedResults.slice(0, limit).map(result => {
return { parentKey: result.item.key };
});
};
}
export function useFunEmojiSearch(): FunEmojiSearch {
const { emojiSearchIndex } = useFunEmojiLocalization();
const emojiSearch = useMemo(() => {
return _createFunEmojiSearch(emojiSearchIndex);
}, [emojiSearchIndex]);
return emojiSearch;
}

View File

@ -37,7 +37,6 @@ import { AxoSwitch } from '../../../axo/AxoSwitch.dom.tsx';
import { FunEmojiPickerButton } from '../../fun/FunButton.dom.tsx';
import { FunEmojiPicker } from '../../fun/FunEmojiPicker.dom.tsx';
import type { FunEmojiSelection } from '../../fun/panels/FunPanelEmojis.dom.tsx';
import { getEmojiVariantByKey } from '../../fun/data/emojis.std.ts';
import {
ItemAvatar,
ItemBody,
@ -127,9 +126,7 @@ export function PreferencesEditChatFolderPage(
strictAssert(inputRef.current, 'Missing input ref');
const input = inputRef.current;
const { selectionStart, selectionEnd } = input;
const variant = getEmojiVariantByKey(emojiSelection.variantKey);
const emoji = variant.value;
const emoji = emojiSelection.emoji;
let newName: string;
if (selectionStart == null || selectionEnd == null) {

View File

@ -12,6 +12,11 @@ import {
portraitTealUrl,
squareStickerUrl,
} from '../../storybook/Fixtures.std.ts';
import type {
StickerPackType,
StickerType,
} from '../../state/ducks/stickers.preload.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { i18n } = window.SignalContext;
@ -21,21 +26,21 @@ export default {
args: {},
} satisfies Meta<Props>;
const abeSticker = {
const abeSticker: StickerType = {
id: -1,
emoji: '🎩',
emoji: Emoji.TOPHAT,
url: squareStickerUrl,
packId: 'abe',
};
const wideSticker = {
const wideSticker: StickerType = {
id: -2,
emoji: '🤯',
emoji: Emoji.EXPLODING_HEAD,
url: landscapeGreenUrl,
packId: 'wide',
};
const tallSticker = {
const tallSticker: StickerType = {
id: -3,
emoji: '🔥',
emoji: Emoji.FIRE,
url: portraitTealUrl,
packId: 'tall',
};
@ -44,7 +49,7 @@ export function Full(): JSX.Element {
const title = 'Foo';
const author = 'Foo McBarrington';
const pack = {
const pack: StickerPackType = {
id: 'foo',
key: 'foo',
lastUsed: Date.now(),

View File

@ -138,6 +138,7 @@ import Actions = Proto.GroupChange.Actions;
import AccessRequired = Proto.AccessControl.AccessRequired;
import MemberRole = Proto.Member.Role;
import { computeGroupNameHash } from './util/Conversation.preload.ts';
import { Emoji } from './axo/emoji.std.ts';
const { compact, difference, flatten, fromPairs, isNumber, omit, values } =
lodash;
@ -1325,7 +1326,7 @@ export function buildModifyMemberLabelChange({
}: {
serviceId: ServiceIdString;
group: ConversationAttributesType;
labelEmoji: string | undefined;
labelEmoji: Emoji.Variant | undefined;
labelString: string | undefined;
}): Actions.Params {
const logId = `buildModifyMemberLabelChange(${getConversationIdForLogging(group)})`;
@ -6332,7 +6333,7 @@ function normalizeTimestamp(timestamp: bigint | null | undefined): number {
type DecryptedModifyMemberLabelAction = {
userId: AciString;
labelEmoji?: string;
labelEmoji?: Emoji.Variant;
labelString?: string;
};
@ -7127,11 +7128,11 @@ function decryptModifyMemberLabelAction(
}
// labelEmoji
let decryptedLabelEmoji: string | undefined;
let decryptedLabelEmoji: Emoji.Variant | undefined;
if (Bytes.isNotEmpty(labelEmoji)) {
try {
decryptedLabelEmoji = Bytes.toString(
decryptGroupBlob(clientZkGroupCipher, labelEmoji)
decryptedLabelEmoji = Emoji.unsafeCastMaybeInvalidStringToVariant(
Bytes.toString(decryptGroupBlob(clientZkGroupCipher, labelEmoji))
);
} catch (error) {
log.warn(
@ -7364,7 +7365,7 @@ type DecryptedMember = Readonly<{
profileKey: Uint8Array<ArrayBuffer>;
role: MemberRole;
joinedAtVersion: number;
labelEmoji?: string;
labelEmoji?: Emoji.Variant;
labelString?: string;
}>;
@ -7413,11 +7414,11 @@ function decryptMember(
}
// labelEmoji
let decryptedLabelEmoji: string | undefined;
let decryptedLabelEmoji: Emoji.Variant | undefined;
if (Bytes.isNotEmpty(member.labelEmoji)) {
try {
decryptedLabelEmoji = Bytes.toString(
decryptGroupBlob(clientZkGroupCipher, member.labelEmoji)
decryptedLabelEmoji = Emoji.unsafeCastMaybeInvalidStringToVariant(
Bytes.toString(decryptGroupBlob(clientZkGroupCipher, member.labelEmoji))
);
} catch (error) {
log.warn(

View File

@ -49,13 +49,14 @@ import {
} from '../jobs/conversationJobQueue.preload.ts';
import { maybeNotify } from '../messages/maybeNotify.preload.ts';
import { itemStorage } from '../textsecure/Storage.preload.ts';
import type { Emoji } from '../axo/emoji.std.ts';
const { maxBy } = lodash;
const log = createLogger('Reactions');
export type ReactionAttributesType = {
emoji: string;
emoji: Emoji.Variant;
envelopeId: string;
fromId: string;
remove?: boolean;

11
ts/model-types.d.ts vendored
View File

@ -45,6 +45,7 @@ import MemberRoleEnum = Proto.Member.Role;
import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent.std.ts';
import type { QuotedMessageForComposerType } from './state/ducks/composer.preload.ts';
import type { SEALED_SENDER } from './types/SealedSender.std.ts';
import type { Emoji } from './axo/emoji.std.ts';
export type LastMessageStatus =
| 'paused'
@ -127,7 +128,7 @@ export type GroupV1Update = {
};
export type MessageReactionType = {
emoji: undefined | string;
emoji: undefined | Emoji.Variant;
fromId: string;
targetTimestamp: number;
timestamp: number;
@ -252,7 +253,7 @@ export type MessageAttributesType = {
contact?: ReadonlyArray<EmbeddedContactType>;
conversationId: string;
storyReaction?: {
emoji: string;
emoji: Emoji.Variant;
targetAuthorAci: AciString;
targetTimestamp: number;
};
@ -400,7 +401,7 @@ export type ConversationAttributesType = {
lastMessageAuthorAci?: AciString | null;
lastMessage?: string | null;
lastMessageBodyRanges?: ReadonlyArray<RawBodyRange>;
lastMessagePrefix?: string;
lastMessagePrefix?: Emoji.Variant;
/** @deprecated Use lastMessageAuthorAci instead */
lastMessageAuthor?: string | null;
lastMessageStatus?: LastMessageStatus | null;
@ -464,7 +465,7 @@ export type ConversationAttributesType = {
// Private other fields
about?: string;
aboutEmoji?: string;
aboutEmoji?: Emoji.Variant;
profileFamilyName?: string;
profileKey?: string;
profileName?: string;
@ -567,7 +568,7 @@ export type GroupV2MemberType = {
role: MemberRoleEnum;
joinedAtVersion: number;
labelString?: string;
labelEmoji?: string;
labelEmoji?: Emoji.Variant;
// Note that these are temporary flags, generated by applyGroupChange, but eliminated
// by applyGroupState. They are used to make our diff-generation more intelligent but

View File

@ -270,6 +270,7 @@ import { missingCaseError } from '../util/missingCaseError.std.ts';
import * as Message from '../types/Message2.preload.ts';
import { itemStorage } from '../textsecure/Storage.preload.ts';
import { isUsernameValid } from '../util/Username.dom.ts';
import type { Emoji } from '../axo/emoji.std.ts';
const { compact, isNumber, throttle, debounce } = lodash;
@ -4641,7 +4642,7 @@ export class ConversationModel {
labelEmoji,
labelString,
}: {
labelEmoji: string | undefined;
labelEmoji: Emoji.Variant | undefined;
labelString: string | undefined;
}): Promise<void> {
if (!isGroupV2(this.attributes)) {

View File

@ -6,38 +6,33 @@ import Emitter from '@signalapp/quill-cjs/core/emitter.js';
import type Quill from '@signalapp/quill-cjs';
import { createLogger } from '../../logging/log.std.ts';
import type { EmojiParentKey } from '../../components/fun/data/emojis.std.ts';
import {
EMOJI_PARENT_KEY_CONSTANTS,
EmojiSkinTone,
getEmojiVariantByParentKeyAndSkinTone,
} from '../../components/fun/data/emojis.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const log = createLogger('index');
export type AutoSubstituteAsciiEmojisOptions = {
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSkinToneDefault: Emoji.SkinTone | null;
};
type EmojiShortcutMap = Partial<Record<string, EmojiParentKey>>;
type EmojiShortcutMap = Partial<Record<string, Emoji.Parent>>;
const emojiShortcutMap: EmojiShortcutMap = {
':-)': EMOJI_PARENT_KEY_CONSTANTS.SLIGHTLY_SMILING_FACE,
':-(': EMOJI_PARENT_KEY_CONSTANTS.SLIGHTLY_FROWNING_FACE,
':-D': EMOJI_PARENT_KEY_CONSTANTS.GRINNING_FACE,
':-*': EMOJI_PARENT_KEY_CONSTANTS.FACE_BLOWING_A_KISS,
':-P': EMOJI_PARENT_KEY_CONSTANTS.FACE_WITH_STUCK_OUT_TONGUE,
':-p': EMOJI_PARENT_KEY_CONSTANTS.FACE_WITH_STUCK_OUT_TONGUE,
":'(": EMOJI_PARENT_KEY_CONSTANTS.CRYING_FACE,
':-\\': EMOJI_PARENT_KEY_CONSTANTS.CONFUSED_FACE,
':-|': EMOJI_PARENT_KEY_CONSTANTS.NEUTRAL_FACE,
';-)': EMOJI_PARENT_KEY_CONSTANTS.WINKING_FACE,
'(Y)': EMOJI_PARENT_KEY_CONSTANTS.THUMBS_UP,
'(N)': EMOJI_PARENT_KEY_CONSTANTS.THUMBS_UP,
'(y)': EMOJI_PARENT_KEY_CONSTANTS.THUMBS_UP,
'(n)': EMOJI_PARENT_KEY_CONSTANTS.THUMBS_DOWN,
'<3': EMOJI_PARENT_KEY_CONSTANTS.RED_HEART,
'^_^': EMOJI_PARENT_KEY_CONSTANTS.GRINNING_FACE,
':-)': Emoji.SLIGHTLY_SMILING_FACE,
':-(': Emoji.SLIGHTLY_FROWNING_FACE,
':-D': Emoji.GRINNING,
':-*': Emoji.KISSING_HEART,
':-P': Emoji.STUCK_OUT_TONGUE,
':-p': Emoji.STUCK_OUT_TONGUE,
":'(": Emoji.CRY,
':-\\': Emoji.CONFUSED,
':-|': Emoji.NEUTRAL_FACE,
';-)': Emoji.WINK,
'(Y)': Emoji.THUMBS_UP,
'(N)': Emoji.THUMBS_UP,
'(y)': Emoji.THUMBS_UP,
'(n)': Emoji.THUMBS_DOWN,
'<3': Emoji.HEART,
'^_^': Emoji.GRINNING,
};
function buildRegexp(obj: EmojiShortcutMap): RegExp {
@ -110,11 +105,11 @@ export class AutoSubstituteAsciiEmojis {
}
const [, textEmoji] = match as EmojiRegExpMatch;
const emojiParentKey = emojiShortcutMap[textEmoji];
const emoji = emojiShortcutMap[textEmoji];
if (emojiParentKey != null) {
if (emoji != null) {
this.insertEmoji(
emojiParentKey,
emoji,
range.index - textEmoji.length,
textEmoji.length,
textEmoji
@ -123,21 +118,18 @@ export class AutoSubstituteAsciiEmojis {
}
insertEmoji(
emojiParentKey: EmojiParentKey,
parent: Emoji.Parent,
index: number,
range: number,
source: string
): void {
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
emojiParentKey,
this.options.emojiSkinToneDefault ?? EmojiSkinTone.None
const value = Emoji.getVariant(
parent,
this.options.emojiSkinToneDefault ?? Emoji.SkinTone.None
);
const delta = new Delta()
.retain(index)
.delete(range)
.insert({
emoji: { value: emojiVariant.value, source },
});
const delta = new Delta().retain(index).delete(range).insert({
emoji: { value, source },
});
this.quill.updateContents(delta, 'api');
this.quill.setSelection(index + 1, 0);
}

View File

@ -2,22 +2,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
import EmbedBlot from '@signalapp/quill-cjs/blots/embed.js';
import type { EmojiVariantValue } from '../../components/fun/data/emojis.std.ts';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
} from '../../components/fun/data/emojis.std.ts';
import {
createStaticEmojiBlot,
FUN_STATIC_EMOJI_CLASS,
getFunEmojiElementValue,
} from '../../components/fun/FunEmoji.dom.tsx';
import { Emoji } from '../../axo/emoji.std.ts';
// the DOM structure of this EmojiBlot should match the other emoji implementations:
// ts/components/fun/FunEmoji.tsx
export type EmojiBlotValue = Readonly<{
value: EmojiVariantValue;
value: Emoji.Variant;
source?: string;
}>;
@ -29,20 +25,16 @@ export class EmojiBlot extends EmbedBlot {
static override className = FUN_STATIC_EMOJI_CLASS;
static override create({ value: emoji, source }: EmojiBlotValue): Node {
static override create({ value, source }: EmojiBlotValue): Node {
const node = super.create(undefined) as HTMLSpanElement;
const variantKey = getEmojiVariantKeyByValue(emoji);
const variant = getEmojiVariantByKey(variantKey);
createStaticEmojiBlot(node, {
role: 'img',
'aria-label': emoji,
emoji: variant,
'aria-label': Emoji.getDisplayLabel(value),
emoji: value,
size: 20,
});
node.setAttribute('data-emoji-key', variantKey);
node.setAttribute('data-emoji-value', emoji);
node.setAttribute('data-emoji', value);
node.setAttribute('data-source', source ?? '');
return node;

View File

@ -15,19 +15,9 @@ import { getBlotTextPartitions, matchBlotTextPartitions } from '../util.dom.ts';
import { handleOutsideClick } from '../../util/handleOutsideClick.dom.ts';
import { createLogger } from '../../logging/log.std.ts';
import { FunStaticEmoji } from '../../components/fun/FunEmoji.dom.tsx';
import type { EmojiParentKey } from '../../components/fun/data/emojis.std.ts';
import {
EmojiSkinTone,
getEmojiVariantByParentKeyAndSkinTone,
normalizeShortNameCompletionDisplay,
} from '../../components/fun/data/emojis.std.ts';
import type {
FunEmojiSearchResult,
FunEmojiSearch,
} from '../../components/fun/useFunEmojiSearch.dom.tsx';
import { type FunEmojiLocalizer } from '../../components/fun/useFunEmojiLocalizer.dom.tsx';
import type { FunEmojiSelection } from '../../components/fun/panels/FunPanelEmojis.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import { Emoji } from '../../axo/emoji.std.ts';
const { isNumber, debounce } = lodash;
@ -36,13 +26,11 @@ const log = createLogger('completion');
export type EmojiCompletionOptions = {
onSelectEmoji: (emojiSelection: FunEmojiSelection) => void;
setEmojiPickerElement: (element: JSX.Element | null) => void;
emojiSkinToneDefault: EmojiSkinTone | null;
emojiSearch: FunEmojiSearch;
emojiLocalizer: FunEmojiLocalizer;
emojiSkinToneDefault: Emoji.SkinTone | null;
};
export type InsertEmojiOptionsType = Readonly<{
emojiParentKey: EmojiParentKey;
emoji: Emoji.Parent;
index: number;
range: number;
withTrailingSpace?: boolean;
@ -50,7 +38,7 @@ export type InsertEmojiOptionsType = Readonly<{
}>;
export class EmojiCompletion {
results: ReadonlyArray<FunEmojiSearchResult>;
results: ReadonlyArray<Emoji.Parent>;
index: number;
@ -174,13 +162,12 @@ export class EmojiCompletion {
leftTokenTextMatch as LeftTokenMatch;
if (isSelfClosing || justPressedColon) {
const parentKey =
this.options.emojiLocalizer.getParentKeyForText(leftTokenText);
if (parentKey != null) {
const parent = Emoji.matchShortName(leftTokenText);
if (parent != null) {
const numberOfColons = isSelfClosing ? 2 : 1;
this.insertEmoji({
emojiParentKey: parentKey,
emoji: parent,
index: range.index - leftTokenText.length - numberOfColons,
range: leftTokenText.length + numberOfColons,
justPressedColon,
@ -194,12 +181,11 @@ export class EmojiCompletion {
if (rightTokenTextMatch) {
const [, rightTokenText] = rightTokenTextMatch as RightTokenMatch;
const tokenText = leftTokenText + rightTokenText;
const parentKey =
this.options.emojiLocalizer.getParentKeyForText(tokenText);
const parent = Emoji.matchShortName(tokenText);
if (parentKey != null) {
if (parent != null) {
this.insertEmoji({
emojiParentKey: parentKey,
emoji: parent,
index: range.index - leftTokenText.length - 1,
range: tokenText.length + 2,
justPressedColon,
@ -213,7 +199,7 @@ export class EmojiCompletion {
return PASS_THROUGH;
}
const showEmojiResults = this.options.emojiSearch(leftTokenText, 10);
const showEmojiResults = Emoji.search(leftTokenText, 10);
if (showEmojiResults.length > 0) {
this.results = showEmojiResults;
@ -262,7 +248,7 @@ export class EmojiCompletion {
const [, tokenText] = tokenTextMatch as TokenTextMatch;
this.insertEmoji({
emojiParentKey: result.parentKey,
emoji: result,
index: range.index - tokenText.length - 1,
range: tokenText.length + 1,
withTrailingSpace: true,
@ -270,17 +256,14 @@ export class EmojiCompletion {
}
insertEmoji({
emojiParentKey,
emoji,
index,
range,
withTrailingSpace = false,
justPressedColon = false,
}: InsertEmojiOptionsType): void {
const skinTone = this.options.emojiSkinToneDefault ?? EmojiSkinTone.None;
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
emojiParentKey,
skinTone
);
const skinTone = this.options.emojiSkinToneDefault ?? Emoji.SkinTone.None;
const variant = Emoji.getVariant(emoji, skinTone);
let source = this.quill.getText(index, range);
if (justPressedColon) {
@ -291,7 +274,7 @@ export class EmojiCompletion {
.retain(index)
.delete(range)
.insert({
emoji: { value: emojiVariant.value, source },
emoji: { value: variant, source },
});
if (withTrailingSpace) {
@ -305,7 +288,7 @@ export class EmojiCompletion {
}
this.options.onSelectEmoji({
variantKey: emojiVariant.key,
emoji: variant,
});
this.reset();
@ -385,31 +368,23 @@ export class EmojiCompletion {
role="listbox"
aria-expanded
aria-activedescendant={`emoji-result--${
emojiResults.length
? emojiResults[emojiResultsIndex]?.parentKey
: ''
emojiResults.length ? emojiResults[emojiResultsIndex] : ''
}`}
tabIndex={0}
>
{emojiResults.map((result, index) => {
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
result.parentKey,
this.options.emojiSkinToneDefault ?? EmojiSkinTone.None
const emojiVariant = Emoji.getVariant(
result,
this.options.emojiSkinToneDefault ?? Emoji.SkinTone.None
);
const localeShortName =
this.options.emojiLocalizer.getLocaleShortName(
emojiVariant.key
);
const normalized =
normalizeShortNameCompletionDisplay(localeShortName);
const completionLabel = Emoji.getCompletionLabel(result);
return (
<button
type="button"
key={result.parentKey}
id={`emoji-result--${result.parentKey}`}
key={result}
id={`emoji-result--${result}`}
role="option button"
aria-selected={emojiResultsIndex === index}
onClick={() => {
@ -429,7 +404,7 @@ export class EmojiCompletion {
size={16}
/>
<div className="module-composition-input__suggestions__row__short-name">
:{normalized}:
:{completionLabel}:
</div>
</button>
);

Some files were not shown because too many files have changed in this diff Show More