eleven / messenger Public

web: send button, reactions, inline attachments, auto-link, iOS push fix

- composer: inline up-arrow send button (hidden until typed/attached); platform-aware Enter via shouldSend.

- emoji reactions UI: per-message picker + toggleable count chips, live over WS.

- attachments: encrypt+upload files; images render inline, other files as download chips.

- auto-link URLs in message text (safe DOM nodes, never innerHTML).

- iOS push fix: await serviceWorker.ready before subscribing; onboarding surfaces failures instead of silently finishing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell b625d1e1fc88da08ca1c0cd2917d8a1165af86ca parent c1a1472
4 files changed, +571 −18
  • web/pages/index.html +14 −2
  • web/static/chat.js +384 −13
  • web/static/push.js +8 −1
  • web/static/style.css +165 −2
diff --git a/web/pages/index.html b/web/pages/index.html
index 2e01a24..276d094 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -29,9 +29,20 @@
</header>
<div class="banner hidden" id="banner"></div>
<div id="feed"></div>
+ <div class="composer-preview hidden" id="composer-preview"></div>
<form class="composer" id="composer" autocomplete="off">
- <textarea id="text" placeholder="Message" rows="1" autocomplete="off"></textarea>
- <button type="submit">Send</button>
+ <button type="button" class="attach-btn" id="attach-btn" aria-label="Attach a photo or file">+</button>
+ <input type="file" id="file-input" hidden />
+ <div class="composer-field">
+ <textarea id="text" placeholder="Message" rows="1" autocomplete="off"></textarea>
+ <button type="submit" class="send-btn" id="send-btn" aria-label="Send message" hidden>
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
+ stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+ <line x1="12" y1="19" x2="12" y2="5"></line>
+ <polyline points="6 11 12 5 18 11"></polyline>
+ </svg>
+ </button>
+ </div>
</form>
</div>
</template>
@@ -75,6 +86,7 @@
<p>Get an alert when there's a new message — even when the chat is closed.</p>
<div class="row"><button id="push-enable">Enable notifications</button></div>
<div class="row"><button id="push-skip" class="secondary">Not now</button></div>
+ <p id="push-note" class="banner warn hidden" style="margin-top:12px"></p>
</div>
</div>
</template>
diff --git a/web/static/chat.js b/web/static/chat.js
index 9f96200..e7f43d9 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -1,4 +1,8 @@
-import { importKey, encrypt, decrypt } from "/static/crypto.js";
+import { importKey, encrypt, decrypt, encryptBytes, decryptBytes } from "/static/crypto.js";
+import { shouldSend } from "/static/ui.js";
+import { tokenizeText, formatBytes } from "/static/format.js";
+
+const MAX_UPLOAD = 10 * 1024 * 1024; // keep in step with maxUploadBytes (main.go)
import {
needsHomeScreen,
pushSupported,
@@ -13,6 +17,9 @@ import {
const SESSION_KEY = "lchat_session";
+// The fixed reaction set, matching the server allowlist (hub.go).
+const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
+
const app = document.getElementById("app");
const session = loadSession();
@@ -91,11 +98,25 @@ function onboardPush(session) {
resolve();
};
const enable = document.getElementById("push-enable");
+ const note = document.getElementById("push-note");
enable.onclick = async () => {
enable.disabled = true;
enable.textContent = "Enabling…";
- await subscribePush(session);
- finish();
+ const result = await subscribePush(session);
+ if (result === "on") {
+ finish();
+ return;
+ }
+ // Didn't actually turn on — don't pretend it did. Let them retry or skip.
+ enable.disabled = false;
+ enable.textContent = "Try again";
+ if (note) {
+ note.textContent =
+ result === "denied"
+ ? "Notifications are blocked. You can turn them on later from the ⋮ menu."
+ : "Couldn't turn on notifications just now. Try again, or enable them later from the ⋮ menu.";
+ note.classList.remove("hidden");
+ }
};
document.getElementById("push-skip").onclick = finish;
});
@@ -311,6 +332,14 @@ async function start(session) {
const seen = new Set();
let lastId = 0;
+ // Reaction state, keyed by message id:
+ // reactionState: latest [{emoji, count}] from the server
+ // myReactions: Set of emojis *this* user has applied (drives the toggle UI)
+ // reactionEls: the per-message DOM container to repaint
+ const reactionState = new Map();
+ const myReactions = new Map();
+ const reactionEls = new Map();
+
async function render(msg) {
if (seen.has(msg.id)) return;
seen.add(msg.id);
@@ -321,6 +350,14 @@ async function start(session) {
} catch {
return; // wrong key or corrupt — skip
}
+
+ // Seed reaction state from history (live "msg" frames carry none yet).
+ if (Array.isArray(msg.reactions)) {
+ reactionState.set(msg.id, msg.reactions.map((r) => ({ emoji: r.emoji, count: r.count })));
+ const mineSet = new Set(msg.reactions.filter((r) => r.mine).map((r) => r.emoji));
+ if (mineSet.size) myReactions.set(msg.id, mineSet);
+ }
+
const mine = payload.name === session.name;
const el = document.createElement("div");
el.className = "msg " + (mine ? "me" : "them");
@@ -330,10 +367,40 @@ async function start(session) {
n.textContent = payload.name;
el.appendChild(n);
}
- const bubble = document.createElement("div");
- bubble.className = "bubble";
- bubble.textContent = payload.text;
- el.appendChild(bubble);
+
+ const bubbleRow = document.createElement("div");
+ bubbleRow.className = "bubble-row";
+
+ const content = document.createElement("div");
+ content.className = "content";
+ if (payload.text) {
+ const bubble = document.createElement("div");
+ bubble.className = "bubble";
+ appendText(bubble, payload.text);
+ content.appendChild(bubble);
+ }
+ if (Array.isArray(payload.attachments)) {
+ for (const att of payload.attachments) content.appendChild(renderAttachment(att));
+ }
+ bubbleRow.appendChild(content);
+
+ const reactBtn = document.createElement("button");
+ reactBtn.type = "button";
+ reactBtn.className = "react-btn";
+ reactBtn.setAttribute("aria-label", "Add reaction");
+ reactBtn.textContent = "🙂";
+ reactBtn.onclick = (e) => {
+ e.stopPropagation();
+ openReactionPicker(reactBtn, (emoji) => toggleReaction(msg.id, emoji));
+ };
+ bubbleRow.appendChild(reactBtn);
+ el.appendChild(bubbleRow);
+
+ const reactions = document.createElement("div");
+ reactions.className = "reactions";
+ el.appendChild(reactions);
+ reactionEls.set(msg.id, reactions);
+
const t = document.createElement("div");
t.className = "time";
t.textContent = formatTime(payload.ts || msg.created_at * 1000);
@@ -341,9 +408,178 @@ async function start(session) {
const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
feed.appendChild(el);
+ renderReactionsFor(msg.id);
if (atBottom || mine) feed.scrollTop = feed.scrollHeight;
}
+ // Repaint the reaction chips for one message from current state.
+ function renderReactionsFor(msgId) {
+ const container = reactionEls.get(msgId);
+ if (!container) return;
+ const list = reactionState.get(msgId) || [];
+ const mineSet = myReactions.get(msgId) || new Set();
+ container.innerHTML = "";
+ for (const r of list) {
+ if (!r.count) continue;
+ const chip = document.createElement("button");
+ chip.type = "button";
+ chip.className = "reaction-chip" + (mineSet.has(r.emoji) ? " mine" : "");
+ chip.textContent = r.emoji + " " + r.count;
+ chip.onclick = () => toggleReaction(msgId, r.emoji);
+ container.appendChild(chip);
+ }
+ }
+
+ // Toggle my reaction: update local state optimistically, then tell the server.
+ // The authoritative counts come back via the "reaction" broadcast.
+ function toggleReaction(msgId, emoji) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ let mineSet = myReactions.get(msgId);
+ if (!mineSet) {
+ mineSet = new Set();
+ myReactions.set(msgId, mineSet);
+ }
+ const adding = !mineSet.has(emoji);
+ let list = reactionState.get(msgId);
+ if (!list) {
+ list = [];
+ reactionState.set(msgId, list);
+ }
+ const idx = list.findIndex((r) => r.emoji === emoji);
+ if (adding) {
+ mineSet.add(emoji);
+ if (idx >= 0) list[idx].count++;
+ else list.push({ emoji, count: 1 });
+ } else {
+ mineSet.delete(emoji);
+ if (idx >= 0) {
+ list[idx].count--;
+ if (list[idx].count <= 0) list.splice(idx, 1);
+ }
+ }
+ renderReactionsFor(msgId);
+ ws.send(JSON.stringify({ type: adding ? "react" : "unreact", message_id: msgId, emoji }));
+ }
+
+ let pickerEl = null;
+ let pickerCloser = null;
+ function closeReactionPicker() {
+ if (pickerEl) pickerEl.remove();
+ pickerEl = null;
+ if (pickerCloser) {
+ document.removeEventListener("click", pickerCloser);
+ document.removeEventListener("scroll", pickerCloser, true);
+ pickerCloser = null;
+ }
+ }
+ function openReactionPicker(anchorEl, onPick) {
+ closeReactionPicker();
+ pickerEl = document.createElement("div");
+ pickerEl.className = "emoji-picker";
+ for (const e of REACTIONS) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "emoji-opt";
+ b.textContent = e;
+ b.onclick = (ev) => {
+ ev.stopPropagation();
+ onPick(e);
+ closeReactionPicker();
+ };
+ pickerEl.appendChild(b);
+ }
+ document.body.appendChild(pickerEl);
+ const r = anchorEl.getBoundingClientRect();
+ let top = r.top - pickerEl.offsetHeight - 8;
+ if (top < 8) top = r.bottom + 8;
+ let left = Math.min(r.left, window.innerWidth - pickerEl.offsetWidth - 8);
+ left = Math.max(8, left);
+ pickerEl.style.top = top + "px";
+ pickerEl.style.left = left + "px";
+ pickerCloser = (ev) => {
+ if (pickerEl && !pickerEl.contains(ev.target)) closeReactionPicker();
+ };
+ setTimeout(() => {
+ document.addEventListener("click", pickerCloser);
+ document.addEventListener("scroll", pickerCloser, true);
+ }, 0);
+ }
+
+ // ---- attachments ----
+ async function fetchBlob(id) {
+ const res = await fetch(
+ `/api/blob/${encodeURIComponent(id)}?token=${encodeURIComponent(session.token)}`
+ );
+ if (!res.ok) return null;
+ return new Uint8Array(await res.arrayBuffer());
+ }
+
+ // Build the element for one attachment. Images render inline (decrypted to an
+ // object URL); everything else becomes a download chip.
+ function renderAttachment(att) {
+ if (att.type && att.type.startsWith("image/")) {
+ const wrap = document.createElement("div");
+ wrap.className = "msg-image-wrap";
+ const img = document.createElement("img");
+ img.className = "msg-image";
+ img.alt = att.name || "image";
+ if (att.w && att.h) img.style.aspectRatio = att.w + " / " + att.h;
+ loadAttachmentImage(att, img);
+ wrap.appendChild(img);
+ return wrap;
+ }
+ const a = document.createElement("a");
+ a.className = "file-chip";
+ a.href = "#";
+ a.textContent =
+ "📎 " + (att.name || "file") + (att.size ? " · " + formatBytes(att.size) : "");
+ a.onclick = (e) => {
+ e.preventDefault();
+ downloadAttachment(att);
+ };
+ return a;
+ }
+
+ async function loadAttachmentImage(att, img) {
+ try {
+ const bytes = await fetchBlob(att.id);
+ if (!bytes) return;
+ const plain = await decryptBytes(key, bytes);
+ const url = URL.createObjectURL(
+ new Blob([plain], { type: att.type || "application/octet-stream" })
+ );
+ const wasAtBottom =
+ feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
+ img.onload = () => {
+ if (wasAtBottom) feed.scrollTop = feed.scrollHeight;
+ };
+ img.src = url;
+ } catch {
+ // decryption failed (wrong key / corrupt) — leave the placeholder
+ }
+ }
+
+ async function downloadAttachment(att) {
+ const bytes = await fetchBlob(att.id);
+ if (!bytes) return;
+ let plain;
+ try {
+ plain = await decryptBytes(key, bytes);
+ } catch {
+ return;
+ }
+ const url = URL.createObjectURL(
+ new Blob([plain], { type: att.type || "application/octet-stream" })
+ );
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = att.name || "download";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
+ }
+
// 1) Load recent history.
const history = await fetchMessages(session.token, 0);
for (const m of history) await render(m);
@@ -363,7 +599,12 @@ async function start(session) {
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
- if (msg.type === "msg") render(msg);
+ if (msg.type === "msg") {
+ render(msg);
+ } else if (msg.type === "reaction") {
+ reactionState.set(msg.message_id, msg.reactions || []);
+ renderReactionsFor(msg.message_id);
+ }
} catch {}
};
ws.onclose = () => {
@@ -374,24 +615,135 @@ async function start(session) {
}
connect();
- // 3) Sending. Enter sends; Shift+Enter inserts a newline (it's a textarea).
+ // 3) Sending. Desktop: Enter sends, Shift+Enter = newline. Mobile/touch:
+ // Enter = newline, the on-screen button sends (see shouldSend).
const form = document.getElementById("composer");
const input = document.getElementById("text");
+ const sendBtn = document.getElementById("send-btn");
+ const attachBtn = document.getElementById("attach-btn");
+ const fileInput = document.getElementById("file-input");
+ const previewBox = document.getElementById("composer-preview");
+ const coarse =
+ window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
+
+ // The attachment staged for the next send (uploaded already), or null.
+ let pendingAttachment = null;
+
+ const syncSendBtn = () => {
+ sendBtn.hidden = input.value.trim() === "" && !pendingAttachment;
+ };
const autosize = () => {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 140) + "px";
};
+
+ function clearPreview() {
+ pendingAttachment = null;
+ previewBox.classList.add("hidden");
+ previewBox.innerHTML = "";
+ syncSendBtn();
+ }
+
+ function showPreview(file, uploading) {
+ previewBox.classList.remove("hidden");
+ previewBox.innerHTML = "";
+ const chip = document.createElement("div");
+ chip.className = "preview-chip";
+ if (file.type && file.type.startsWith("image/")) {
+ const img = document.createElement("img");
+ img.src = URL.createObjectURL(file);
+ chip.appendChild(img);
+ } else {
+ const ic = document.createElement("div");
+ ic.className = "preview-file";
+ ic.textContent = "📎";
+ chip.appendChild(ic);
+ }
+ const label = document.createElement("div");
+ label.className = "preview-name";
+ label.textContent = uploading
+ ? "Uploading… " + file.name
+ : file.name + " · " + formatBytes(file.size);
+ chip.appendChild(label);
+ if (!uploading) {
+ const rm = document.createElement("button");
+ rm.type = "button";
+ rm.className = "preview-remove";
+ rm.textContent = "×";
+ rm.setAttribute("aria-label", "Remove attachment");
+ rm.onclick = clearPreview;
+ chip.appendChild(rm);
+ }
+ previewBox.appendChild(chip);
+ }
+
+ // Encrypt and upload a file, returning its attachment metadata (or null).
+ async function uploadFile(file) {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ if (bytes.length > MAX_UPLOAD) {
+ showBanner("That file is too big (max 10 MB).", true);
+ return null;
+ }
+ const enc = await encryptBytes(key, bytes);
+ const res = await fetch(
+ `/api/upload?token=${encodeURIComponent(session.token)}`,
+ { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: enc }
+ );
+ if (!res.ok) return null;
+ const { id } = await res.json();
+ const meta = {
+ id,
+ name: file.name || "file",
+ type: file.type || "application/octet-stream",
+ size: bytes.length,
+ };
+ if (meta.type.startsWith("image/")) {
+ try {
+ const bmp = await createImageBitmap(file);
+ meta.w = bmp.width;
+ meta.h = bmp.height;
+ if (bmp.close) bmp.close();
+ } catch {}
+ }
+ return meta;
+ }
+
+ attachBtn.onclick = () => fileInput.click();
+ fileInput.onchange = async () => {
+ const file = fileInput.files[0];
+ fileInput.value = ""; // allow picking the same file again later
+ if (!file) return;
+ showBanner("", false);
+ showPreview(file, true);
+ const meta = await uploadFile(file);
+ if (!meta) {
+ clearPreview();
+ showBanner("Couldn't upload that file. Try again.", true);
+ return;
+ }
+ pendingAttachment = meta;
+ showPreview(file, false);
+ syncSendBtn();
+ input.focus();
+ };
+
const send = async () => {
const text = input.value.trim();
- if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
+ if ((!text && !pendingAttachment) || !ws || ws.readyState !== WebSocket.OPEN) return;
+ const payload = { name: session.name, text, ts: Date.now() };
+ if (pendingAttachment) payload.attachments = [pendingAttachment];
input.value = "";
+ clearPreview();
autosize();
- const blob = await encrypt(key, { name: session.name, text, ts: Date.now() });
+ const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", blob }));
};
- input.addEventListener("input", autosize);
+ input.addEventListener("input", () => {
+ autosize();
+ syncSendBtn();
+ });
input.addEventListener("keydown", (e) => {
- if (e.key === "Enter" && !e.shiftKey) {
+ if (shouldSend(e, coarse)) {
e.preventDefault();
send();
}
@@ -400,6 +752,7 @@ async function start(session) {
e.preventDefault();
send();
});
+ syncSendBtn();
input.focus();
}
@@ -415,6 +768,24 @@ function formatTime(ms) {
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
+// Append message text to an element, turning URLs into links. Builds DOM nodes
+// only (text nodes + anchors with validated http(s) hrefs) — never innerHTML, so
+// message content can't inject markup.
+function appendText(el, text) {
+ for (const tok of tokenizeText(text || "")) {
+ if (tok.href && /^https?:\/\//i.test(tok.href)) {
+ const a = document.createElement("a");
+ a.href = tok.href;
+ a.textContent = tok.text;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ el.appendChild(a);
+ } else if (tok.text) {
+ el.appendChild(document.createTextNode(tok.text));
+ }
+ }
+}
+
function showBanner(text, warn) {
const b = document.getElementById("banner");
if (!b) return;
diff --git a/web/static/push.js b/web/static/push.js
index 5b76bba..5fe06a1 100644
--- a/web/static/push.js
+++ b/web/static/push.js
@@ -88,7 +88,14 @@ export async function subscribePush(session) {
}
if (Notification.permission !== "granted") return "denied";
try {
- const reg = swReg || (await navigator.serviceWorker.register("/sw.js"));
+ // Ensure the worker is registered AND active before subscribing. Right after
+ // first registration the worker is still "installing"; subscribing then fails
+ // silently on iOS — permission reads "granted" but no subscription ever
+ // reaches the server, so notifications look allowed yet stay off until a
+ // later menu toggle. serviceWorker.ready resolves only once a worker is active.
+ await navigator.serviceWorker.register("/sw.js").catch(() => {});
+ const reg = await navigator.serviceWorker.ready;
+ swReg = reg;
const { publicKey } = await (await fetch("/api/push/vapid")).json();
const appKey = urlB64ToUint8Array(publicKey);
let sub = await reg.pushManager.getSubscription();
diff --git a/web/static/style.css b/web/static/style.css
index cfa5497..33791a7 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -169,16 +169,148 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.msg.me { align-self: flex-end; align-items: flex-end; }
.msg.me .bubble { background: var(--bubble-me); color: #fff; border-bottom-right-radius: 6px; }
.msg.them .bubble { border-bottom-left-radius: 6px; }
+.msg .bubble a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.msg .time { font-size: 11px; color: var(--muted); margin: 3px 6px 0; }
+/* message content (text bubble + any attachments) */
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ align-items: flex-start;
+ min-width: 0;
+}
+.msg.me .content { align-items: flex-end; }
+
+/* inline attachments */
+.msg-image-wrap { max-width: 240px; }
+.msg-image {
+ max-width: 100%;
+ max-height: 320px;
+ border-radius: var(--radius);
+ display: block;
+ background: var(--bubble-them);
+}
+.file-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--bubble-them);
+ color: var(--ink);
+ padding: 10px 14px;
+ border-radius: var(--radius);
+ text-decoration: none;
+ font-size: 14px;
+ max-width: 260px;
+ word-break: break-word;
+}
+.msg.me .file-chip { background: var(--bubble-me); color: #fff; }
+
+/* composer attachment preview */
+.composer-preview { padding: 10px 14px 0; }
+.preview-chip {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--bubble-them);
+ border-radius: 12px;
+ padding: 6px 34px 6px 8px;
+ max-width: 100%;
+}
+.preview-chip img { width: 40px; height: 40px; object-fit: cover; border-radius: 8px; }
+.preview-file { font-size: 24px; }
+.preview-name {
+ font-size: 13px;
+ color: var(--ink);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 220px;
+}
+.preview-remove {
+ position: absolute;
+ right: 6px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: var(--ink);
+ color: #fff;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border-radius: 50%;
+ font-size: 16px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* reactions */
+.bubble-row { display: flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; }
+.msg.me .bubble-row { flex-direction: row-reverse; }
+.react-btn {
+ flex: none;
+ background: transparent;
+ color: var(--muted);
+ font-size: 15px;
+ padding: 2px 5px;
+ border-radius: 50%;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+.msg:hover .react-btn { opacity: 0.5; }
+.react-btn:hover { opacity: 1; background: var(--bubble-them); }
+@media (hover: none) {
+ .react-btn { opacity: 0.4; }
+}
+.reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 3px 2px 0; }
+.msg.me .reactions { justify-content: flex-end; }
+.reactions:empty { display: none; }
+.reaction-chip {
+ background: var(--bubble-them);
+ color: var(--ink);
+ border: 1px solid transparent;
+ border-radius: 999px;
+ padding: 2px 9px;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.35;
+}
+.reaction-chip.mine { border-color: var(--accent); background: #e7eaff; }
+.emoji-picker {
+ position: fixed;
+ display: flex;
+ gap: 2px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 4px 6px;
+ box-shadow: 0 12px 40px rgba(20, 30, 80, 0.18);
+ z-index: 60;
+}
+.emoji-opt {
+ background: transparent;
+ font-size: 22px;
+ padding: 4px 6px;
+ border-radius: 50%;
+ line-height: 1;
+}
+.emoji-opt:hover { background: var(--bubble-them); }
+
.composer {
display: flex;
- gap: 10px;
+ gap: 8px;
padding: 14px;
border-top: 1px solid var(--border);
background: var(--panel);
align-items: flex-end;
}
+.composer-field {
+ position: relative;
+ flex: 1;
+ display: flex;
+}
.composer textarea {
flex: 1;
resize: none;
@@ -187,8 +319,39 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
border-radius: 20px;
font-family: inherit;
overflow: hidden;
+ padding-right: 46px; /* room for the inline send button */
+}
+/* circular up-arrow send button, tucked into the bottom-right of the field */
+.send-btn {
+ position: absolute;
+ right: 5px;
+ bottom: 5px;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--accent);
+ color: #fff;
+}
+.send-btn[hidden] { display: none; }
+/* left-side attach (+) button */
+.attach-btn {
+ flex: none;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bubble-them);
+ color: var(--ink);
+ font-size: 24px;
+ line-height: 1;
}
-.composer button { align-self: flex-end; }
.modal-backdrop {
position: fixed;