improve emoji coverage and add kaomoji

This commit is contained in:
Jon Staab 2026-09-25 13:32:44 -07:00
parent be6b9043b2
commit 1c934576fe
17 changed files with 565 additions and 50 deletions

View file

@ -0,0 +1,203 @@
import {HOUR} from "@welshman/lib"
import type {Locator, Page} from "@playwright/test"
import {composer, emojiButton, expect, message, roomPath, test, users} from "../harness"
const openReactionPicker = async (page: Page, opener: Locator) => {
await opener.click()
const picker = page.locator("emoji-picker").filter({visible: true})
await expect(picker).toHaveCount(1)
return picker
}
const openComposerEmojiPicker = async (page: Page) => {
await page.getByRole("button", {name: "Add emoji"}).click()
const shell = page.locator('[data-component="EmojiPicker"]').filter({visible: true})
await expect(shell).toHaveCount(1)
return shell
}
const searchPicker = async (picker: Locator, annotation: string) => {
await picker.locator("input.search").fill(annotation)
await expect(picker.getByRole("option", {name: new RegExp(annotation)}).first()).toBeVisible()
}
test("Tippy reaction picker shows gestures and flags without CDN fetches", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.bob, "general", "coverage check", at(2, HOUR))
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const cdnRequests: string[] = []
await alice.context().route(/jsdelivr|skypack|fonts\.googleapis|fonts\.gstatic/i, route => {
cdnRequests.push(route.request().url())
return route.continue()
})
const opener = emojiButton(message(alice, "coverage check"))
const picker = await openReactionPicker(alice, opener)
for (const annotation of [
"heart hands",
"pinched fingers",
"flag: India",
"flag: United States",
]) {
await searchPicker(picker, annotation)
}
await alice.keyboard.press("Escape")
expect(cdnRequests).toEqual([])
})
test("reaction picker has no kaomoji and preserves skin tone", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.bob, "general", "skin tone check", at(2, HOUR))
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const opener = emojiButton(message(alice, "skin tone check"))
const picker = await openReactionPicker(alice, opener)
await expect(alice.getByRole("button", {name: "Kaomoji", exact: true})).toHaveCount(0)
await picker.getByRole("button", {name: /Choose a skin tone/}).click()
await picker.getByRole("option", {name: "Medium-Dark"}).click()
await searchPicker(picker, "thumbs up")
await picker
.getByRole("option", {name: /thumbs up/})
.first()
.click()
await expect(message(alice, "skin tone check").getByRole("button", {name: "👍🏾"})).toBeVisible()
})
test("composer inserts emoji and kaomoji at the caret and closes the popover", async ({
seed,
as,
}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const editor = composer(alice)
await expect(editor).toBeVisible()
await editor.click()
await editor.pressSequentially("Hello world")
await editor.evaluate(node => {
const paragraph = node.querySelector("p")
const textNode = paragraph?.firstChild
if (!(textNode instanceof Text)) {
return
}
const range = document.createRange()
range.setStart(textNode, "Hello ".length)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
})
const shell = await openComposerEmojiPicker(alice)
const picker = shell.locator("emoji-picker")
await picker.locator("input.search").fill("grinning face")
await picker
.getByRole("option", {name: /grinning face/})
.first()
.click()
await expect(shell).toHaveCount(0)
await expect(editor).toBeFocused()
await expect(editor).toHaveText("Hello 😀world")
const kaomojiShell = await openComposerEmojiPicker(alice)
await kaomojiShell.getByRole("button", {name: "Kaomoji", exact: true}).click()
await kaomojiShell.getByRole("button", {name: "Shrug", exact: true}).click()
await expect(kaomojiShell).toHaveCount(0)
await expect(editor).toBeFocused()
await expect(editor).toContainText("¯\\_(ツ)_/¯")
})
test("Emoji → Kaomoji → Emoji still selects emoji", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const shell = await openComposerEmojiPicker(alice)
await shell.getByRole("button", {name: "Kaomoji", exact: true}).click()
await expect(shell.getByRole("button", {name: "Shrug", exact: true})).toBeVisible()
await shell.getByRole("button", {name: "Emoji", exact: true}).click()
const picker = shell.locator("emoji-picker")
await expect(picker).toBeVisible()
await picker.locator("input.search").fill("party popper")
await picker.getByRole("option", {name: /party popper/}).click()
await expect(composer(alice)).toContainText("🎉")
})
test("mobile picker keeps a reduced column count", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.bob, "general", "mobile picker", at(2, HOUR))
})
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
// Hover reaction buttons are desktop-only; phones open the picker from the message menu.
await expect(alice.getByRole("button", {name: "Open space menu"})).toBeVisible()
await message(alice, "mobile picker").click()
await alice.getByRole("button", {name: "React"}).click()
const picker = alice.locator("emoji-picker").filter({visible: true})
await expect(picker).toHaveCount(1)
await expect
.poll(() =>
picker.evaluate(el => getComputedStyle(el).getPropertyValue("--num-columns").trim()),
)
.toBe("6")
await searchPicker(picker, "party popper")
await picker.getByRole("option", {name: /party popper/}).click()
await expect(message(alice, "mobile picker")).toContainText("🎉")
})

View file

@ -11,6 +11,7 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import DictationButton from "@app/components/DictationButton.svelte"
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {getDictation} from "@app/dictation"
@ -163,6 +164,10 @@
<Icon icon={GallerySend} />
{/if}
</Button>
<EditorEmojiButton
{editor}
disabled={$uploading || disabled}
class="button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors" />
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} />
</div>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -13,8 +12,8 @@
const {event, pubkeys}: Props = $props()
const onEmoji = async (emoji: NativeEmoji) => {
const reaction = await $reactions.react(event, emoji.unicode)
const onEmoji = async (emoji: string) => {
const reaction = await $reactions.react(event, emoji)
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
}

View file

@ -1,5 +1,4 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
@ -30,10 +29,10 @@
const tile = "button h-auto flex-col gap-1.5 py-4 text-xs"
const onEmoji = async (emoji: NativeEmoji) => {
const onEmoji = async (emoji: string) => {
history.back()
const reaction = await $reactions.react(event, emoji.unicode)
const reaction = await $reactions.react(event, emoji)
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
}

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type {Editor} from "@welshman/editor"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import {escapeHtml} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
type Props = {
editor: Promise<Editor>
class?: string
disabled?: boolean
}
const {editor, class: className, disabled = false}: Props = $props()
const onEmoji = async (emoji: string) => {
const ed = await editor
ed.chain().focus().insertContent(escapeHtml(emoji)).run()
}
</script>
<EmojiButton
aria-label="Add emoji"
data-tip="Emoji"
{onEmoji}
allowKaomoji
{disabled}
class={className}>
<Icon icon={SmileCircle} />
</EmojiButton>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
@ -21,8 +20,7 @@
const h = tagValue(tagSpec("h"), event.tags)
const onEmoji = (emoji: NativeEmoji) =>
publishReaction(event, {content: emoji.unicode, tags: []}, {url, h})
const onEmoji = (emoji: string) => publishReaction(event, {content: emoji, tags: []}, {url, h})
</script>
{#if ENABLE_ZAPS && !hideZap}

View file

@ -1,5 +1,4 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {seen} from "@welshman/util"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
@ -28,7 +27,7 @@
const createReaction = async (values: EventContent) =>
publishReaction(event, values, {url, urls: await getRelays()})
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
const onEmoji = (emoji: string) => createReaction({content: emoji, tags: []})
</script>
<ReactionSummary {url} {event} {context} {deleteReaction} {createReaction} {reactionClass}>

View file

@ -13,6 +13,7 @@
import type {TippyController} from "@lib/components/Tippy.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
import DictationButton from "@app/components/DictationButton.svelte"
import CommandArgBar from "@app/components/CommandArgBar.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
@ -187,6 +188,10 @@
<Icon icon={WidgetAdd} />
</Tippy>
</Button>
<EditorEmojiButton
{editor}
disabled={$uploading}
class="join-item h-10 w-10 min-w-10 button button-neutral" />
</div>
<div class="chat-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />

View file

@ -1,5 +1,4 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import EmojiButton from "@lib/components/EmojiButton.svelte"
@ -16,11 +15,11 @@
const shouldProtect = $relays.hasNip(url, 70)
const onEmoji = async (emoji: NativeEmoji) => {
const onEmoji = async (emoji: string) => {
const protect = await shouldProtect
const room = tagValue(tagSpec("h"), event.tags)
const command = await $reactions.react(event, emoji.unicode, writer => {
const command = await $reactions.react(event, emoji, writer => {
writer.setProtected(protect)
if (room) {

View file

@ -1,5 +1,4 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import * as nip19 from "nostr-tools/nip19"
import {getIdOrAddress, tagSpec, tagValue, toNostrURI} from "@welshman/util"
@ -54,11 +53,11 @@
const isPinned = $derived($pinIds.includes(event.id))
const tile = "button h-auto flex-col gap-1.5 py-4 text-xs"
const onEmoji = async (emoji: NativeEmoji) => {
const onEmoji = async (emoji: string) => {
history.back()
const protect = await $relays.hasNip(url, 70)
const command = await $reactions.react(event, emoji.unicode, w => w.setProtected(protect))
const command = await $reactions.react(event, emoji, w => w.setProtected(protect))
command.publishToRelays([url])
}

View file

@ -1,7 +1,6 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
import type {NativeEmoji} from "emoji-picker-element/shared"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
@ -22,8 +21,8 @@
let {amount = $bindable(), content = $bindable(), children}: Props = $props()
const onEmoji = (emoji: NativeEmoji) => {
content = emoji.unicode
const onEmoji = (emoji: string) => {
content = emoji
}
const selectAmount = (preset: number) => {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {Snippet} from "svelte"
import {between, throttle} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
@ -7,11 +7,27 @@
import type {TippyController} from "@lib/components/Tippy.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
const {tippyParams = {}, onEmoji, class: className, children, ...restProps} = $props()
type Props = {
tippyParams?: Record<string, unknown>
onEmoji: (emoji: string) => void
allowKaomoji?: boolean
class?: string
children?: Snippet
[key: string]: unknown
}
const {
tippyParams = {},
onEmoji,
allowKaomoji = false,
class: className,
children,
...restProps
}: Props = $props()
const open = () => tippy?.show()
const onClick = (emoji: NativeEmoji) => {
const onClick = (emoji: string) => {
onEmoji(emoji)
tippy?.hide()
}
@ -31,14 +47,14 @@
let tippy: Maybe<TippyController> = $state()
</script>
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
<svelte:document onmousemove={tippy?.visible && !allowKaomoji ? onMouseMove : undefined} />
<Button onclick={open} class={className} {...restProps}>
<Tippy
bind:controller={tippy}
class="flex"
component={EmojiPicker}
props={{onClick}}
props={{onClick, allowKaomoji}}
params={{trigger: "manual", interactive: true, ...tippyParams}}>
{@render children?.()}
</Tippy>

View file

@ -1,7 +1,6 @@
<style>
@media (max-width: 450px) {
emoji-picker {
max-width: 100%;
--num-columns: 6;
--category-emoji-size: 1.125rem;
}
@ -9,22 +8,98 @@
</style>
<script lang="ts">
import cx from "classnames"
import "emoji-picker-element"
import emojiDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url"
import type {Emoji} from "emoji-picker-element/shared"
import {onMount} from "svelte"
import type {EmojiClickEvent} from "emoji-picker-element/shared"
import {createSearch} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import {kaomoji} from "@lib/kaomoji"
interface Props {
onClick: (emoji: Emoji) => void
type Props = {
onClick: (emoji: string) => void
allowKaomoji?: boolean
}
const {onClick}: Props = $props()
const {onClick, allowKaomoji = false}: Props = $props()
let element: Element | undefined = $state()
onMount(() => {
element?.addEventListener("emoji-click", (event: any) => onClick(event.detail as Emoji))
const kaomojiSearch = createSearch(kaomoji, {
getValue: entry => entry.value,
fuseOptions: {
keys: ["name", "keywords"],
threshold: 0.4,
},
})
const showEmoji = () => {
showingKaomoji = false
}
const showKaomoji = () => {
showingKaomoji = true
}
const onEmojiClick = ({detail}: EmojiClickEvent) => {
if (detail.unicode) {
onClick(detail.unicode)
}
}
const listen = (picker: HTMLElementTagNameMap["emoji-picker"]) => {
picker.addEventListener("emoji-click", onEmojiClick)
return () => picker.removeEventListener("emoji-click", onEmojiClick)
}
let showingKaomoji = $state(false)
let query = $state("")
</script>
<emoji-picker bind:this={element} data-source={emojiDataUrl} class="m-auto"></emoji-picker>
<div data-component="EmojiPicker" class="emoji-picker">
{#if allowKaomoji}
<div class="flex gap-1 px-2 pt-2" role="group" aria-label="Emoji or kaomoji">
<Button
aria-pressed={!showingKaomoji}
class={cx("button button-sm rounded-full", {
"button-primary": !showingKaomoji,
"button-ghost": showingKaomoji,
})}
onclick={showEmoji}>
Emoji
</Button>
<Button
aria-pressed={showingKaomoji}
class={cx("button button-sm rounded-full", {
"button-primary": showingKaomoji,
"button-ghost": !showingKaomoji,
})}
onclick={showKaomoji}>
Kaomoji
</Button>
</div>
{/if}
<div class="h-100 max-h-[60vh]">
{#if showingKaomoji}
<div class="flex h-full flex-col gap-2 p-2">
<input
type="search"
class="input input-sm"
placeholder="Search"
aria-label="Search kaomoji"
bind:value={query} />
<div class="grid min-h-0 grow grid-cols-2 content-start gap-1 overflow-y-auto">
{#each kaomojiSearch.searchOptions(query.trim()) as entry (entry.name)}
<Button
class="button button-ghost button-sm h-auto justify-start px-2 py-2 text-left font-normal break-all"
aria-label={entry.name}
onclick={() => onClick(entry.value)}>
{entry.value}
</Button>
{/each}
</div>
</div>
{:else}
<emoji-picker {@attach listen} data-source={emojiDataUrl}></emoji-picker>
{/if}
</div>
</div>

View file

@ -425,32 +425,30 @@ body.keyboard-open .room {
box-shadow: var(--shadow-lg);
}
/* emoji picker */
/* emoji picker — the .emoji-picker frame around it supplies the border */
emoji-picker {
--background: var(--surface);
--border-color: var(--surface-more);
--border-radius: var(--radius-2xl);
--border-size: 0;
--button-active-background: var(--surface-more);
--button-hover-background: var(--surface);
--button-hover-background: var(--surface-more);
--indicator-color: var(--primary);
--input-border-color: var(--line);
--input-border-size: var(--border-thin);
--input-border-radius: var(--radius-xl);
--input-padding: 0.4rem 0.7rem;
--input-font-size: 0.85rem;
--input-font-color: var(--content);
--input-placeholder-color: var(--content-subtle);
--outline-color: var(--primary);
width: min(352px, calc(100vw - 2rem));
max-width: 100%;
width: 100%;
height: 100%;
}
/* In a popover the border is what separates the picker from the page, but in a dialog
it sits on a surface of its own color, so it reads as a stray box. */
.dialog emoji-picker {
--border-size: 0;
}
[data-tippy-root]:has(emoji-picker) {
[data-tippy-root]:has([data-component="EmojiPicker"]) {
max-width: min(352px, calc(100vw - 2rem));
}
.tippy-box:has(emoji-picker) {
.tippy-box:has([data-component="EmojiPicker"]) {
overflow: visible;
max-width: none;
}

View file

@ -0,0 +1,11 @@
@layer components {
.emoji-picker {
@apply bg-surface flex max-w-full flex-col overflow-hidden rounded-3xl;
width: 352px;
border: var(--border-thin) solid var(--line);
}
.dialog .emoji-picker {
@apply rounded-none border-none;
}
}

View file

@ -28,6 +28,7 @@
@import "./content-search.css";
@import "./controls.css";
@import "./divider.css";
@import "./emoji-picker.css";
@import "./input.css";
@import "./join.css";
@import "./link.css";

178
src/lib/kaomoji.ts Normal file
View file

@ -0,0 +1,178 @@
export type Kaomoji = {
name: string
value: string
keywords?: string[]
}
export const kaomoji: Kaomoji[] = [
{
name: "Shrug",
value: "¯\\_(ツ)_/¯",
keywords: ["shrug", "whatever", "idk"],
},
{
name: "Table flip",
value: "(╯°□°)╯︵ ┻━┻",
keywords: ["angry", "flip", "rage"],
},
{
name: "Table restore",
value: "┬─┬ノ( º _ ºノ)",
keywords: ["calm", "fix", "restore"],
},
{
name: "Double table flip",
value: "┻━┻ ︵ヽ(`Д´)ノ︵ ┻━┻",
keywords: ["angry", "flip"],
},
{
name: "Lenny",
value: "( ͡° ͜ʖ ͡°)",
keywords: ["lenny", "smug", "wink"],
},
{
name: "Disapproval",
value: "ಠ_ಠ",
keywords: ["disapproval", "look", "judge"],
},
{
name: "Look of disapproval",
value: "ಠ╭╮ಠ",
keywords: ["disapproval", "angry"],
},
{
name: "Bear",
value: "ʕ•ᴥ•ʔ",
keywords: ["bear", "cute", "happy"],
},
{
name: "Hug",
value: "(づ。◕‿‿◕。)づ",
keywords: ["hug", "love", "give"],
},
{
name: "Gimme",
value: "༼ つ ◕_◕ ༽つ",
keywords: ["give", "want", "hug"],
},
{
name: "Happy",
value: "ヽ(・∀・)ノ",
keywords: ["happy", "wave", "yay"],
},
{
name: "Very happy",
value: "\(^o^)/",
keywords: ["happy", "celebrate", "yay"],
},
{
name: "Love",
value: "♥‿♥",
keywords: ["love", "heart", "happy"],
},
{
name: "Kiss",
value: "(づ ̄ ³ ̄)づ",
keywords: ["kiss", "love", "hug"],
},
{
name: "Cry",
value: "ಥ_ಥ",
keywords: ["cry", "sad", "tears"],
},
{
name: "Sob",
value: "(ಥ﹏ಥ)",
keywords: ["cry", "sob", "sad"],
},
{
name: "Angry",
value: "(╬ಠ益ಠ)",
keywords: ["angry", "mad", "rage"],
},
{
name: "Fighting",
value: "(ง'̀-'́)ง",
keywords: ["fight", "ready", "challenge"],
},
{
name: "Confused",
value: "(・・?)",
keywords: ["confused", "huh", "what"],
},
{
name: "Thinking",
value: "(・・ ) ?",
keywords: ["think", "hmm", "confused"],
},
{
name: "Nervous",
value: "(´・_・`)",
keywords: ["nervous", "worried", "awkward"],
},
{
name: "Facepalm",
value: "(-‸ლ)",
keywords: ["facepalm", "oops", "fail"],
},
{
name: "Celebrate",
value: "٩(◕‿◕。)۶",
keywords: ["celebrate", "party", "happy"],
},
{
name: "Sparkle happy",
value: "✧٩(ˊωˋ*)و✧",
keywords: ["happy", "sparkle", "cheer"],
},
{
name: "Cool",
value: "(⌐■_■)",
keywords: ["cool", "deal with it", "sunglasses"],
},
{
name: "Sleepy",
value: "(-_-) zzZ",
keywords: ["sleep", "tired", "zzz"],
},
{
name: "Shocked",
value: "Σ(°△°|||)︴",
keywords: ["shock", "surprise", "gasp"],
},
{
name: "Waves",
value: "ヾ(^∇^)",
keywords: ["wave", "hello", "hi"],
},
{
name: "Flex",
value: "ᕦ(ò_óˇ)ᕤ",
keywords: ["flex", "strong", "muscle"],
},
{
name: "Peek",
value: "(/◕ヮ◕)/",
keywords: ["peek", "hello", "cute"],
},
{
name: "Blush",
value: "(⁄ ⁄•⁄ω⁄•⁄ ⁄)",
keywords: ["blush", "shy", "embarrassed"],
},
{
name: "Meh",
value: "¯\\(°_o)/¯",
keywords: ["meh", "shrug", "dunno"],
},
{
name: "Run",
value: "ε=ε=ε=┌(;*´Д`)ノ",
keywords: ["run", "flee", "panic"],
},
{
name: "Magic",
value: "(ノ◕ヮ◕)ノ*:・゚✧",
keywords: ["magic", "sparkle", "throw"],
},
]