eleven / messenger Public

web: drag-drop/paste uploads, image lightbox + downscaling, long-press reactions, link cards

- drag-and-drop or paste an image into the composer (shared upload path).

- click an inline image to open a full-screen lightbox (reuses the decrypted object URL).

- downscale large images in-browser before encrypt+upload (max 2048px long edge, re-encode strips EXIF/GPS); no server-side proxy is possible under E2EE.

- reactions: press-and-hold a message to open the picker; removed the per-message smiley button.

- render OpenGraph preview cards under messages that contain a link (cached per session).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell eaf94e83d69d3110913adda8f64f1cad59788aab parent 1321889
3 files changed, +352 −52
  • web/pages/index.html +1 −0
  • web/static/chat.js +265 −38
  • web/static/style.css +86 −14
diff --git a/web/pages/index.html b/web/pages/index.html
index 276d094..2962bea 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -29,6 +29,7 @@
</header>
<div class="banner hidden" id="banner"></div>
<div id="feed"></div>
+ <div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div>
<div class="composer-preview hidden" id="composer-preview"></div>
<form class="composer" id="composer" autocomplete="off">
<button type="button" class="attach-btn" id="attach-btn" aria-label="Attach a photo or file">+</button>
diff --git a/web/static/chat.js b/web/static/chat.js
index e7f43d9..5da276e 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -3,6 +3,7 @@ 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)
+const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser
import {
needsHomeScreen,
pushSupported,
@@ -217,6 +218,35 @@ async function refreshNotifItem() {
}
}
+// Full-screen image viewer. Reuses the already-decrypted object URL, so there's
+// no re-fetch. Closes on click, the × button, or Escape.
+function openLightbox(src, alt) {
+ const backdrop = document.createElement("div");
+ backdrop.className = "lightbox";
+
+ const img = document.createElement("img");
+ img.src = src;
+ img.alt = alt || "";
+ backdrop.appendChild(img);
+
+ const closeBtn = document.createElement("button");
+ closeBtn.className = "lightbox-close";
+ closeBtn.setAttribute("aria-label", "Close");
+ closeBtn.textContent = "×";
+ backdrop.appendChild(closeBtn);
+
+ const onKey = (e) => {
+ if (e.key === "Escape") close();
+ };
+ function close() {
+ backdrop.remove();
+ document.removeEventListener("keydown", onKey);
+ }
+ backdrop.addEventListener("click", close);
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(backdrop);
+}
+
// Generic dismissible modal (used for the "blocked" recovery instructions).
function openModal(innerHTML) {
const backdrop = document.createElement("div");
@@ -382,20 +412,13 @@ async function start(session) {
if (Array.isArray(payload.attachments)) {
for (const att of payload.attachments) content.appendChild(renderAttachment(att));
}
+ if (payload.text) addLinkPreview(content, payload.text);
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);
+ // Press-and-hold a message to react (no per-message button).
+ attachLongPress(el, msg.id);
+
const reactions = document.createElement("div");
reactions.className = "reactions";
el.appendChild(reactions);
@@ -472,7 +495,7 @@ async function start(session) {
pickerCloser = null;
}
}
- function openReactionPicker(anchorEl, onPick) {
+ function openReactionPicker(x, y, onPick) {
closeReactionPicker();
pickerEl = document.createElement("div");
pickerEl.className = "emoji-picker";
@@ -489,11 +512,12 @@ async function start(session) {
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);
+ // Centre the picker above the press point, clamped to the viewport.
+ const pw = pickerEl.offsetWidth;
+ const ph = pickerEl.offsetHeight;
+ let top = y - ph - 12;
+ if (top < 8) top = y + 16;
+ let left = Math.max(8, Math.min(x - pw / 2, window.innerWidth - pw - 8));
pickerEl.style.top = top + "px";
pickerEl.style.left = left + "px";
pickerCloser = (ev) => {
@@ -505,6 +529,116 @@ async function start(session) {
}, 0);
}
+ // Press-and-hold (touch or mouse) on a message opens the reaction picker.
+ function attachLongPress(el, msgId) {
+ let timer = null;
+ let sx = 0;
+ let sy = 0;
+ let fired = false;
+ const clear = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+ el.addEventListener("pointerdown", (e) => {
+ if (e.pointerType === "mouse" && e.button !== 0) return;
+ fired = false;
+ sx = e.clientX;
+ sy = e.clientY;
+ clear();
+ timer = setTimeout(() => {
+ timer = null;
+ fired = true;
+ openReactionPicker(e.clientX, e.clientY, (emoji) => toggleReaction(msgId, emoji));
+ }, 450);
+ });
+ el.addEventListener("pointermove", (e) => {
+ if (timer && (Math.abs(e.clientX - sx) > 10 || Math.abs(e.clientY - sy) > 10)) clear();
+ });
+ el.addEventListener("pointerup", clear);
+ el.addEventListener("pointercancel", clear);
+ // Swallow the click that follows a long-press so links/lightbox don't fire too.
+ el.addEventListener(
+ "click",
+ (e) => {
+ if (fired) {
+ e.preventDefault();
+ e.stopPropagation();
+ fired = false;
+ }
+ },
+ true
+ );
+ }
+
+ // ---- link previews ----
+ const previewCache = new Map(); // href -> Promise<preview|null>
+
+ function firstLink(text) {
+ for (const t of tokenizeText(text || "")) if (t.href) return t.href;
+ return null;
+ }
+
+ function fetchPreview(href) {
+ if (previewCache.has(href)) return previewCache.get(href);
+ const p = fetch(
+ `/api/unfurl?token=${encodeURIComponent(session.token)}&url=${encodeURIComponent(href)}`
+ )
+ .then((r) => (r.ok ? r.json() : null))
+ .then((pv) => (pv && pv.ok ? pv : null))
+ .catch(() => null);
+ previewCache.set(href, p);
+ return p;
+ }
+
+ async function addLinkPreview(content, text) {
+ const href = firstLink(text);
+ if (!href) return;
+ const pv = await fetchPreview(href);
+ if (!pv || (!pv.title && !pv.image)) return;
+
+ const card = document.createElement("a");
+ card.className = "link-preview";
+ card.href = href;
+ card.target = "_blank";
+ card.rel = "noopener noreferrer";
+ if (pv.image) {
+ const im = document.createElement("img");
+ im.className = "lp-image";
+ im.src = pv.image;
+ im.loading = "lazy";
+ im.referrerPolicy = "no-referrer";
+ im.onerror = () => im.remove();
+ card.appendChild(im);
+ }
+ const body = document.createElement("div");
+ body.className = "lp-body";
+ if (pv.title) {
+ const tt = document.createElement("div");
+ tt.className = "lp-title";
+ tt.textContent = pv.title;
+ body.appendChild(tt);
+ }
+ if (pv.description) {
+ const d = document.createElement("div");
+ d.className = "lp-desc";
+ d.textContent = pv.description;
+ body.appendChild(d);
+ }
+ const host = document.createElement("div");
+ host.className = "lp-host";
+ try {
+ host.textContent = new URL(href).hostname.replace(/^www\./, "");
+ } catch {}
+ body.appendChild(host);
+ card.appendChild(body);
+
+ const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 160;
+ content.appendChild(card);
+ if (atBottom) feed.scrollTop = feed.scrollHeight;
+ }
+
// ---- attachments ----
async function fetchBlob(id) {
const res = await fetch(
@@ -524,6 +658,9 @@ async function start(session) {
img.className = "msg-image";
img.alt = att.name || "image";
if (att.w && att.h) img.style.aspectRatio = att.w + " / " + att.h;
+ img.addEventListener("click", () => {
+ if (img.src) openLightbox(img.src, img.alt);
+ });
loadAttachmentImage(att, img);
wrap.appendChild(img);
return wrap;
@@ -644,7 +781,7 @@ async function start(session) {
syncSendBtn();
}
- function showPreview(file, uploading) {
+ function showPreview(file, uploading, meta) {
previewBox.classList.remove("hidden");
previewBox.innerHTML = "";
const chip = document.createElement("div");
@@ -663,7 +800,7 @@ async function start(session) {
label.className = "preview-name";
label.textContent = uploading
? "Uploading… " + file.name
- : file.name + " · " + formatBytes(file.size);
+ : (meta ? meta.name : file.name) + " · " + formatBytes(meta ? meta.size : file.size);
chip.appendChild(label);
if (!uploading) {
const rm = document.createElement("button");
@@ -677,9 +814,54 @@ async function start(session) {
previewBox.appendChild(chip);
}
+ // Downscale large images in the browser before upload (there's no server-side
+ // proxy — the server only ever sees ciphertext). Re-encoding also strips EXIF/
+ // GPS. Returns { blob, type, w, h } or null to upload the file unchanged.
+ // Animated GIFs are skipped so they keep animating.
+ async function processImage(file) {
+ if (!file.type || !file.type.startsWith("image/") || file.type === "image/gif") {
+ return null;
+ }
+ let bmp;
+ try {
+ bmp = await createImageBitmap(file);
+ } catch {
+ return null; // can't decode (e.g. unsupported) — upload as-is
+ }
+ const ow = bmp.width;
+ const oh = bmp.height;
+ const scale = Math.min(1, MAX_IMAGE_DIM / Math.max(ow, oh));
+ // Small enough already and not heavy → keep original bytes (best quality).
+ if (scale === 1 && file.size <= 1_500_000) {
+ if (bmp.close) bmp.close();
+ return { blob: file, type: file.type, w: ow, h: oh };
+ }
+ const w = Math.round(ow * scale);
+ const h = Math.round(oh * scale);
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+ canvas.getContext("2d").drawImage(bmp, 0, 0, w, h);
+ if (bmp.close) bmp.close();
+ const outType = file.type === "image/png" ? "image/png" : "image/jpeg";
+ const blob = await new Promise((res) => canvas.toBlob(res, outType, 0.85));
+ if (!blob) return { blob: file, type: file.type, w: ow, h: oh };
+ return { blob, type: outType, w, h };
+ }
+
// Encrypt and upload a file, returning its attachment metadata (or null).
async function uploadFile(file) {
- const bytes = new Uint8Array(await file.arrayBuffer());
+ let blob = file;
+ let type = file.type || "application/octet-stream";
+ let w, h;
+ const processed = await processImage(file).catch(() => null);
+ if (processed) {
+ blob = processed.blob;
+ type = processed.type;
+ w = processed.w;
+ h = processed.h;
+ }
+ const bytes = new Uint8Array(await blob.arrayBuffer());
if (bytes.length > MAX_UPLOAD) {
showBanner("That file is too big (max 10 MB).", true);
return null;
@@ -691,27 +873,16 @@ async function start(session) {
);
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 {}
+ const meta = { id, name: file.name || "file", type, size: bytes.length };
+ if (w && h) {
+ meta.w = w;
+ meta.h = h;
}
return meta;
}
- attachBtn.onclick = () => fileInput.click();
- fileInput.onchange = async () => {
- const file = fileInput.files[0];
- fileInput.value = ""; // allow picking the same file again later
+ // Shared by the picker, drag-and-drop, and paste: stage one file for sending.
+ async function stageFile(file) {
if (!file) return;
showBanner("", false);
showPreview(file, true);
@@ -722,11 +893,67 @@ async function start(session) {
return;
}
pendingAttachment = meta;
- showPreview(file, false);
+ showPreview(file, false, meta);
syncSendBtn();
input.focus();
+ }
+
+ attachBtn.onclick = () => fileInput.click();
+ fileInput.onchange = () => {
+ const file = fileInput.files[0];
+ fileInput.value = ""; // allow picking the same file again later
+ stageFile(file);
};
+ // Paste an image straight from the clipboard.
+ input.addEventListener("paste", (e) => {
+ const items = (e.clipboardData && e.clipboardData.items) || [];
+ for (const it of items) {
+ if (it.type && it.type.startsWith("image/")) {
+ const file = it.getAsFile();
+ if (file) {
+ e.preventDefault();
+ stageFile(file);
+ }
+ break;
+ }
+ }
+ });
+
+ // Drag-and-drop a photo/file onto the chat.
+ const chatEl = app.querySelector(".chat");
+ const dropOverlay = document.getElementById("drop-overlay");
+ const hasFiles = (e) =>
+ !!e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files");
+ // Stop the browser from navigating to a file dropped anywhere on the page.
+ for (const t of ["dragover", "drop"]) {
+ document.addEventListener(t, (e) => {
+ if (hasFiles(e)) e.preventDefault();
+ });
+ }
+ let dragDepth = 0;
+ chatEl.addEventListener("dragenter", (e) => {
+ if (!hasFiles(e)) return;
+ dragDepth++;
+ dropOverlay.classList.remove("hidden");
+ });
+ chatEl.addEventListener("dragleave", (e) => {
+ if (!hasFiles(e)) return;
+ dragDepth--;
+ if (dragDepth <= 0) {
+ dragDepth = 0;
+ dropOverlay.classList.add("hidden");
+ }
+ });
+ chatEl.addEventListener("drop", (e) => {
+ if (!hasFiles(e)) return;
+ e.preventDefault();
+ dragDepth = 0;
+ dropOverlay.classList.add("hidden");
+ const file = e.dataTransfer.files && e.dataTransfer.files[0];
+ if (file) stageFile(file);
+ });
+
const send = async () => {
const text = input.value.trim();
if ((!text && !pendingAttachment) || !ws || ws.readyState !== WebSocket.OPEN) return;
diff --git a/web/static/style.css b/web/static/style.css
index 33791a7..36d360d 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -81,6 +81,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
background: var(--panel);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
+ position: relative; /* anchors the drag-and-drop overlay */
}
.chat header {
padding: 14px 18px;
@@ -190,6 +191,53 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
border-radius: var(--radius);
display: block;
background: var(--bubble-them);
+ cursor: zoom-in;
+}
+
+/* full-screen image viewer */
+.lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+ background: rgba(0, 0, 0, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+.lightbox img { max-width: 100%; max-height: 100%; border-radius: 8px; }
+.lightbox-close {
+ position: fixed;
+ top: 14px;
+ right: 16px;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.16);
+ color: #fff;
+ font-size: 26px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* drag-and-drop overlay */
+.drop-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 40;
+ background: rgba(91, 108, 255, 0.12);
+ border: 3px dashed var(--accent);
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ font-size: 18px;
+ color: var(--accent);
+ pointer-events: none;
}
.file-chip {
display: inline-flex;
@@ -206,6 +254,39 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.msg.me .file-chip { background: var(--bubble-me); color: #fff; }
+/* link (OpenGraph) preview card */
+.link-preview {
+ display: flex;
+ flex-direction: column;
+ max-width: 280px;
+ background: var(--bubble-them);
+ color: var(--ink);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ text-decoration: none;
+}
+.msg.me .link-preview {
+ background: rgba(255, 255, 255, 0.14);
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.25);
+}
+.lp-image { width: 100%; max-height: 150px; object-fit: cover; display: block; }
+.lp-body { padding: 8px 11px; }
+.lp-title { font-weight: 600; font-size: 14px; line-height: 1.3; }
+.lp-desc {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 2px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.lp-host { font-size: 11px; color: var(--muted); margin-top: 5px; }
+.msg.me .lp-desc,
+.msg.me .lp-host { color: rgba(255, 255, 255, 0.82); }
+
/* composer attachment preview */
.composer-preview { padding: 10px 14px 0; }
.preview-chip {
@@ -249,20 +330,11 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* 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; }
+/* press-and-hold to react: suppress the iOS text-selection callout on messages */
+.bubble-row {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
}
.reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 3px 2px 0; }
.msg.me .reactions { justify-content: flex-end; }