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",
|
"@noble/hashes": "^1.5.0",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"canonicalize": "^2.0.0",
|
"canonicalize": "^2.0.0",
|
||||||
|
"emoji-picker-element": "^1.29.1",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"nostr-tools": "^2.23.5",
|
"nostr-tools": "^2.23.5",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"svelte-spa-router": "^4.0.1"
|
||||||
@ -4319,6 +4320,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"canonicalize": "^2.0.0",
|
"canonicalize": "^2.0.0",
|
||||||
|
"emoji-picker-element": "^1.29.1",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"nostr-tools": "^2.23.5",
|
"nostr-tools": "^2.23.5",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"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,
|
type StreamHandle,
|
||||||
} from "../lib/messages.js";
|
} from "../lib/messages.js";
|
||||||
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||||
|
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||||
import {
|
import {
|
||||||
appendInbound,
|
appendInbound,
|
||||||
appendOutbound,
|
appendOutbound,
|
||||||
@ -31,6 +32,57 @@
|
|||||||
);
|
);
|
||||||
let composeText = $state("");
|
let composeText = $state("");
|
||||||
let composing = $state(false);
|
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 pollError = $state<string | null>(null);
|
||||||
let lastPolledAt = $state<string | null>(null);
|
let lastPolledAt = $state<string | null>(null);
|
||||||
let pollTimer: ReturnType<typeof setInterval> | 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">
|
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
|
{#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={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>
|
||||||
|
{#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
|
<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"}`}
|
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}
|
{m.body}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "out" ? "text-right" : ""}`}>
|
<p class={`mt-1 text-xs text-gray-400 ${m.direction === "out" ? "text-right" : ""}`}>
|
||||||
{formatTime(m.ts)}
|
{formatTime(m.ts)}
|
||||||
</p>
|
</p>
|
||||||
@ -398,15 +460,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<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) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
send();
|
send();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<EmojiButton onpick={insertEmoji} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={composeText}
|
bind:value={composeText}
|
||||||
|
bind:this={composeEl}
|
||||||
placeholder="Type a message…"
|
placeholder="Type a message…"
|
||||||
class="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded"
|
class="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user