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 @@