eleven / messenger Public

web+native: attachments accept any file type (#486)

The extension allowlist (fileAllowed) bit real files — an extensionless
text file was rejected on every surface (the Mac said 'unsupported type',
iOS silently dropped it). The gate bought nothing: the server only ever
stores an opaque encrypted blob, and the filename/MIME ride inside the
encrypted payload. fileAllowed now answers true everywhere (kept so the
web and native twins stay in lockstep); FILE_TYPES/fileIcons remain purely
as the icon + download-MIME map (📎/octet-stream for anything unmapped).
Size caps unchanged. Gates removed from chat.js staging, the shell upload
bridge, iOS stageFile, and Mac stage(); ui.test.mjs pins the new contract.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4f0b51df91c4d43bbabcfed01ee66d9c86d7d8de parent 5c59497
8 files changed, +31 −33
  • changelog/2026-07-09-any-file-attachments.md +4 −0
  • ios/LChat/Views/NativeChatScreen.swift +0 −1
  • ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift +6 −3
  • ios/LChatMac/NativeFeedView.swift +1 −5
  • test/ui.test.mjs +4 −6
  • web/static/apps/chat.js +2 −9
  • web/static/shell.js +2 −1
  • web/static/ui.js +12 −8
diff --git a/changelog/2026-07-09-any-file-attachments.md b/changelog/2026-07-09-any-file-attachments.md
new file mode 100644
index 0000000..91061f3
--- /dev/null
+++ b/changelog/2026-07-09-any-file-attachments.md
@@ -0,0 +1,4 @@
+🤖 Share any file. Attachments are no longer limited to documents and
+spreadsheets — drop anything into a chat and it goes through, end-to-end
+encrypted like everything else. (Files without an extension used to be
+rejected; not any more.) Size limits are unchanged.
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index d73758c..3ebeef6 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -1185,7 +1185,6 @@ struct NativeChatScreen: View {
defer { url.stopAccessingSecurityScopedResource() }
guard let data = try? Data(contentsOf: url) else { return }
let name = url.lastPathComponent
- guard fileAllowed(name) else { return }
let entry = StagedUpload(name: name)
staged.append(entry)
let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
diff --git a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift
index fdc63dc..2b15ab3 100644
--- a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift
+++ b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift
@@ -23,10 +23,13 @@ private func fileExt(_ name: String?) -> String {
(name ?? "").split(separator: ".").last.map { $0.lowercased() } ?? ""
}
-/// ui.js fileAllowed: a non-image file may be attached only when its extension
-/// is on the allowlist (the safety guard — executables/scripts have no entry).
+/// ui.js fileAllowed: any file may be attached (2026-07-09 — the extension
+/// allowlist bit real files, e.g. extensionless text; the server only stores
+/// an encrypted blob, so the gate bought nothing). The icon map above is the
+/// only per-type behaviour left. Kept so call sites stay in lockstep with
+/// the web's fileAllowed.
public func fileAllowed(_ name: String?) -> Bool {
- fileIcons[fileExt(name)] != nil
+ true
}
/// "742 B" / "1.4 KB" / "12 MB" — format.js formatBytes.
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 300419e..4e5c9d6 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -1280,11 +1280,7 @@ struct NativeFeedView: View {
private func stage(data: Data, name: String, type: UTType?) {
let isImage = type?.conforms(to: .image) ?? false
let isVideo = type?.conforms(to: .movie) ?? false
- guard isImage || isVideo || fileAllowed(name) else {
- busyText = "\(name): that file type isn't supported"
- DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil }
- return
- }
+ // Any file type stages — size is the only gate (matching the web).
if isVideo, data.count > maxVideoUploadBytes {
busyText = "\(name): videos are capped at 50 MB"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil }
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index bd40068..5ea6abc 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -65,12 +65,10 @@ await test("fileExt: lowercased extension without the dot", () => {
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("fileAllowed: every file type may be attached (2026-07-09)", () => {
+ for (const name of ["a.pdf", "notes.txt", "CLAUDE.local.md", "data.csv", "x.json",
+ "deck.pptx", "evil.exe", "run.sh", "app.js", "lib.so", "noext", ""]) {
+ assert.equal(fileAllowed(name), true, name);
}
});
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 0d48dbd..e6acc9d 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -26,7 +26,7 @@ import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, ren
import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { renderCard, CARD_CSS } from "../cards.js";
import { emojify } from "../emoji.js";
-import { fileIcon, shouldSend, composerToken, fileAllowed, ownMessage } from "../ui.js";
+import { fileIcon, shouldSend, composerToken, ownMessage } from "../ui.js";
// The host bridge: chat.js injects the pieces that are server mechanisms,
// key-holding machinery, or host state — the app never touches crypto/tokens.
@@ -2673,7 +2673,6 @@ function buildSkeleton(el, ctx) {
// only to word the "too big" message; the host bridge does the real limiting.
const MAX_UPLOAD = 25 * 1024 * 1024;
const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024; // clips get a larger cap (see shell.js)
- const UNSUPPORTED_TYPE = "That file type isn't supported. You can share images, video, PDFs, text, CSV, JSON, and Office documents.";
// alert() would freeze an embedded webview (modal blocks the native message
// channel) — route the error to the shell instead.
const stageError = (msg) => {
@@ -2699,12 +2698,7 @@ function buildSkeleton(el, ctx) {
for (const file of files) {
const isImage = !!(file.type && file.type.startsWith("image/"));
const isVideo = !!(file.type && file.type.startsWith("video/"));
- // Reject unsupported types before staging so no loading chip flashes for a
- // file we were never going to accept (the same allow-list the bridge uses).
- if (!isImage && !isVideo && !fileAllowed(file.name)) {
- stageError(UNSUPPORTED_TYPE);
- continue;
- }
+ // Any file type stages — size is the only gate (the bridge enforces it).
const entry = { att: null, isImage, isVideo };
pending.push(entry);
refreshStaged();
@@ -2732,7 +2726,6 @@ function buildSkeleton(el, ctx) {
const msg = att === "photo" ? "Couldn't add that photo. Try a smaller one."
: att === "size" ? `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`
: att === "video" ? `Couldn't add that video (max ${formatBytes(MAX_VIDEO_UPLOAD)}).`
- : att === "type" ? UNSUPPORTED_TYPE
: "Couldn't add that file.";
stageError(msg);
}
diff --git a/web/static/shell.js b/web/static/shell.js
index 9714320..b8086b7 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -3035,7 +3035,8 @@ async function chatAppUpload(threadId, file, onProgress) {
if (!key || !file) return "generic";
const isImage = !!(file.type && file.type.startsWith("image/"));
const isVideo = !!(file.type && file.type.startsWith("video/"));
- if (!isImage && !isVideo && !fileAllowed(file.name)) return "type";
+ // Any file type uploads — it's an opaque encrypted blob to the server, and
+ // the name/MIME ride inside the payload. Size stays the only gate.
try {
// Every blob is tagged with its thread so the server can GC it with the
// thread / when the referencing message is deleted — it never sees the blob
diff --git a/web/static/ui.js b/web/static/ui.js
index 78d4445..90a7598 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -162,11 +162,14 @@ export function pinnedFirst(list, isPinned, lastOf = () => 0) {
}
// ---- 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).
+// ANY file may be shared (2026-07-09 — the old extension allowlist bit real
+// files, e.g. extensionless text; the server only ever stores an encrypted
+// blob and the name/MIME ride inside the payload, so a type gate bought
+// nothing). This map now only picks icons and download MIMEs for the types we
+// recognise; anything else gets 📎 + octet-stream. It's extension-driven (not
+// MIME) because browsers report inconsistent — often empty — types for
+// documents like .md and .csv. Images still take a separate path (downscaled
+// + re-encoded to JPEG before upload).
const FILE_TYPES = {
pdf: { mime: "application/pdf", icon: "📕" },
txt: { mime: "text/plain", icon: "📄" },
@@ -188,9 +191,10 @@ export function fileExt(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));
+// Any file may be attached — kept (always true) so older call sites keep
+// working; the icon/MIME maps below are the only per-type behaviour left.
+export function fileAllowed() {
+ return true;
}
// Best-effort MIME for an attachment: the browser's reported type if it gave