feat(kez-chat/web): emoji picker + emoji-only message style boost

Compose bar gets a 😀 button. Click → emoji-picker-element (the
canonical web component, ~140 KB) lazy-loads on first open and stays
cached after. Pick → inserts at cursor position, focus returns to
input. Click-outside closes the popover.

Bonus: emoji-only messages render iMessage-style — no bubble, larger
text. Uses Intl.Segmenter to count grapheme clusters so 👨‍👩‍👧 reads
as 1 not 5 code points.

  • 1 emoji  → text-5xl
  • 2-3      → text-4xl
  • 4-6      → text-3xl
  • 7+ or any letters/digits/punct → normal bubble

Bundle: emoji picker chunked separately via dynamic import (38 KB
gzipped). Initial Messages-page JS only nudged ~159→162 KB.

Native emoji input (macOS ⌃⌘Space, iOS keyboard, Android long-press)
still works — the picker is just for discoverability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-26 23:19:59 -06:00
parent bd8c8bf606
commit 46cb58307c
4 changed files with 184 additions and 6 deletions

View File

@ -13,6 +13,7 @@
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
"canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1",
"nostr-tools": "^2.23.5",
"svelte-spa-router": "^4.0.1"
@ -4319,6 +4320,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-picker-element": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.29.1.tgz",
"integrity": "sha512-TOiHzu9Dqib3x4MwcAi3wi3RdyT4SoeB4b15AvH1ks4SBwTl7DeebhZ0d3x6dNi4XfNU7IGRZ7NBQllj0RqwrQ==",
"license": "Apache-2.0"
},
"node_modules/enhanced-resolve": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",

View File

@ -15,6 +15,7 @@
"@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9",
"canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1",
"nostr-tools": "^2.23.5",
"svelte-spa-router": "^4.0.1"

View File

@ -0,0 +1,106 @@
<script lang="ts">
// Emoji picker button — toggles a popover with the full Unicode set
// (search, skin tone, category nav) via emoji-picker-element. The
// picker is ~140 KB so we dynamic-import on first click; subsequent
// clicks are instant (module cached).
import { onDestroy } from "svelte";
interface Props {
/** Called with the picked emoji's character. */
onpick: (emoji: string) => void;
}
let { onpick }: Props = $props();
let open = $state(false);
let mountEl: HTMLDivElement | null = $state(null);
let pickerEl: HTMLElement | null = null;
let loading = $state(false);
let loadError = $state<string | null>(null);
async function togglePicker() {
if (open) {
close();
return;
}
open = true;
if (!pickerEl) {
loading = true;
try {
// Side-effect import — registers <emoji-picker> as a custom element.
await import("emoji-picker-element");
pickerEl = document.createElement("emoji-picker") as HTMLElement;
pickerEl.addEventListener("emoji-click", onEmojiClick as EventListener);
// Match the dark-on-light scheme of the rest of the app.
pickerEl.classList.add("light");
if (mountEl) mountEl.appendChild(pickerEl);
} catch (e) {
loadError = (e as Error).message;
} finally {
loading = false;
}
} else if (mountEl && !mountEl.contains(pickerEl)) {
mountEl.appendChild(pickerEl);
}
document.addEventListener("click", onDocumentClick);
}
function close() {
open = false;
document.removeEventListener("click", onDocumentClick);
}
function onEmojiClick(ev: Event) {
const e = ev as CustomEvent<{ unicode: string }>;
onpick(e.detail.unicode);
close();
}
function onDocumentClick(ev: MouseEvent) {
// Close when the user clicks anywhere outside the popover or trigger.
if (mountEl && !mountEl.contains(ev.target as Node)) close();
}
onDestroy(() => {
pickerEl?.removeEventListener("emoji-click", onEmojiClick as EventListener);
document.removeEventListener("click", onDocumentClick);
});
</script>
<div class="relative" bind:this={mountEl}>
<button
type="button"
class="px-2 py-2 text-xl border border-gray-300 rounded hover:bg-gray-50 leading-none"
title="Insert emoji"
onclick={(e) => {
e.stopPropagation();
togglePicker();
}}
>
😀
</button>
{#if open}
<div
class="absolute bottom-full right-0 mb-2 z-50 shadow-lg rounded overflow-hidden bg-white"
>
{#if loading}
<div class="p-4 text-xs text-gray-500">Loading…</div>
{:else if loadError}
<div class="p-4 text-xs text-red-700">Failed: {loadError}</div>
{/if}
<!-- The actual <emoji-picker> custom element is appended here
imperatively by togglePicker() so we can control its lifecycle. -->
</div>
{/if}
</div>
<style>
/* emoji-picker-element renders inside shadow DOM; only its outer size
is themeable here. Width comes from the component itself (~340px). */
:global(emoji-picker) {
height: 360px;
--background: #fff;
--border-color: #e5e7eb;
}
</style>

View File

@ -11,6 +11,7 @@
type StreamHandle,
} from "../lib/messages.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import EmojiButton from "../lib/EmojiButton.svelte";
import {
appendInbound,
appendOutbound,
@ -31,6 +32,57 @@
);
let composeText = $state("");
let composing = $state(false);
let composeEl: HTMLInputElement | null = $state(null);
/**
* Insert an emoji at the current cursor position in the compose input
* (or append if the input isn't focused). Keeps the cursor right after
* the inserted character so you can keep typing.
*/
function insertEmoji(emoji: string) {
if (!composeEl) {
composeText += emoji;
return;
}
const start = composeEl.selectionStart ?? composeText.length;
const end = composeEl.selectionEnd ?? composeText.length;
composeText = composeText.slice(0, start) + emoji + composeText.slice(end);
// Restore focus + position the caret after the inserted emoji
// (need a tick for the value to flush through the binding).
queueMicrotask(() => {
if (!composeEl) return;
composeEl.focus();
const pos = start + emoji.length;
composeEl.setSelectionRange(pos, pos);
});
}
// Lazy grapheme segmenter — reused across messages.
const graphemeSeg =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
/**
* iMessage-style: if the whole message is just a handful of emoji and
* nothing else, drop the bubble chrome and render it big. We count
* grapheme clusters (so "👨‍👩‍👧" reads as 1, not 5 code points).
*
* "Emoji-only" = the string contains no letters, digits, whitespace,
* or punctuation. That leaves Symbol_Other + emoji bits.
*/
function emojiOnlyBoost(body: string): "lg" | "xl" | "2xl" | null {
const trimmed = body.trim();
if (!trimmed) return null;
if (/[\p{L}\p{N}\p{P}\p{Zs}]/u.test(trimmed)) return null;
const count = graphemeSeg
? [...graphemeSeg.segment(trimmed)].length
: [...trimmed].length;
if (count === 1) return "2xl";
if (count <= 3) return "xl";
if (count <= 6) return "lg";
return null;
}
let pollError = $state<string | null>(null);
let lastPolledAt = $state<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
@ -378,12 +430,22 @@
<div class="flex-1 overflow-y-auto p-4 space-y-2">
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
{@const boost = emojiOnlyBoost(m.body)}
<div class={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>
<div
class={`px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ${m.direction === "out" ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-900"}`}
>
{m.body}
</div>
{#if boost}
<!-- Emoji-only message: drop the bubble chrome, render big. -->
<div
class={`whitespace-pre-wrap break-words leading-none ${boost === "2xl" ? "text-5xl" : boost === "xl" ? "text-4xl" : "text-3xl"} ${m.direction === "out" ? "text-right" : ""}`}
>
{m.body}
</div>
{:else}
<div
class={`px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ${m.direction === "out" ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-900"}`}
>
{m.body}
</div>
{/if}
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "out" ? "text-right" : ""}`}>
{formatTime(m.ts)}
</p>
@ -398,15 +460,17 @@
</div>
<form
class="p-3 border-t border-gray-200 flex gap-2"
class="p-3 border-t border-gray-200 flex gap-2 items-center"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
<EmojiButton onpick={insertEmoji} />
<input
type="text"
bind:value={composeText}
bind:this={composeEl}
placeholder="Type a message…"
class="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded"
autocomplete="off"