eleven / messenger Public

Share files (PDF/text/Office) + multiple attachments per message (#22)

* web,server: share files (PDF/text/Office), multiple per message

Attachments were images-only. This adds an extension-gated allowlist for
documents — PDF, text/Markdown/CSV/JSON, and Word/Excel/PowerPoint — sent
the same E2EE way as photos (encrypted client-side; the name/type/size ride
inside the encrypted payload, the server stores opaque ciphertext only). The
allowlist IS the safety guard: executables/scripts have no entry, so they're
rejected. Non-image attachments render as a download chip that decrypts and
saves under the file's real name.

Also:
- Multiple attachments per message: the composer stages a list, each as its
  own removable chip; the picker and drag-drop accept several at once.
- Fix the unstyled "Adding…" status that floated above the composer — each
  staged attachment now shows a proper chip with a spinner while uploading.
- Raise the upload cap 10 MB -> 25 MB (server maxUploadBytes 26 MB to cover
  AES-GCM overhead).
- Reuse the pre-existing (orphaned) .file-chip/.preview-chip styles; drop the
  now-unused .preview-x rule.
- Pure file helpers (fileExt/fileAllowed/fileMime/fileIcon) in ui.js + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: disable send until staged attachments finish uploading

Sending while an upload was still in flight silently dropped that
attachment. Now the send button stays visible but disabled until every
staged attachment resolves, and sendMessage bails (keeping the typed
text — Enter can still reach the submit handler) if it's called mid-upload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell e4486e35fb699d846532c06c2751a97c120326c4 parent ff8ed3d
6 files changed, +342 −71
  • main.go +5 −3
  • test/ui.test.mjs +33 −1
  • web/pages/index.html +4 −3
  • web/static/chat.js +194 −58
  • web/static/style.css +60 −6
  • web/static/ui.js +46 −0
diff --git a/main.go b/main.go
index e46a7c7..89c0ef7 100644
--- a/main.go
+++ b/main.go
@@ -17,8 +17,10 @@ import (
"time"
)
-// maxUploadBytes caps an encrypted attachment upload (10 MB of ciphertext).
-const maxUploadBytes = 10 << 20
+// maxUploadBytes caps an encrypted attachment upload: ~25 MB of plaintext (the
+// client's MAX_UPLOAD ceiling) plus AES-GCM overhead. The blob is opaque
+// ciphertext — the server can't tell an image from a document.
+const maxUploadBytes = 26 << 20
//go:embed web/pages/*.html web/static/*
var webFS embed.FS
@@ -713,7 +715,7 @@ func (s *Server) uploadBlob(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
data, err := io.ReadAll(r.Body)
if err != nil {
- httpError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MB)")
+ httpError(w, http.StatusRequestEntityTooLarge, "file too large (max 25 MB)")
return
}
if len(data) == 0 {
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index e058042..6e1bfac 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -1,6 +1,6 @@
// Tests for composer key handling. node test/ui.test.mjs
import assert from "node:assert/strict";
-import { shouldSend } from "../web/static/ui.js";
+import { shouldSend, fileExt, fileAllowed, fileMime, fileIcon } from "../web/static/ui.js";
let failures = 0;
async function test(name, fn) {
@@ -34,5 +34,37 @@ await test("other keys never send", () => {
assert.equal(shouldSend({ key: "Tab", shiftKey: false }, false), false);
});
+await test("fileExt: lowercased extension without the dot", () => {
+ assert.equal(fileExt("CLAUDE.local.md"), "md");
+ assert.equal(fileExt("Report.PDF"), "pdf");
+ assert.equal(fileExt("archive.tar.gz"), "gz");
+ assert.equal(fileExt("noext"), "");
+ assert.equal(fileExt(""), "");
+ assert.equal(fileExt(undefined), "");
+});
+
+await test("fileAllowed: docs yes, executables/scripts no", () => {
+ for (const ok of ["a.pdf", "notes.txt", "CLAUDE.local.md", "data.csv", "x.json", "deck.pptx", "sheet.xlsx", "memo.docx"]) {
+ assert.equal(fileAllowed(ok), true, ok);
+ }
+ for (const bad of ["evil.exe", "run.sh", "app.js", "lib.so", "photo.png", "noext", ""]) {
+ assert.equal(fileAllowed(bad), false, bad);
+ }
+});
+
+await test("fileMime: browser type wins, else mapped from extension", () => {
+ assert.equal(fileMime("x.bin", "image/png"), "image/png"); // browser-provided type wins
+ assert.equal(fileMime("CLAUDE.local.md", ""), "text/markdown"); // .md often has empty type
+ assert.equal(fileMime("data.csv", undefined), "text/csv");
+ assert.equal(fileMime("mystery.xyz", ""), "application/octet-stream"); // unknown ext
+});
+
+await test("fileIcon: mapped emoji, 📎 fallback", () => {
+ assert.equal(fileIcon("a.pdf"), "📕");
+ assert.equal(fileIcon("b.csv"), "📊");
+ assert.equal(fileIcon("c.docx"), "📘");
+ assert.equal(fileIcon("d.unknown"), "📎");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/pages/index.html b/web/pages/index.html
index 37a408a..af83dc0 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -68,14 +68,15 @@
</div>
</header>
<div class="feed" id="feed"></div>
- <div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div>
+ <div class="drop-overlay hidden" id="drop-overlay">Drop a photo or file to send</div>
<div class="typing hidden" id="typing" aria-live="polite"></div>
<div class="composer-reply hidden" id="composer-reply"></div>
<div class="composer-edit hidden" id="composer-edit"></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="Add a photo">+</button>
- <input type="file" id="file-input" accept="image/*" hidden />
+ <button type="button" class="attach-btn" id="attach-btn" aria-label="Add a photo or file">+</button>
+ <input type="file" id="file-input" multiple
+ accept="image/*,.pdf,.txt,.md,.markdown,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx" 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" hidden>
diff --git a/web/static/chat.js b/web/static/chat.js
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";
diff --git a/web/static/style.css b/web/static/style.css
index fbaff85..e573d10 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -307,6 +307,24 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
color: var(--accent);
pointer-events: none;
}
+.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; }
+.file-chip .file-icon { font-size: 22px; line-height: 1; flex: none; }
+.file-chip .file-meta { display: flex; flex-direction: column; min-width: 0; }
+.file-chip .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.file-chip .file-sub { font-size: 12px; opacity: 0.7; }
/* link (OpenGraph) preview card */
.link-preview {
@@ -335,14 +353,49 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.lp-host { font-size: 11px; color: var(--muted); margin-top: 5px; }
/* composer attachment preview */
-.composer-preview { padding: 10px 16px 0; position: relative; display: inline-block; }
+.composer-preview { padding: 10px 16px 0; display: flex; flex-wrap: wrap; gap: 8px; }
.composer-preview.hidden { display: none; }
-.composer-preview img { max-height: 80px; border-radius: 10px; display: block; }
-.preview-x {
- position: absolute; top: 4px; left: 22px; width: 22px; height: 22px; padding: 0;
- border-radius: 50%; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 16px; line-height: 1;
- display: inline-flex; align-items: center; justify-content: center;
+.preview-spinner {
+ width: 16px; height: 16px; border-radius: 50%;
+ border: 2px solid var(--border); border-top-color: var(--accent);
+ animation: spin 0.8s linear infinite; flex: none;
+}
+.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%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
}
+.preview-remove svg { width: 13px; height: 13px; }
/* Text selection works everywhere now: desktop reacts with a stationary hold
(drag-select cancels it) and touch reacts with a double-tap, so long-press is
@@ -546,6 +599,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
color: #fff;
}
.send-btn[hidden] { display: none; }
+.send-btn:disabled { opacity: 0.45; cursor: default; }
/* left-side attach (+) button */
.attach-btn {
flex: none;
diff --git a/web/static/ui.js b/web/static/ui.js
index c4e9c66..20228c8 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -8,3 +8,49 @@
export function shouldSend(e, coarse) {
return e.key === "Enter" && !e.shiftKey && !coarse;
}
+
+// ---- attachment policy --------------------------------------------------
+// Which non-image file types may be shared, keyed by extension. It's extension-
+// driven (not MIME) because browsers report inconsistent — often empty — types
+// for documents like .md and .csv. This allowlist IS the safety guard: there's
+// no entry for executables or scripts, so they're rejected. Images take a
+// separate path (downscaled + re-encoded to JPEG before upload).
+const FILE_TYPES = {
+ pdf: { mime: "application/pdf", icon: "📕" },
+ txt: { mime: "text/plain", icon: "📄" },
+ md: { mime: "text/markdown", icon: "📄" },
+ markdown: { mime: "text/markdown", icon: "📄" },
+ csv: { mime: "text/csv", icon: "📊" },
+ json: { mime: "application/json", icon: "📄" },
+ doc: { mime: "application/msword", icon: "📘" },
+ docx: { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", icon: "📘" },
+ xls: { mime: "application/vnd.ms-excel", icon: "📊" },
+ xlsx: { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", icon: "📊" },
+ ppt: { mime: "application/vnd.ms-powerpoint", icon: "📙" },
+ pptx: { mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", icon: "📙" },
+};
+
+// The lowercased extension of a filename (without the dot), or "".
+export function fileExt(name) {
+ const m = /\.([a-z0-9]+)$/i.exec(name || "");
+ return m ? m[1].toLowerCase() : "";
+}
+
+// True if a (non-image) file is allowed to be attached, decided by extension.
+export function fileAllowed(name) {
+ return Object.prototype.hasOwnProperty.call(FILE_TYPES, fileExt(name));
+}
+
+// Best-effort MIME for an attachment: the browser's reported type if it gave
+// one, else mapped from the extension so .md/.csv still download/open sensibly.
+export function fileMime(name, type) {
+ if (type) return type;
+ const t = FILE_TYPES[fileExt(name)];
+ return t ? t.mime : "application/octet-stream";
+}
+
+// A document emoji for an attachment, by extension. 📎 for anything unmapped.
+export function fileIcon(name) {
+ const t = FILE_TYPES[fileExt(name)];
+ return t ? t.icon : "📎";
+}