diff --git a/kez-chat/web/package-lock.json b/kez-chat/web/package-lock.json index 94cf436..3293800 100644 --- a/kez-chat/web/package-lock.json +++ b/kez-chat/web/package-lock.json @@ -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", diff --git a/kez-chat/web/package.json b/kez-chat/web/package.json index 72cf5c8..0196881 100644 --- a/kez-chat/web/package.json +++ b/kez-chat/web/package.json @@ -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" diff --git a/kez-chat/web/src/lib/EmojiButton.svelte b/kez-chat/web/src/lib/EmojiButton.svelte new file mode 100644 index 0000000..f47257d --- /dev/null +++ b/kez-chat/web/src/lib/EmojiButton.svelte @@ -0,0 +1,106 @@ + + +
+ + + {#if open} +
+ {#if loading} +
Loading…
+ {:else if loadError} +
Failed: {loadError}
+ {/if} + +
+ {/if} +
+ + diff --git a/kez-chat/web/src/routes/Messages.svelte b/kez-chat/web/src/routes/Messages.svelte index cb55b22..837c58b 100644 --- a/kez-chat/web/src/routes/Messages.svelte +++ b/kez-chat/web/src/routes/Messages.svelte @@ -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(null); let lastPolledAt = $state(null); let pollTimer: ReturnType | null = null; @@ -378,12 +430,22 @@
{#each activeConv.messages as m (m.seq + ":" + m.direction)} + {@const boost = emojiOnlyBoost(m.body)}
-
- {m.body} -
+ {#if boost} + +
+ {m.body} +
+ {:else} +
+ {m.body} +
+ {/if}

{formatTime(m.ts)}

@@ -398,15 +460,17 @@
{ e.preventDefault(); send(); }} > +