From 1c934576feb6ff5e4c56aa1feaaf1df150e06820 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 25 Sep 2026 13:32:44 -0700 Subject: [PATCH] improve emoji coverage and add kaomoji --- e2e/specs/emoji-picker.spec.ts | 203 ++++++++++++++++++ src/app/components/ChatCompose.svelte | 5 + .../components/ChatMessageEmojiButton.svelte | 5 +- .../components/ChatMessageMenuMobile.svelte | 5 +- src/app/components/EditorEmojiButton.svelte | 31 +++ src/app/components/EventReactButtons.svelte | 4 +- src/app/components/EventReactions.svelte | 3 +- src/app/components/RoomCompose.svelte | 5 + src/app/components/RoomItemEmojiButton.svelte | 5 +- src/app/components/RoomItemMenuMobile.svelte | 5 +- src/app/components/ZapForm.svelte | 5 +- src/lib/components/EmojiButton.svelte | 26 ++- src/lib/components/EmojiPicker.svelte | 97 ++++++++- src/lib/components/base.css | 26 ++- src/lib/components/emoji-picker.css | 11 + src/lib/components/theme.css | 1 + src/lib/kaomoji.ts | 178 +++++++++++++++ 17 files changed, 565 insertions(+), 50 deletions(-) create mode 100644 e2e/specs/emoji-picker.spec.ts create mode 100644 src/app/components/EditorEmojiButton.svelte create mode 100644 src/lib/components/emoji-picker.css create mode 100644 src/lib/kaomoji.ts diff --git a/e2e/specs/emoji-picker.spec.ts b/e2e/specs/emoji-picker.spec.ts new file mode 100644 index 00000000..66e06a63 --- /dev/null +++ b/e2e/specs/emoji-picker.spec.ts @@ -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("🎉") +}) diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte index 4588e672..97af9a03 100644 --- a/src/app/components/ChatCompose.svelte +++ b/src/app/components/ChatCompose.svelte @@ -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 @@ {/if} +
diff --git a/src/app/components/ChatMessageEmojiButton.svelte b/src/app/components/ChatMessageEmojiButton.svelte index 8e34338d..8e6fc555 100644 --- a/src/app/components/ChatMessageEmojiButton.svelte +++ b/src/app/components/ChatMessageEmojiButton.svelte @@ -1,5 +1,4 @@ + + + + diff --git a/src/app/components/EventReactButtons.svelte b/src/app/components/EventReactButtons.svelte index 0cf5df5c..50cff15c 100644 --- a/src/app/components/EventReactButtons.svelte +++ b/src/app/components/EventReactButtons.svelte @@ -1,5 +1,4 @@ {#if ENABLE_ZAPS && !hideZap} diff --git a/src/app/components/EventReactions.svelte b/src/app/components/EventReactions.svelte index 1c884934..bf2b5dc4 100644 --- a/src/app/components/EventReactions.svelte +++ b/src/app/components/EventReactions.svelte @@ -1,5 +1,4 @@ diff --git a/src/app/components/RoomCompose.svelte b/src/app/components/RoomCompose.svelte index b33ecd09..3b6f895b 100644 --- a/src/app/components/RoomCompose.svelte +++ b/src/app/components/RoomCompose.svelte @@ -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 @@ +
diff --git a/src/app/components/RoomItemEmojiButton.svelte b/src/app/components/RoomItemEmojiButton.svelte index 707515e3..7a47a793 100644 --- a/src/app/components/RoomItemEmojiButton.svelte +++ b/src/app/components/RoomItemEmojiButton.svelte @@ -1,5 +1,4 @@ - + + +
+ {/if} +
+ {#if showingKaomoji} +
+ +
+ {#each kaomojiSearch.searchOptions(query.trim()) as entry (entry.name)} + + {/each} +
+
+ {:else} + + {/if} +
+ diff --git a/src/lib/components/base.css b/src/lib/components/base.css index c8456715..e40f0ff2 100644 --- a/src/lib/components/base.css +++ b/src/lib/components/base.css @@ -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; } diff --git a/src/lib/components/emoji-picker.css b/src/lib/components/emoji-picker.css new file mode 100644 index 00000000..54ad967a --- /dev/null +++ b/src/lib/components/emoji-picker.css @@ -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; + } +} diff --git a/src/lib/components/theme.css b/src/lib/components/theme.css index 7ea5c179..fd18fc8f 100644 --- a/src/lib/components/theme.css +++ b/src/lib/components/theme.css @@ -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"; diff --git a/src/lib/kaomoji.ts b/src/lib/kaomoji.ts new file mode 100644 index 00000000..e5965ef1 --- /dev/null +++ b/src/lib/kaomoji.ts @@ -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"], + }, +]