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