feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883)
No files matched your search
@@ -1,8 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const EYE_IN_SPEECH_BUBBLE = '\u{1F441}\u200D\u{1F5E8}';
|
||||
|
||||
export function convertToCodePoints(emoji: string): string {
|
||||
const containsZWJ = emoji.includes('\u200D');
|
||||
const processedEmoji = containsZWJ ? emoji : emoji.replace(/\uFE0F/g, '');
|
||||
const emojiWithoutFE0F = emoji.replace(/\uFE0F/g, '');
|
||||
const keepsFE0F = emoji.includes('\u200D') && emojiWithoutFE0F !== EYE_IN_SPEECH_BUBBLE;
|
||||
const processedEmoji = keepsFE0F ? emoji : emojiWithoutFE0F;
|
||||
return Array.from(processedEmoji)
|
||||
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
|
||||
.join('-');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {UnicodeEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils';
|
||||
import type {EmojiSurrogateMatch} from '@app/features/messaging/utils/markdown/parser/EmojiParsers';
|
||||
import * as RegexUtils from '@app/features/messaging/utils/RegexUtils';
|
||||
import emojiData from '@app/media/data/emojis.json';
|
||||
import {SKIN_TONE_SURROGATES} from '@fluxer/constants/src/EmojiConstants';
|
||||
@@ -60,6 +61,15 @@ const categories = Object.freeze(Object.keys(emojiData.categories));
|
||||
|
||||
const toCanonicalSurrogate = (surrogate: string): string => surrogate.replace(/️/g, '');
|
||||
|
||||
const EYE_IN_SPEECH_BUBBLE_FULLY_QUALIFIED = '\u{1F441}\uFE0F\u200D\u{1F5E8}\uFE0F';
|
||||
const HAIR_COMPONENT_NAMES: Record<string, string> = {
|
||||
'\u{1F9B0}': 'red_hair',
|
||||
'\u{1F9B1}': 'curly_hair',
|
||||
'\u{1F9B3}': 'white_hair',
|
||||
'\u{1F9B2}': 'bald',
|
||||
};
|
||||
const REGIONAL_INDICATOR_PAIR_PATTERN = '\\uD83C[\\uDDE6-\\uDDFF]\\uD83C[\\uDDE6-\\uDDFF]';
|
||||
|
||||
let defaultSkinTone: string = '';
|
||||
|
||||
class UnicodeEmojiClass {
|
||||
@@ -216,6 +226,11 @@ function buildEmojiIndex(): EmojiIndex {
|
||||
surrogateToName[skinToneEntry.surrogatePair] = skinToneEntry.name;
|
||||
canonicalSurrogateToName[toCanonicalSurrogate(skinToneEntry.surrogatePair)] = skinToneEntry.name;
|
||||
});
|
||||
const skins = (emojiObject as {skins?: ReadonlyArray<{names: ReadonlyArray<string>; surrogates: string}>}).skins;
|
||||
skins?.slice(SKIN_TONE_SURROGATES.length).forEach((skin) => {
|
||||
surrogateToName[skin.surrogates] = skin.names[0];
|
||||
canonicalSurrogateToName[toCanonicalSurrogate(skin.surrogates)] = skin.names[0];
|
||||
});
|
||||
categoryByEmojiName[emoji.uniqueName] = category;
|
||||
emojis.push(emojiJson);
|
||||
return emojiJson;
|
||||
@@ -228,6 +243,20 @@ function buildEmojiIndex(): EmojiIndex {
|
||||
canonicalSurrogateToName[toCanonicalSurrogate(surrogatePair)] = `skin-tone-${index + 1}`;
|
||||
});
|
||||
|
||||
Object.entries(HAIR_COMPONENT_NAMES).forEach(([surrogate, name]) => {
|
||||
surrogateToName[surrogate] = name;
|
||||
canonicalSurrogateToName[surrogate] = name;
|
||||
});
|
||||
|
||||
Object.keys(surrogateToName)
|
||||
.filter((surrogate) => surrogate.includes('\u20E3'))
|
||||
.map(toCanonicalSurrogate)
|
||||
.concat(EYE_IN_SPEECH_BUBBLE_FULLY_QUALIFIED)
|
||||
.forEach((alias) => {
|
||||
const name = canonicalSurrogateToName[toCanonicalSurrogate(alias)];
|
||||
if (name) surrogateToName[alias] = name;
|
||||
});
|
||||
|
||||
const keywordOwner: Record<string, string> = {};
|
||||
const keywordCount: Record<string, number> = {};
|
||||
|
||||
@@ -275,7 +304,7 @@ function buildEmojiIndex(): EmojiIndex {
|
||||
emojis,
|
||||
skinToneSpriteCount,
|
||||
baseSpriteCount,
|
||||
emojiSurrogateRegex: new RegExp(`(${surrogateAlternation})`, 'g'),
|
||||
emojiSurrogateRegex: new RegExp(`(${REGIONAL_INDICATOR_PAIR_PATTERN}|${surrogateAlternation})`, 'g'),
|
||||
emojiShortcutRegex: new RegExp(`^(${shortcutAlternation})`),
|
||||
};
|
||||
}
|
||||
@@ -288,6 +317,50 @@ function getEmojiIndex(): EmojiIndex {
|
||||
const lookupSurrogateName = (surrogate: string): string | null =>
|
||||
getEmojiIndex().canonicalSurrogateToName[toCanonicalSurrogate(surrogate)] ?? null;
|
||||
|
||||
const isRegionalIndicatorAt = (text: string, index: number): boolean => {
|
||||
const lowSurrogate = text.charCodeAt(index + 1);
|
||||
return text.charCodeAt(index) === 0xd83c && lowSurrogate >= 0xdde6 && lowSurrogate <= 0xddff;
|
||||
};
|
||||
|
||||
const isInsideRegionalIndicatorPair = (text: string, index: number): boolean => {
|
||||
if (!isRegionalIndicatorAt(text, index)) return false;
|
||||
let runStart = index;
|
||||
while (isRegionalIndicatorAt(text, runStart - 2)) runStart -= 2;
|
||||
return (index - runStart) % 4 === 2;
|
||||
};
|
||||
|
||||
const toEmojiSurrogateMatch = (text: string, start: number, end: number): EmojiSurrogateMatch => ({
|
||||
start,
|
||||
end,
|
||||
name: lookupSurrogateName(text.slice(start, end)),
|
||||
});
|
||||
|
||||
function* matchEmojiSurrogates(text: string, startIndex = 0): Generator<EmojiSurrogateMatch, void> {
|
||||
const regex = getEmojiIndex().emojiSurrogateRegex;
|
||||
let index = startIndex;
|
||||
if (isInsideRegionalIndicatorPair(text, index)) {
|
||||
yield toEmojiSurrogateMatch(text, index, index + 2);
|
||||
index += 2;
|
||||
}
|
||||
while (true) {
|
||||
regex.lastIndex = index;
|
||||
const match = regex.exec(text);
|
||||
if (match == null) return;
|
||||
index = match.index + match[0].length;
|
||||
const emojiMatch = toEmojiSurrogateMatch(text, match.index, index);
|
||||
const isUnknownRegionalIndicatorPair =
|
||||
emojiMatch.name == null &&
|
||||
isRegionalIndicatorAt(text, match.index) &&
|
||||
isRegionalIndicatorAt(text, match.index + 2);
|
||||
if (isUnknownRegionalIndicatorPair) {
|
||||
yield toEmojiSurrogateMatch(text, match.index, match.index + 2);
|
||||
yield toEmojiSurrogateMatch(text, match.index + 2, index);
|
||||
} else {
|
||||
yield emojiMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EMOJI_SHORTCODE_RE = /^:([^\s:]+(?:::skin-tone-[0-9])?):/;
|
||||
const categoryIcons = {
|
||||
people: SmileyIcon,
|
||||
@@ -377,6 +450,8 @@ export default {
|
||||
getSurrogateName: (surrogate: string): string | null => {
|
||||
return lookupSurrogateName(surrogate);
|
||||
},
|
||||
matchEmojiSurrogates,
|
||||
isInsideRegionalIndicatorPair,
|
||||
findEmojiByName: (emojiName: string): UnicodeEmoji | null => {
|
||||
return getEmojiIndex().nameToEmoji[emojiName] || null;
|
||||
},
|
||||
@@ -400,8 +475,5 @@ export default {
|
||||
get EMOTICON_PREFIX_RE(): RegExp {
|
||||
return getEmojiIndex().emojiShortcutRegex;
|
||||
},
|
||||
get EMOJI_SURROGATE_RE(): RegExp {
|
||||
return getEmojiIndex().emojiSurrogateRegex;
|
||||
},
|
||||
EMOJI_SPRITES,
|
||||
};
|
||||
@@ -29,17 +29,19 @@ export function registerComposerEmojiShortcode(editor: LexicalEditor, resolve: C
|
||||
}
|
||||
|
||||
function findUnicodeEmoji(text: string, startIndex: number): TypedEmojiMatch | null {
|
||||
const pattern = new RegExp(UnicodeEmojis.EMOJI_SURROGATE_RE.source, 'g');
|
||||
pattern.lastIndex = startIndex;
|
||||
const match = pattern.exec(text);
|
||||
if (match == null) {
|
||||
const matches = UnicodeEmojis.matchEmojiSurrogates(text, startIndex);
|
||||
let match = matches.next().value;
|
||||
if (match && UnicodeEmojis.isInsideRegionalIndicatorPair(text, match.end)) {
|
||||
match = matches.next().value;
|
||||
}
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const name = UnicodeEmojis.nameForSurrogate(match[0], false);
|
||||
const name = match.name;
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {start: match.index, end: match.index + match[0].length, name};
|
||||
return {start: match.start, end: match.end, name};
|
||||
}
|
||||
|
||||
interface EmojiToken extends TypedEmojiMatch {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {type EmojiProvider, setEmojiParserConfig} from '@app/features/messaging/
|
||||
import {SKIN_TONE_SURROGATES} from '@fluxer/constants/src/EmojiConstants';
|
||||
|
||||
const emojiProvider: EmojiProvider = {
|
||||
getSurrogateName: UnicodeEmojis.getSurrogateName,
|
||||
matchEmojiSurrogates: UnicodeEmojis.matchEmojiSurrogates,
|
||||
findEmojiByName: UnicodeEmojis.findEmojiByShortcodeName,
|
||||
findEmojiWithSkinTone: UnicodeEmojis.findEmojiWithSkinTone,
|
||||
};
|
||||
@@ -13,9 +13,6 @@ const emojiProvider: EmojiProvider = {
|
||||
export function initializeEmojiParser(): void {
|
||||
setEmojiParserConfig({
|
||||
emojiProvider,
|
||||
get emojiRegex() {
|
||||
return UnicodeEmojis.EMOJI_SURROGATE_RE;
|
||||
},
|
||||
skinToneSurrogates: SKIN_TONE_SURROGATES,
|
||||
});
|
||||
}
|
||||
@@ -4,17 +4,21 @@ export interface UnicodeEmoji {
|
||||
surrogates: string;
|
||||
}
|
||||
|
||||
export interface EmojiSurrogateMatch {
|
||||
start: number;
|
||||
end: number;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface EmojiProvider {
|
||||
getSurrogateName(surrogate: string): string | null;
|
||||
matchEmojiSurrogates(text: string): Iterable<EmojiSurrogateMatch>;
|
||||
findEmojiByName(name: string): UnicodeEmoji | null;
|
||||
findEmojiWithSkinTone(baseName: string, skinToneSurrogate: string): UnicodeEmoji | null;
|
||||
}
|
||||
|
||||
export interface EmojiParserConfig {
|
||||
emojiProvider?: EmojiProvider;
|
||||
emojiRegex?: RegExp;
|
||||
skinToneSurrogates?: ReadonlyArray<string>;
|
||||
convertToCodePoints?: (emoji: string) => string;
|
||||
}
|
||||
|
||||
let globalEmojiConfig: EmojiParserConfig | null = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {convertToCodePoints} from '@app/features/expressions/utils/EmojiCodepointUtils';
|
||||
import {flattenAST} from '@app/features/messaging/utils/markdown/parser/AstUtils';
|
||||
import {getEmojiParserConfig} from '@app/features/messaging/utils/markdown/parser/EmojiParsers';
|
||||
import {MARKDOWN_PARSER_WASM_BASE64} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes';
|
||||
@@ -202,14 +203,6 @@ class Utf8OffsetTracker {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCodepoints(emoji: string): string {
|
||||
const containsZwJ = emoji.includes('');
|
||||
const processed = containsZwJ ? emoji : emoji.replace(/️/g, '');
|
||||
return Array.from(processed)
|
||||
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
|
||||
.join('-');
|
||||
}
|
||||
|
||||
const PLAINTEXT_SYMBOLS = new Set(['™', '™️', '©', '©️', '®', '®️']);
|
||||
const SPECIAL_SHORTCODES: Record<string, string> = {
|
||||
tm: '™',
|
||||
@@ -226,29 +219,23 @@ function buildEmojiContext(input: string): string {
|
||||
const provider = config?.emojiProvider;
|
||||
let context = '';
|
||||
if (!provider) return context;
|
||||
const convertToCodePoints = config.convertToCodePoints || defaultCodepoints;
|
||||
const emojiRegex = config.emojiRegex;
|
||||
if (emojiRegex) {
|
||||
const offsetTracker = new Utf8OffsetTracker();
|
||||
emojiRegex.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = emojiRegex.exec(input)) !== null) {
|
||||
const candidate = match[0];
|
||||
if (!candidate || PLAINTEXT_SYMBOLS.has(candidate)) continue;
|
||||
const name = provider.getSurrogateName(candidate);
|
||||
if (!name) continue;
|
||||
const candidateBytes = textEncoder.encode(candidate).byteLength;
|
||||
const byteOffset = offsetTracker.offsetFor(input, match.index);
|
||||
context += appendContextLine([
|
||||
'S',
|
||||
String(byteOffset),
|
||||
String(candidateBytes),
|
||||
candidate,
|
||||
name,
|
||||
convertToCodePoints(candidate),
|
||||
]);
|
||||
offsetTracker.advance(candidate.length, candidateBytes);
|
||||
}
|
||||
const offsetTracker = new Utf8OffsetTracker();
|
||||
for (const match of provider.matchEmojiSurrogates(input)) {
|
||||
const candidate = input.slice(match.start, match.end);
|
||||
if (PLAINTEXT_SYMBOLS.has(candidate)) continue;
|
||||
const name = match.name;
|
||||
if (!name) continue;
|
||||
const candidateBytes = textEncoder.encode(candidate).byteLength;
|
||||
const byteOffset = offsetTracker.offsetFor(input, match.start);
|
||||
context += appendContextLine([
|
||||
'S',
|
||||
String(byteOffset),
|
||||
String(candidateBytes),
|
||||
candidate,
|
||||
name,
|
||||
convertToCodePoints(candidate),
|
||||
]);
|
||||
offsetTracker.advance(candidate.length, candidateBytes);
|
||||
}
|
||||
const shortcodeRegex = /:([\p{L}\p{N}_-]+):/gu;
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"shortcuts":{">:(":"angry",">:-(":"angry",">=(":"angry",">=-(":"angry",":\")":"blush",":-\")":"blush","=\")":"blush","=-\")":"blush","</3":"broken_heart","<\\3":"broken_heart",":-\\":"confused",":-/":"confused","=-\\":"confused","=-/":"confused",":'(":"cry",":'-(":"cry",":,(":"cry",":,-(":"cry","='(":"cry","='-(":"cry","=,(":"cry","=,-(":"cry",":(":"slight_frown",":-(":"slight_frown","=(":"slight_frown","=-(":"slight_frown","<3":"heart","♡":"heart","]:(":"imp","]:-(":"imp","]=(":"imp","]=-(":"imp","o:)":"innocent","O:)":"innocent","o:-)":"innocent","O:-)":"innocent","0:)":"innocent","0:-)":"innocent","o=)":"innocent","O=)":"innocent","o=-)":"innocent","O=-)":"innocent","0=)":"innocent","0=-)":"innocent",":'D":"joy",":'-D":"joy",":,D":"joy",":,-D":"joy","='D":"joy","='-D":"joy","=,D":"joy","=,-D":"joy",":*":"kissing",":-*":"kissing","=*":"kissing","=-*":"kissing","x-)":"laughing","X-)":"laughing",":|":"neutral_face",":-|":"neutral_face","=|":"neutral_face","=-|":"neutral_face",":o":"open_mouth",":-o":"open_mouth",":O":"open_mouth",":-O":"open_mouth","=o":"open_mouth","=-o":"open_mouth","=O":"open_mouth","=-O":"open_mouth",":@":"rage",":-@":"rage","=@":"rage","=-@":"rage",":D":"smile",":-D":"smile","=D":"smile","=-D":"smile",":')":"smiling_face_with_tear",":'-)":"smiling_face_with_tear",":,)":"smiling_face_with_tear",":,-)":"smiling_face_with_tear","=')":"smiling_face_with_tear","='-)":"smiling_face_with_tear","=,)":"smiling_face_with_tear","=,-)":"smiling_face_with_tear",":)":"slight_smile",":-)":"slight_smile","=)":"slight_smile","=-)":"slight_smile","]:)":"smiling_imp","]:-)":"smiling_imp","]=)":"smiling_imp","]=-)":"smiling_imp",":,'(":"sob",":,'-(":"sob",";(":"sob",";-(":"sob","=,'(":"sob","=,'-(":"sob",":P":"stuck_out_tongue",":-P":"stuck_out_tongue","=P":"stuck_out_tongue","=-P":"stuck_out_tongue","8-)":"sunglasses","B-)":"sunglasses",",:(":"sweat",",:-(":"sweat",",=(":"sweat",",=-(":"sweat",",:)":"sweat_smile",",:-)":"sweat_smile",",=)":"sweat_smile",",=-)":"sweat_smile",":s":"unamused",":-S":"unamused",":z":"unamused",":-Z":"unamused",":$":"unamused",":-$":"unamused","=s":"unamused","=-S":"unamused","=z":"unamused","=-Z":"unamused","=$":"unamused","=-$":"unamused",";)":"wink",";-)":"wink"},"categories":{"people":[{"names":["grinning","grinning_face"],"surrogates":"😀","keywords":["cheerful","cheery","face","grin","happy","laugh","nice","smile","smiling","teeth"]},{"names":["smiley"],"surrogates":"😃","keywords":["awesome","big","eyes","face","grin","grinning","happy","mouth","open","smile","smiling","teeth","yay"]},{"names":["smile"],"surrogates":"😄","keywords":["eye","eyes","face","grin","grinning","happy","laugh","lol","mouth","open","smiling"]},{"names":["grin"],"surrogates":"😁","keywords":["beaming","eye","eyes","face","grinning","happy","nice","smile","smiling","teeth"]},{"names":["laughing","satisfied"],"surrogates":"😆","keywords":["closed","eyes","face","grinning","haha","hahaha","happy","laugh","lol","mouth","open","rofl","smile","smiling","squinting"]},{"names":["sweat_smile"],"surrogates":"😅","keywords":["cold","dejected","excited","face","grinning","mouth","nervous","open","smile","smiling","stress","stressed","sweat"]},{"names":["rofl","rolling_on_the_floor_laughing"],"surrogates":"🤣","keywords":["crying","face","floor","funny","haha","happy","hehe","hilarious","joy","laugh","lmao","lol","roflmao","rolling","tear"]},{"names":["joy"],"surrogates":"😂","keywords":["crying","face","feels","funny","haha","happy","hehe","hilarious","laugh","lmao","lol","rofl","roflmao","tear"]},{"names":["slight_smile","slightly_smiling_face"],"surrogates":"🙂","keywords":["face","happy","slightly","smile","smiling"]},{"names":["upside_down","upside_down_face"],"surrogates":"🙃","keywords":["face","hehe","smile","upside-down"]},{"names":["melting_face"],"surrogates":"🫠","keywords":["disappear","dissolve","embarrassed","face","haha","heat","hot","liquid","lol","melt","melting","sarcasm","sarcastic"]},{"names":["wink","winking_face"],"surrogates":"😉","keywords":["face","flirt","heartbreaker","sexy","slide","tease","winking","winks"]},{"names":["blush"],"surrogates":"😊","keywords":["eye","eyes","face","glad","satisfied","smile","smiling"]},{"names":["innocent"],"surrogates":"😇","keywords":["angel","angelic","angels","blessed","face","fairy","fairytale","fantasy","halo","happy","peaceful","smile","smiling","spirit","tale"]},{"names":["smiling_face_with_3_hearts"],"surrogates":"🥰","keywords":["3","adore","crush","face","heart","hearts","ily","love","romance","smile","smiling","you"]},{"names":["heart_eyes"],"surrogates":"😍","keywords":["143","bae","eye","face","feels","heart-eyes","hearts","ily","kisses","love","romance","romantic","smile","xoxo"]},{"names":["star_struck"],"surrogates":"🤩","keywords":["excited","eyes","face","grinning","smile","star","star-struck","starry-eyed","wow"]},{"names":["kissing_heart"],"surrogates":"😘","keywords":["adorbs","bae","blowinLine truncated
|
||||
{"shortcuts":{">:(":"angry",">:-(":"angry",">=(":"angry",">=-(":"angry",":\")":"blush",":-\")":"blush","=\")":"blush","=-\")":"blush","</3":"broken_heart","<\\3":"broken_heart",":-\\":"confused",":-/":"confused","=-\\":"confused","=-/":"confused",":'(":"cry",":'-(":"cry",":,(":"cry",":,-(":"cry","='(":"cry","='-(":"cry","=,(":"cry","=,-(":"cry",":(":"slight_frown",":-(":"slight_frown","=(":"slight_frown","=-(":"slight_frown","<3":"heart","♡":"heart","]:(":"imp","]:-(":"imp","]=(":"imp","]=-(":"imp","o:)":"innocent","O:)":"innocent","o:-)":"innocent","O:-)":"innocent","0:)":"innocent","0:-)":"innocent","o=)":"innocent","O=)":"innocent","o=-)":"innocent","O=-)":"innocent","0=)":"innocent","0=-)":"innocent",":'D":"joy",":'-D":"joy",":,D":"joy",":,-D":"joy","='D":"joy","='-D":"joy","=,D":"joy","=,-D":"joy",":*":"kissing",":-*":"kissing","=*":"kissing","=-*":"kissing","x-)":"laughing","X-)":"laughing",":|":"neutral_face",":-|":"neutral_face","=|":"neutral_face","=-|":"neutral_face",":o":"open_mouth",":-o":"open_mouth",":O":"open_mouth",":-O":"open_mouth","=o":"open_mouth","=-o":"open_mouth","=O":"open_mouth","=-O":"open_mouth",":@":"rage",":-@":"rage","=@":"rage","=-@":"rage",":D":"smile",":-D":"smile","=D":"smile","=-D":"smile",":')":"smiling_face_with_tear",":'-)":"smiling_face_with_tear",":,)":"smiling_face_with_tear",":,-)":"smiling_face_with_tear","=')":"smiling_face_with_tear","='-)":"smiling_face_with_tear","=,)":"smiling_face_with_tear","=,-)":"smiling_face_with_tear",":)":"slight_smile",":-)":"slight_smile","=)":"slight_smile","=-)":"slight_smile","]:)":"smiling_imp","]:-)":"smiling_imp","]=)":"smiling_imp","]=-)":"smiling_imp",":,'(":"sob",":,'-(":"sob",";(":"sob",";-(":"sob","=,'(":"sob","=,'-(":"sob",":P":"stuck_out_tongue",":-P":"stuck_out_tongue","=P":"stuck_out_tongue","=-P":"stuck_out_tongue","8-)":"sunglasses","B-)":"sunglasses",",:(":"sweat",",:-(":"sweat",",=(":"sweat",",=-(":"sweat",",:)":"sweat_smile",",:-)":"sweat_smile",",=)":"sweat_smile",",=-)":"sweat_smile",":s":"unamused",":-S":"unamused",":z":"unamused",":-Z":"unamused",":$":"unamused",":-$":"unamused","=s":"unamused","=-S":"unamused","=z":"unamused","=-Z":"unamused","=$":"unamused","=-$":"unamused",";)":"wink",";-)":"wink"},"categories":{"people":[{"names":["grinning","grinning_face"],"surrogates":"😀","keywords":["cheerful","cheery","face","grin","happy","laugh","nice","smile","smiling","teeth"]},{"names":["smiley"],"surrogates":"😃","keywords":["awesome","big","eyes","face","grin","grinning","happy","mouth","open","smile","smiling","teeth","yay"]},{"names":["smile"],"surrogates":"😄","keywords":["eye","eyes","face","grin","grinning","happy","laugh","lol","mouth","open","smiling"]},{"names":["grin"],"surrogates":"😁","keywords":["beaming","eye","eyes","face","grinning","happy","nice","smile","smiling","teeth"]},{"names":["laughing","satisfied"],"surrogates":"😆","keywords":["closed","eyes","face","grinning","haha","hahaha","happy","laugh","lol","mouth","open","rofl","smile","smiling","squinting"]},{"names":["sweat_smile"],"surrogates":"😅","keywords":["cold","dejected","excited","face","grinning","mouth","nervous","open","smile","smiling","stress","stressed","sweat"]},{"names":["rofl","rolling_on_the_floor_laughing"],"surrogates":"🤣","keywords":["crying","face","floor","funny","haha","happy","hehe","hilarious","joy","laugh","lmao","lol","roflmao","rolling","tear"]},{"names":["joy"],"surrogates":"😂","keywords":["crying","face","feels","funny","haha","happy","hehe","hilarious","laugh","lmao","lol","rofl","roflmao","tear"]},{"names":["slight_smile","slightly_smiling_face"],"surrogates":"🙂","keywords":["face","happy","slightly","smile","smiling"]},{"names":["upside_down","upside_down_face"],"surrogates":"🙃","keywords":["face","hehe","smile","upside-down"]},{"names":["melting_face"],"surrogates":"🫠","keywords":["disappear","dissolve","embarrassed","face","haha","heat","hot","liquid","lol","melt","melting","sarcasm","sarcastic"]},{"names":["wink","winking_face"],"surrogates":"😉","keywords":["face","flirt","heartbreaker","sexy","slide","tease","winking","winks"]},{"names":["blush"],"surrogates":"😊","keywords":["eye","eyes","face","glad","satisfied","smile","smiling"]},{"names":["innocent"],"surrogates":"😇","keywords":["angel","angelic","angels","blessed","face","fairy","fairytale","fantasy","halo","happy","peaceful","smile","smiling","spirit","tale"]},{"names":["smiling_face_with_3_hearts"],"surrogates":"🥰","keywords":["3","adore","crush","face","heart","hearts","ily","love","romance","smile","smiling","you"]},{"names":["heart_eyes"],"surrogates":"😍","keywords":["143","bae","eye","face","feels","heart-eyes","hearts","ily","kisses","love","romance","romantic","smile","xoxo"]},{"names":["star_struck"],"surrogates":"🤩","keywords":["excited","eyes","face","grinning","smile","star","star-struck","starry-eyed","wow"]},{"names":["kissing_heart"],"surrogates":"😘","keywords":["adorbs","bae","blowinLine truncated
|
||||
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 612 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 589 KiB After Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 602 KiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 590 KiB After Width: | Height: | Size: 608 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 585 KiB After Width: | Height: | Size: 603 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 3.2 MiB |