feat: Add custom emoji picker
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 0s
Deploy to GitHub Pages / build (push) Failing after 0s
Deploy to GitHub Pages / deploy (push) Has been skipped
Docker / build (push) Failing after 0s
Mirror to Codeberg / Mirror (push) Failing after 0s

This commit is contained in:
Jesse Wierzbinski 2025-05-27 21:45:54 +02:00
parent b77700b8b8
commit 2b72a2a5ea
No known key found for this signature in database
15 changed files with 592 additions and 6 deletions

View file

@ -32,6 +32,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-vue": "^8.6.0",
"emojibase": "^16.0.0",
"emojibase-data": "^16.0.3",
"fuzzysort": "^3.1.0",
"html-to-text": "^9.0.5",
"lucide-vue-next": "^0.511.0",
@ -1178,6 +1180,10 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emojibase": ["emojibase@16.0.0", "", {}, "sha512-Nw2m7JLIO4Ou2X/yZPRNscHQXVbbr6SErjkJ7EooG7MbR3yDZszCv9KTizsXFc7yZl0n3WF+qUKIC/Lw6H9xaQ=="],
"emojibase-data": ["emojibase-data@16.0.3", "", { "peerDependencies": { "emojibase": "*" } }, "sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q=="],
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],

View file

@ -1,5 +1,5 @@
<template>
<div v-if="relation" class="rounded border overflow-auto max-h-72">
<div v-if="relation" class="overflow-auto max-h-72">
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
</div>

View file

@ -10,6 +10,9 @@
{{ numberFormat(reblogCount) }}
</ActionButton>
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" />
<Picker @pick="react">
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!identity" />
</Picker>
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
</Menu>
@ -17,8 +20,8 @@
</template>
<script lang="ts" setup>
import type { Status } from "@versia/client/schemas";
import { Ellipsis, Heart, Quote, Repeat, Reply } from "lucide-vue-next";
import type { CustomEmoji, Status } from "@versia/client/schemas";
import { Ellipsis, Heart, Quote, Repeat, Reply, Smile } from "lucide-vue-next";
import { toast } from "vue-sonner";
import type { z } from "zod";
import * as m from "~/paraglide/messages.js";
@ -26,6 +29,8 @@ import { getLocale } from "~/paraglide/runtime";
import { confirmModalService } from "../modals/composable";
import ActionButton from "./action-button.vue";
import Menu from "./menu.vue";
import type { UnicodeEmoji } from "./reactions/picker/emoji";
import Picker from "./reactions/picker/index.vue";
const { noteId } = defineProps<{
replyCount: number;
@ -46,6 +51,7 @@ const emit = defineEmits<{
reply: [];
quote: [];
delete: [];
react: [];
}>();
const { play } = useAudio();
@ -137,6 +143,19 @@ const unreblog = async () => {
useEvent("note:edit", data);
};
const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
const id = toast.loading(m.gray_stale_antelope_roam());
const text = (emoji as UnicodeEmoji).hexcode
? (emoji as UnicodeEmoji).unicode
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
const { data } = await client.value.createEmojiReaction(noteId, text);
toast.dismiss(id);
toast.success(m.main_least_turtle_fall());
useEvent("note:edit", data);
};
const numberFormat = (number = 0) =>
number !== 0
? new Intl.NumberFormat(getLocale(), {

View file

@ -0,0 +1,15 @@
<template>
<div class="sticky top-2 z-10 flex items-center justify-center p-2">
<Badge variant="secondary">
{{ categoryName }}
</Badge>
</div>
</template>
<script lang="ts" setup>
import { Badge } from "~/components/ui/badge";
const { categoryName } = defineProps<{
categoryName: string;
}>();
</script>

View file

@ -0,0 +1,21 @@
<template>
<div class="p-2 text-sm font-semibold border-0 rounded-none text-center flex flex-row items-center gap-2 truncate">
<img v-if="(emoji as InferredEmoji)?.url" :src="(emoji as InferredEmoji)?.url"
:alt="(emoji as InferredEmoji)?.shortcode" class="h-8 align-middle inline not-prose" />
<span v-else-if="(emoji as UnicodeEmoji)?.unicode" class="text-2xl align-middle inline not-prose">
{{ (emoji as UnicodeEmoji)?.unicode }}
</span>
{{ (emoji as InferredEmoji)?.shortcode || (emoji as UnicodeEmoji)?.shortcode }}
</div>
</template>
<script lang="ts" setup>
import type { CustomEmoji } from "@versia/client/schemas";
import type { z } from "zod";
import type { UnicodeEmoji } from "./emoji.ts";
type InferredEmoji = z.infer<typeof CustomEmoji>;
const { emoji } = defineProps<{
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji | null;
}>();
</script>

View file

@ -0,0 +1,140 @@
/**
* Adapted from Cinny's code
* @see https://github.com/cinnyapp/cinny/blob/e6f4eeca8edc85ab64179e545b4e2e8c15763633/src/app/plugins/emoji.ts
*/
import type { CompactEmoji } from "emojibase";
import emojisData from "emojibase-data/en/compact.json";
import joypixels from "emojibase-data/en/shortcodes/joypixels.json";
export type UnicodeEmoji = CompactEmoji & {
shortcode: string;
};
export enum EmojiGroupId {
People = "People",
Nature = "Nature",
Food = "Food",
Activity = "Activity",
Travel = "Travel",
Object = "Object",
Symbol = "Symbol",
Flag = "Flag",
}
export type UnicodeEmojiGroup = {
id: EmojiGroupId;
order: number;
emojis: UnicodeEmoji[];
};
export const getShortcodesFor = (
hexcode: string,
): string[] | string | undefined => joypixels[hexcode];
export const getShortcodeFor = (hexcode: string): string | undefined => {
const shortcode = joypixels[hexcode];
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
};
export const emojiGroups: UnicodeEmojiGroup[] = [
{
id: EmojiGroupId.People,
order: 0,
emojis: [],
},
{
id: EmojiGroupId.Nature,
order: 1,
emojis: [],
},
{
id: EmojiGroupId.Food,
order: 2,
emojis: [],
},
{
id: EmojiGroupId.Activity,
order: 3,
emojis: [],
},
{
id: EmojiGroupId.Travel,
order: 4,
emojis: [],
},
{
id: EmojiGroupId.Object,
order: 5,
emojis: [],
},
{
id: EmojiGroupId.Symbol,
order: 6,
emojis: [],
},
{
id: EmojiGroupId.Flag,
order: 7,
emojis: [],
},
];
export const emojis: UnicodeEmoji[] = [];
function addEmojiToGroup(groupIndex: number, emoji: UnicodeEmoji) {
emojiGroups[groupIndex]?.emojis.push(emoji);
}
function getGroupIndex(emoji: UnicodeEmoji): number | undefined {
switch (emoji.group) {
case 0:
case 1:
return 0; // People
case 3:
return 1; // Nature
case 4:
return 2; // Food
case 6:
return 3; // Activity
case 5:
return 4; // Travel
case 7:
return 5; // Object
case 8:
case undefined:
return 6; // Symbol
case 9:
return 7; // Flag
default:
return undefined; // Unknown group
}
}
for (const emoji of emojisData) {
const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) {
continue;
}
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) {
continue;
}
const em: UnicodeEmoji = {
...emoji,
shortcode: Array.isArray(myShortCodes)
? (myShortCodes[0] as string)
: myShortCodes,
shortcodes: Array.isArray(myShortCodes)
? myShortCodes
: emoji.shortcodes,
};
const groupIndex = getGroupIndex(em);
if (groupIndex !== undefined) {
addEmojiToGroup(groupIndex, em);
emojis.push(em);
}
}

View file

@ -0,0 +1,27 @@
<template>
<Button @focus="() => emit('select', emoji)" @mouseenter="() => emit('select', emoji)" @click="() => emit('pick', emoji)" size="icon" variant="ghost"
class="size-12">
<img v-if="(emoji as InferredEmoji).url" :src="(emoji as InferredEmoji).url"
:alt="(emoji as InferredEmoji).shortcode" class="h-8 align-middle inline not-prose" />
<span v-else-if="(emoji as UnicodeEmoji).unicode" class="text-2xl align-middle inline not-prose">
{{ (emoji as UnicodeEmoji).unicode }}
</span>
</Button>
</template>
<script lang="ts" setup>
import type { CustomEmoji } from "@versia/client/schemas";
import type { z } from "zod";
import { Button } from "~/components/ui/button";
import type { UnicodeEmoji } from "./emoji";
const { emoji } = defineProps<{
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji;
}>();
type InferredEmoji = z.infer<typeof CustomEmoji>;
const emit = defineEmits<{
select: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
}>();
</script>

View file

@ -0,0 +1,138 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<slot />
</PopoverTrigger>
<PopoverContent class="p-0 w-fit">
<div class="grid-cols-[minmax(0,1fr)_auto] gap-0 grid divide-x *:h-112 *:overflow-y-auto"
orientation="vertical">
<div class="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-0" ref="emojiContainer">
<div class="p-2">
<Input placeholder="Search" v-model="filter" />
</div>
<VList :data="virtualizedItems" #default="{ item }" class="relative" :style="{
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
}">
<CategoryHeader :key="item.headerId" v-if="item.type === 'header'" :category-name="item.name" />
<div v-else-if="item.type === 'emoji-row'" :key="item.rowId" class="flex gap-1 p-2">
<Emoji v-for="emoji in item.emojis" :key="getEmojiKey(emoji)" :emoji="emoji"
@select="(e) => selectedEmoji = e" @pick="e => {
emit('pick', e); open = false;
}" />
</div>
</VList>
<EmojiDisplay :emoji="selectedEmoji" :style="{
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
}" />
</div>
<Sidebar :categories="categories" @select="scrollToCategory" />
</div>
</PopoverContent>
</Popover>
</template>
<script lang="ts" setup>
import type { CustomEmoji } from "@versia/client/schemas";
import { VList } from "virtua/vue";
import type { z } from "zod";
import { Input } from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import CategoryHeader from "./category-header.vue";
import EmojiDisplay from "./display.vue";
import { type EmojiGroupId, type UnicodeEmoji, emojiGroups } from "./emoji.ts";
import Emoji from "./emoji.vue";
import Sidebar from "./sidebar.vue";
import { EMOJI_PER_ROW, getVirtualizedItems } from "./virtual.ts";
const emit = defineEmits<{
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
}>();
const open = ref(false);
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
null,
);
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
const filter = ref("");
const customEmojis = computed(() => identity.value?.emojis ?? []);
const customEmojiCategories = computed(() => {
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
for (const emoji of customEmojis.value) {
const categoryName = emoji.category || "Uncategorized";
if (!categories[categoryName]) {
categories[categoryName] = [];
}
categories[categoryName]?.push(emoji);
}
return categories;
});
const categories = computed(() => {
const customCategories = Object.entries(customEmojiCategories.value).map(
([name, emojis]) => ({
name,
src: (emojis[0]?.url as string) || "",
}),
);
const groupCategories = emojiGroups.map((group) => ({
name: group.id,
groupId: group.id,
}));
return [...customCategories, ...groupCategories];
});
const virtualizedItems = computed(() =>
getVirtualizedItems(customEmojiCategories.value, filter.value),
);
const getEmojiKey = (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
if ("url" in emoji) {
return `custom-${emoji.shortcode}`;
}
return `unicode-${emoji.shortcode}`;
};
const scrollToCategory = (category: {
name: string;
groupId?: EmojiGroupId;
src?: string;
}) => {
const categoryId = category.groupId || `custom-${category.name}`;
const headerIndex = virtualizedItems.value.findIndex(
(item) => item.type === "header" && item.categoryId === categoryId,
);
const child = emojiContainer.value?.children[1];
if (headerIndex !== -1 && child) {
// Estimate scroll position based on item heights
// Headers are approximately 38px, emoji rows are approximately 64px
let scrollTop = 0;
for (let i = 0; i < headerIndex; i++) {
const item = virtualizedItems.value[i];
if (item?.type === "header") {
scrollTop += 38;
} else if (item?.type === "emoji-row") {
scrollTop += 64;
}
}
child.scrollTo({
top: scrollTop,
behavior: "smooth",
});
}
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="grid gap-1 bg-transparent p-2">
<Button v-for="category in categories" :key="category.name" size="icon" variant="ghost" @click="() => emit('select', category)">
<component v-if="category.groupId" :is="emojiGroupIconMap[category.groupId]" class="size-6 text-primary" />
<img v-else-if="category.src" :src="category.src" class="size-6 align-middle inline not-prose" role="presentation" />
</Button>
</div>
</template>
<script lang="ts" setup>
import {
Box,
CarFront,
Flag,
Leaf,
Percent,
Pizza,
Smile,
Volleyball,
} from "lucide-vue-next";
import type { FunctionalComponent } from "vue";
import { Button } from "~/components/ui/button";
import { EmojiGroupId } from "./emoji";
const { categories } = defineProps<{
categories: {
name: string;
groupId?: EmojiGroupId;
src?: string;
}[];
}>();
const emit = defineEmits<{
select: [category: { name: string; groupId?: EmojiGroupId; src?: string }];
}>();
const emojiGroupIconMap: Record<EmojiGroupId, FunctionalComponent> = {
[EmojiGroupId.People]: Smile,
[EmojiGroupId.Nature]: Leaf,
[EmojiGroupId.Food]: Pizza,
[EmojiGroupId.Activity]: Volleyball,
[EmojiGroupId.Travel]: CarFront,
[EmojiGroupId.Object]: Box,
[EmojiGroupId.Symbol]: Percent,
[EmojiGroupId.Flag]: Flag,
};
</script>

View file

@ -0,0 +1,117 @@
import type { CustomEmoji } from "@versia/client/schemas";
import { go } from "fuzzysort";
import { nanoid } from "nanoid";
import type { z } from "zod";
import { type UnicodeEmoji, emojiGroups } from "./emoji";
export const EMOJI_PER_ROW = 7;
export type VirtualizedItem =
| { headerId: string; type: "header"; name: string; categoryId: string }
| {
rowId: string;
type: "emoji-row";
emojis: (z.infer<typeof CustomEmoji> | UnicodeEmoji)[];
};
export const getVirtualizedItems = (
customCategories: Record<string, z.infer<typeof CustomEmoji>[]>,
searchQuery?: string,
): VirtualizedItem[] => {
const items: VirtualizedItem[] = [];
// Add custom emoji categories first
for (const [categoryName, categoryEmojis] of Object.entries(
customCategories,
)) {
// Add category header
items.push({
headerId: nanoid(),
type: "header",
name: categoryName,
categoryId: `custom-${categoryName}`,
});
// Add emoji rows for this category
for (let i = 0; i < categoryEmojis.length; i += EMOJI_PER_ROW) {
items.push({
rowId: nanoid(),
type: "emoji-row",
emojis: categoryEmojis.slice(i, i + EMOJI_PER_ROW),
});
}
}
// Add unicode emoji groups
for (const group of emojiGroups) {
if (group.emojis.length === 0) {
continue;
}
// Add group header
items.push({
headerId: nanoid(),
type: "header",
name: group.id,
categoryId: group.id,
});
// Add emoji rows for this group
for (let i = 0; i < group.emojis.length; i += EMOJI_PER_ROW) {
items.push({
rowId: nanoid(),
type: "emoji-row",
emojis: group.emojis.slice(i, i + EMOJI_PER_ROW),
});
}
}
// If search query is provided, add extra category for search results
// with emojis that contain the search query in their shortcode
// ordered with fuzzysort
if (searchQuery) {
const customEmojiMatches = Object.values(customCategories)
.flat()
.filter((emoji) =>
emoji.shortcode
.toLowerCase()
.includes(searchQuery.toLowerCase()),
);
const unicodeEmojiMatches = emojiGroups
.flatMap((group) => group.emojis)
.filter((emoji) =>
emoji.shortcode
.toLowerCase()
.includes(searchQuery.toLowerCase()),
);
const results = go(
searchQuery,
[...customEmojiMatches, ...unicodeEmojiMatches],
{
key: "shortcode",
limit: 20,
},
);
items.splice(0, 0, {
headerId: nanoid(),
type: "header",
name: "Search Results",
categoryId: "search-results",
});
for (let i = 0; i < results.length; i += EMOJI_PER_ROW) {
const emojis = results
.slice(i, i + EMOJI_PER_ROW)
.map((result) => result.obj);
items.splice(1 + i / EMOJI_PER_ROW, 0, {
rowId: nanoid(),
type: "emoji-row",
emojis,
});
}
}
return items;
};

View file

@ -1,7 +1,7 @@
<template>
<HoverCard @update:open="(open) => open && accounts === null && refreshReactions()">
<HoverCardTrigger as-child>
<Button variant="secondary" size="sm" class="gap-2">
<Button @click="reaction.me ? unreact() : react()" :variant="reaction.me ? 'secondary' : 'outline'" size="sm" class="gap-2">
<img v-if="emoji" :src="emoji.url" :alt="emoji.shortcode"
class="h-[1lh] align-middle inline not-prose" />
<span v-else>
@ -34,6 +34,7 @@ import type {
CustomEmoji,
NoteReaction,
} from "@versia/client/schemas";
import { toast } from "vue-sonner";
import type { z } from "zod";
import Spinner from "~/components/graphics/spinner.vue";
import Avatar from "~/components/profiles/avatar.vue";
@ -43,6 +44,7 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "~/components/ui/hover-card";
import * as m from "~/paraglide/messages.js";
import { getLocale } from "~/paraglide/runtime.js";
const { reaction, emoji, statusId } = defineProps<{
@ -70,4 +72,30 @@ const refreshReactions = async () => {
accounts.value = accountsData;
};
const react = async () => {
const id = toast.loading(m.gray_stale_antelope_roam());
const { data } = await client.value.createEmojiReaction(
statusId,
reaction.name,
);
toast.dismiss(id);
toast.success(m.main_least_turtle_fall());
useEvent("note:edit", data);
};
const unreact = async () => {
const id = toast.loading(m.many_weary_bat_intend());
const { data } = await client.value.deleteEmojiReaction(
statusId,
reaction.name,
);
toast.dismiss(id);
toast.success(m.aware_even_oryx_race());
useEvent("note:edit", data);
};
</script>

View file

@ -332,5 +332,10 @@
"sharp_alive_anteater_fade": "Which instance?",
"noble_misty_rook_slide": "Put your instance's domain name here.",
"next_hour_jurgen_sprout": "Are you sure you want to delete {amount} emojis?",
"equal_only_crow_file": "Deleting {amount} emojis..."
"equal_only_crow_file": "Deleting {amount} emojis...",
"bald_cool_kangaroo_jump": "React",
"gray_stale_antelope_roam": "Creating reaction...",
"main_least_turtle_fall": "Reaction added!",
"many_weary_bat_intend": "Removing reaction...",
"aware_even_oryx_race": "Reaction removed!"
}

View file

@ -17,7 +17,7 @@ in
pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-oYouqCoLTyVemqURLW0j5MVKqupqRDxP5rkR3reMQvk=";
hash = "sha256-0SForaAvRv84MrJ0ohI924+JImxkMiChHhl10rcyHQM=";
};
nativeBuildInputs = [

View file

@ -60,6 +60,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-vue": "^8.6.0",
"emojibase": "^16.0.0",
"emojibase-data": "^16.0.3",
"fuzzysort": "^3.1.0",
"html-to-text": "^9.0.5",
"lucide-vue-next": "^0.511.0",

View file

@ -92,6 +92,12 @@ importers:
embla-carousel-vue:
specifier: ^8.6.0
version: 8.6.0(vue@3.5.14(typescript@5.8.3))
emojibase:
specifier: ^16.0.0
version: 16.0.0
emojibase-data:
specifier: ^16.0.3
version: 16.0.3(emojibase@16.0.0)
fuzzysort:
specifier: ^3.1.0
version: 3.1.0
@ -2893,6 +2899,15 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
emojibase-data@16.0.3:
resolution: {integrity: sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q==}
peerDependencies:
emojibase: '*'
emojibase@16.0.0:
resolution: {integrity: sha512-Nw2m7JLIO4Ou2X/yZPRNscHQXVbbr6SErjkJ7EooG7MbR3yDZszCv9KTizsXFc7yZl0n3WF+qUKIC/Lw6H9xaQ==}
engines: {node: '>=18.12.0'}
enabled@2.0.0:
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
@ -8870,6 +8885,12 @@ snapshots:
emoji-regex@9.2.2: {}
emojibase-data@16.0.3(emojibase@16.0.0):
dependencies:
emojibase: 16.0.0
emojibase@16.0.0: {}
enabled@2.0.0: {}
encodeurl@2.0.0: {}