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:
parent
bd8c8bf606
commit
46cb58307c
7
kez-chat/web/package-lock.json
generated
7
kez-chat/web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
106
kez-chat/web/src/lib/EmojiButton.svelte
Normal file
106
kez-chat/web/src/lib/EmojiButton.svelte
Normal 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>
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user