| index d52e7a9..4fe08ab 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -19,8 +19,8 @@ import { |
| randomSecret, |
| deriveInvite, |
| } from "/static/crypto.js"; |
| -import { shouldSend } from "/static/ui.js"; |
| -import { richTokens, typingSummary, replySnippet } from "/static/format.js"; |
| +import { shouldSend, fileAllowed, fileMime, fileIcon } from "/static/ui.js"; |
| +import { richTokens, typingSummary, replySnippet, formatBytes } from "/static/format.js"; |
| import { emojify } from "/static/emoji.js"; |
| import { MESSAGE_TYPE_TYPING } from "/static/message.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| @@ -38,10 +38,11 @@ import { |
| |
| const SESSION_KEY = "lchat_session"; |
| const isCoarse = matchMedia("(pointer: coarse)").matches; |
| -const MAX_UPLOAD = 10 * 1024 * 1024; // matches maxUploadBytes (main.go) |
| +const MAX_UPLOAD = 25 * 1024 * 1024; // matches maxUploadBytes (main.go) |
| const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser |
| |
| -let pending = null; // an attachment staged in the composer, to send with the next message |
| +let pending = []; // attachments staged in the composer, to send with the next message |
| +let attachSeq = 0; // local id counter for staged attachments (drives per-chip removal) |
| const blobURLs = {}; // blob id -> decrypted object URL (cache) |
| let instanceKey = null; // shared key for all-members content (profile photos) |
| let instanceKeyB64 = null; |
| @@ -344,7 +345,15 @@ function startHeartbeat() { |
| // A one-line snippet for the chat list: "Name: text" in groups, just text in DMs. |
| function previewText(t, data) { |
| let body = (data.text || "").replace(/\s+/g, " ").trim(); |
| - if (!body && data.attachments && data.attachments.length) body = "📷 Photo"; |
| + const atts = data.attachments || []; |
| + if (!body && atts.length) { |
| + const n = atts.length; |
| + if (atts.every((a) => a.type && a.type.startsWith("image/"))) { |
| + body = n > 1 ? `📷 ${n} photos` : "📷 Photo"; |
| + } else { |
| + body = n > 1 ? `📎 ${n} files` : "📎 " + ((atts[0] && atts[0].name) || "File"); |
| + } |
| + } |
| return t.kind === "group" && data.name ? `${data.name}: ${body}` : body; |
| } |
| |
| @@ -452,7 +461,7 @@ function renderApp() { |
| const sendBtn = document.getElementById("send-btn"); |
| const composer = document.getElementById("composer"); |
| text.addEventListener("input", () => { |
| - sendBtn.hidden = text.value.trim() === ""; |
| + updateSendVisibility(); |
| text.style.height = "auto"; |
| text.style.height = Math.min(text.scrollHeight, 140) + "px"; |
| if (text.value.trim()) noteTyping(); |
| @@ -475,8 +484,8 @@ function renderApp() { |
| }); |
| composer.onsubmit = (e) => { |
| e.preventDefault(); |
| + if (!sendMessage(text.value)) return; // still uploading — keep what's typed |
| stopTyping(); // sending means you've stopped typing this message |
| - sendMessage(text.value); |
| text.value = ""; |
| text.style.height = "auto"; |
| sendBtn.hidden = true; |
| @@ -486,7 +495,7 @@ function renderApp() { |
| const fileInput = document.getElementById("file-input"); |
| document.getElementById("attach-btn").onclick = () => fileInput.click(); |
| fileInput.onchange = () => { |
| - if (fileInput.files[0]) addAttachment(fileInput.files[0]); |
| + for (const f of fileInput.files) addAttachment(f); |
| fileInput.value = ""; |
| }; |
| const pane = document.getElementById("thread-view"); |
| @@ -503,8 +512,7 @@ function renderApp() { |
| pane.addEventListener("drop", (e) => { |
| e.preventDefault(); |
| if (dropOverlay) dropOverlay.classList.add("hidden"); |
| - const f = e.dataTransfer.files && e.dataTransfer.files[0]; |
| - if (f) addAttachment(f); |
| + for (const f of e.dataTransfer.files || []) addAttachment(f); |
| }); |
| text.addEventListener("paste", (e) => { |
| const item = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith("image/")); |
| @@ -842,21 +850,25 @@ async function appendMessage(threadId, m, scroll = true) { |
| content.className = "content"; |
| // A reply shows a tappable quote of its parent above everything else. |
| if (data.replyTo) content.appendChild(buildReplyQuote(data.replyTo)); |
| - // Image attachments render above the text bubble; a text-less photo has no bubble. |
| + // Attachments render above the text bubble: images inline, other files as a |
| + // download chip. A text-less attachment has no bubble. |
| for (const a of data.attachments || []) { |
| - if (!a.type || !a.type.startsWith("image/")) continue; |
| - const img = document.createElement("img"); |
| - img.className = "msg-image"; |
| - img.alt = a.name || "photo"; |
| - if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`; |
| - loadBlob(a.id, threadId).then((url) => { |
| - if (url) { |
| - img.src = url; |
| - img.onclick = () => openLightbox(url); |
| - if (scroll) feed.scrollTop = feed.scrollHeight; |
| - } |
| - }); |
| - content.appendChild(img); |
| + if (a.type && a.type.startsWith("image/")) { |
| + const img = document.createElement("img"); |
| + img.className = "msg-image"; |
| + img.alt = a.name || "photo"; |
| + if (a.w && a.h) img.style.aspectRatio = `${a.w} / ${a.h}`; |
| + loadBlob(a.id, threadId).then((url) => { |
| + if (url) { |
| + img.src = url; |
| + img.onclick = () => openLightbox(url); |
| + if (scroll) feed.scrollTop = feed.scrollHeight; |
| + } |
| + }); |
| + content.appendChild(img); |
| + } else { |
| + content.appendChild(buildFileCard(a, threadId)); |
| + } |
| } |
| if (data.text) { |
| const bubble = document.createElement("div"); |
| @@ -1324,9 +1336,12 @@ function cancelReply() { |
| } |
| } |
| |
| +// Returns true when the composer should be reset (message sent, or nothing to |
| +// send), and false when it should be left intact — i.e. an attachment is still |
| +// uploading, so the caller keeps the typed text rather than wiping it. |
| function sendMessage(raw) { |
| const text = emojify((raw || "").trim()); // :tada: → 🎉 before encrypting |
| - if (!activeId || !ws || ws.readyState !== WebSocket.OPEN) return; |
| + if (!activeId || !ws || ws.readyState !== WebSocket.OPEN) return true; |
| |
| // Editing an existing message: re-encrypt the original payload with new text |
| // (preserving its name + any attachments) and send an edit. |
| @@ -1342,12 +1357,25 @@ function sendMessage(raw) { |
| }); |
| } |
| cancelEdit(); |
| - return; |
| + return true; |
| } |
| |
| - if (!text && !pending) return; |
| + // Don't send while any attachment is still uploading — the send button is |
| + // disabled meanwhile, but Enter can still reach here. Bail so the typed text |
| + // is kept; the user sends once every chip resolves. |
| + if (pending.some((p) => p.loading)) return false; |
| + if (!text && !pending.length) return true; |
| const payload = { name: session.name, text, ts: Date.now() }; |
| - if (pending) payload.attachments = [pending]; // {id, name, type, w, h} |
| + if (pending.length) { |
| + // The name/type/size ride inside the encrypted payload; strip local-only fields. |
| + payload.attachments = pending.map((a) => { |
| + const out = { id: a.id, name: a.name, type: a.type }; |
| + if (a.w) out.w = a.w; |
| + if (a.h) out.h = a.h; |
| + if (a.size) out.size = a.size; |
| + return out; |
| + }); |
| + } |
| // Carry the reply link inside the encrypted payload (the server stays blind). |
| if (replyingTo) payload.replyTo = replyingTo; |
| clearPending(); |
| @@ -1355,6 +1383,7 @@ function sendMessage(raw) { |
| encrypt(threadKeys[activeId], payload).then((blob) => { |
| ws.send(JSON.stringify({ type: "msg", thread_id: activeId, blob })); |
| }); |
| + return true; |
| } |
| |
| // The id of your most recent message that's still editable (yours, has text, and |
| @@ -1412,7 +1441,7 @@ function deleteMessage(msgId) { |
| ws.send(JSON.stringify({ type: "delete", thread_id: activeId, message_id: msgId })); |
| } |
| |
| -// ---- attachments (images) ----------------------------------------------- |
| +// ---- attachments (images + files) --------------------------------------- |
| |
| // Downscale + re-encode an image in-browser (caps the long edge, strips EXIF/GPS). |
| async function processImage(file, maxDim = MAX_IMAGE_DIM) { |
| @@ -1428,53 +1457,113 @@ async function processImage(file, maxDim = MAX_IMAGE_DIM) { |
| return { bytes: new Uint8Array(await out.arrayBuffer()), type: "image/jpeg", w, h }; |
| } |
| |
| -// Encrypt an image with the active thread key and upload it; stage it in the |
| -// composer. The filename/type ride inside the (encrypted) message, never as |
| -// server metadata — the blob is opaque ciphertext. |
| +// Stage one attachment. Several can ride a single message, so each gets its own |
| +// entry in `pending` with a local uid; the entry shows a loading chip until its |
| +// upload resolves, then becomes a thumbnail (image) or a file chip. |
| async function addAttachment(file) { |
| - if (!activeId || !file || !file.type || !file.type.startsWith("image/")) return; |
| - const preview = document.getElementById("composer-preview"); |
| - preview.classList.remove("hidden"); |
| - preview.textContent = "Adding photo…"; |
| + if (!activeId || !file) return; |
| + const isImage = !!(file.type && file.type.startsWith("image/")); |
| + // Images take the downscale/re-encode path; other files must pass the |
| + // extension allowlist (the safety guard — executables/scripts have no entry). |
| + if (!isImage && !fileAllowed(file.name)) { |
| + alert("That file type isn't supported. You can share images, PDFs, text, CSV, JSON, and Office documents."); |
| + return; |
| + } |
| + const entry = { uid: ++attachSeq, isImage, loading: true, name: file.name || (isImage ? "photo" : "file") }; |
| + pending.push(entry); |
| + renderPreview(); |
| + updateSendVisibility(); |
| try { |
| - const { bytes, type, w, h } = await processImage(file); |
| - if (bytes.length > MAX_UPLOAD) throw new Error("too big"); |
| - const enc = await encryptBytes(threadKeys[activeId], bytes); |
| - const res = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc }); |
| - if (!res.ok) throw new Error("upload"); |
| - const { id } = await res.json(); |
| - const name = (file.name || "photo").replace(/\.[^.]+$/, "") + ".jpg"; |
| - pending = { id, name, type, w, h }; |
| + if (isImage) { |
| + const { bytes, type, w, h } = await processImage(file); |
| + if (bytes.length > MAX_UPLOAD) throw new Error("too big"); |
| + entry.id = await uploadBytes(bytes); |
| + Object.assign(entry, { type, w, h, name: (file.name || "photo").replace(/\.[^.]+$/, "") + ".jpg" }); |
| + } else { |
| + const bytes = new Uint8Array(await file.arrayBuffer()); |
| + if (bytes.length > MAX_UPLOAD) throw new Error("too big"); |
| + entry.id = await uploadBytes(bytes); |
| + // The name/type/size ride inside the encrypted message, never as server |
| + // metadata — the blob is opaque ciphertext. |
| + Object.assign(entry, { type: fileMime(file.name, file.type), size: bytes.length }); |
| + } |
| + entry.loading = false; |
| renderPreview(); |
| - document.getElementById("send-btn").hidden = false; |
| + updateSendVisibility(); |
| } catch (e) { |
| console.error(e); |
| - clearPending(); |
| - alert("Couldn't add that photo. Try a smaller one."); |
| + pending = pending.filter((p) => p !== entry); |
| + renderPreview(); |
| + updateSendVisibility(); |
| + alert(isImage ? "Couldn't add that photo. Try a smaller one." : `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`); |
| } |
| } |
| |
| +// Encrypt bytes with the active thread key and upload them; returns the blob id. |
| +async function uploadBytes(bytes) { |
| + const enc = await encryptBytes(threadKeys[activeId], bytes); |
| + const res = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc }); |
| + if (!res.ok) throw new Error("upload"); |
| + const { id } = await res.json(); |
| + return id; |
| +} |
| + |
| function renderPreview() { |
| const preview = document.getElementById("composer-preview"); |
| preview.textContent = ""; |
| - if (!pending) { |
| + if (!pending.length) { |
| preview.classList.add("hidden"); |
| return; |
| } |
| preview.classList.remove("hidden"); |
| - const img = document.createElement("img"); |
| - loadBlob(pending.id).then((url) => url && (img.src = url)); |
| + for (const entry of pending) preview.appendChild(buildPreviewTile(entry)); |
| +} |
| + |
| +// One staged attachment as a removable chip: a spinner while uploading, then an |
| +// image thumbnail or a file chip. Each chip carries its own remove button. |
| +function buildPreviewTile(entry) { |
| + const chip = document.createElement("div"); |
| + chip.className = "preview-chip"; |
| + if (entry.loading) { |
| + const spin = document.createElement("span"); |
| + spin.className = "preview-spinner"; |
| + spin.setAttribute("aria-hidden", "true"); |
| + const label = document.createElement("span"); |
| + label.className = "preview-name"; |
| + label.textContent = entry.isImage ? "Adding photo…" : "Adding file…"; |
| + chip.append(spin, label); |
| + } else if (entry.isImage) { |
| + const img = document.createElement("img"); |
| + img.alt = entry.name || "photo"; |
| + loadBlob(entry.id).then((url) => url && (img.src = url)); |
| + chip.appendChild(img); |
| + } else { |
| + const icon = document.createElement("span"); |
| + icon.className = "preview-file"; |
| + icon.textContent = fileIcon(entry.name); |
| + const name = document.createElement("span"); |
| + name.className = "preview-name"; |
| + name.textContent = entry.name || "file"; |
| + chip.append(icon, name); |
| + } |
| const x = document.createElement("button"); |
| - x.className = "preview-x"; |
| + x.className = "preview-remove"; |
| x.type = "button"; |
| - x.textContent = "×"; |
| - x.onclick = clearPending; |
| - preview.appendChild(img); |
| - preview.appendChild(x); |
| + x.setAttribute("aria-label", "Remove attachment"); |
| + x.innerHTML = ICON_X; // static in-code SVG |
| + x.onclick = () => removeAttachment(entry.uid); |
| + chip.appendChild(x); |
| + return chip; |
| +} |
| + |
| +function removeAttachment(uid) { |
| + pending = pending.filter((p) => p.uid !== uid); |
| + renderPreview(); |
| + updateSendVisibility(); |
| } |
| |
| function clearPending() { |
| - pending = null; |
| + pending = []; |
| const preview = document.getElementById("composer-preview"); |
| if (preview) { |
| preview.textContent = ""; |
| @@ -1482,8 +1571,20 @@ function clearPending() { |
| } |
| } |
| |
| +// The send button shows when there's text or any staged attachment, but stays |
| +// disabled until every attachment has finished uploading — so a still-uploading |
| +// file can't be silently dropped from the message. |
| +function updateSendVisibility() { |
| + const text = document.getElementById("text"); |
| + const sendBtn = document.getElementById("send-btn"); |
| + if (!text || !sendBtn) return; |
| + sendBtn.hidden = text.value.trim() === "" && pending.length === 0; |
| + sendBtn.disabled = pending.some((p) => p.loading); |
| +} |
| + |
| // Fetch + decrypt a blob (with the active thread's key), cached as an object URL. |
| -async function loadBlob(id, threadId = activeId) { |
| +// `type` (optional) sets the Blob's MIME so non-image files open/download right. |
| +async function loadBlob(id, threadId = activeId, type) { |
| if (blobURLs[id]) return blobURLs[id]; |
| const key = threadKeys[threadId]; |
| if (!key) return null; |
| @@ -1492,7 +1593,7 @@ async function loadBlob(id, threadId = activeId) { |
| await (await fetch(`/api/blob/${encodeURIComponent(id)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer() |
| ); |
| const plain = await decryptBytes(key, buf); |
| - const url = URL.createObjectURL(new Blob([plain])); |
| + const url = URL.createObjectURL(new Blob([plain], type ? { type } : undefined)); |
| blobURLs[id] = url; |
| return url; |
| } catch { |
| @@ -1500,6 +1601,41 @@ async function loadBlob(id, threadId = activeId) { |
| } |
| } |
| |
| +// A download chip for a non-image attachment: icon + name + size. The bytes are |
| +// fetched + decrypted lazily; clicking downloads the file under its real name. |
| +// textContent only — never innerHTML on the attacker-controlled name. |
| +function buildFileCard(a, threadId) { |
| + const card = document.createElement("a"); |
| + card.className = "file-chip"; |
| + card.href = "#"; |
| + card.download = a.name || "file"; |
| + const icon = document.createElement("span"); |
| + icon.className = "file-icon"; |
| + icon.textContent = fileIcon(a.name); |
| + const meta = document.createElement("span"); |
| + meta.className = "file-meta"; |
| + const nm = document.createElement("span"); |
| + nm.className = "file-name"; |
| + nm.textContent = a.name || "file"; |
| + meta.appendChild(nm); |
| + if (a.size) { |
| + const sz = document.createElement("span"); |
| + sz.className = "file-sub"; |
| + sz.textContent = formatBytes(a.size); |
| + meta.appendChild(sz); |
| + } |
| + card.appendChild(icon); |
| + card.appendChild(meta); |
| + // Don't follow the placeholder "#" href if tapped before the blob is ready. |
| + card.addEventListener("click", (e) => { |
| + if (card.getAttribute("href") === "#") e.preventDefault(); |
| + }); |
| + loadBlob(a.id, threadId, a.type).then((url) => { |
| + if (url) card.href = url; |
| + }); |
| + return card; |
| +} |
| + |
| function openLightbox(url) { |
| const back = document.createElement("div"); |
| back.className = "lightbox"; |