diff --git a/BlipView.qml b/BlipView.qml index 4c2e842..c02d3a2 100644 --- a/BlipView.qml +++ b/BlipView.qml @@ -179,9 +179,15 @@ FocusScope { property var attMetrics: ({}) property var fetchQueue: [] property string fetchingId: "" - // Compose draft attachment (one per message in v1). - property string draftPath: "" - property string draftLabel: "" + // Queued attachments, oldest first: [{ path, label }]. A single draft could + // only ever ship the first of five dropped photos, silently discarding the + // rest — a drop that loses four files with no message is worse than a + // refusal. Sends go out one part per file, in this order. + property var attachDrafts: [] + readonly property int attachCount: attachDrafts.length + /** How many files one message may carry. A stray drop of a whole folder + * should be refused, not turned into eighty sends. */ + readonly property int attachMax: 10 // What the in-flight file send / paste belong to, so late completions // can't clobber a NEWER draft or land in a DIFFERENT conversation. property string sendDraftPath: "" @@ -372,6 +378,15 @@ FocusScope { // Text sends queue instead of refusing while one is on the wire; each gets // its bubble the instant Enter is pressed (see pendingSends). property var sendQueue: [] + // Files still to ship for the send in flight: one part per file, in order. + // The caption rides the FIRST part only — repeating it on each would post + // the same sentence five times. + property var fileQueue: [] + property string sendCaption: "" + // The service is captured when the batch STARTS, never re-read per part: + // root.active can change under a multi-part send, and a later part must not + // go out on a different service from the first (war room #2). + property string sendService: "" // Sends the Mac has not written a row for yet, {chat, text, ts}. Every // thread reload carries them to thread.ts (--pending-stdin, never argv), // which keeps their bubbles until the real row lands. Memory only. @@ -497,7 +512,7 @@ FocusScope { function back() { clearThread() composeField.text = "" - clearDraft() // a queued file must never survive into another thread + clearAttachments() // a queued file must never survive into another thread pinToBottom = false // Top of the list for a mouse user; the cursor row for a keyboard user. Qt.callLater(function() { @@ -551,7 +566,7 @@ FocusScope { loading = true composeField.text = drafts[String(t.chat)] || "" // this conversation's unsent text composeField.cursorPosition = composeField.length - clearDraft() // a queued file must never survive into another thread + clearAttachments() // a queued file must never survive into another thread requestThreadLoad(String(t.chat)) } @@ -967,18 +982,41 @@ FocusScope { // ------------------------------------------------------ compose attachment - function setDraft(path) { + /** Queue one more file on the draft. The same path twice is one attachment. */ + function addAttachment(path) { var p = String(path || "") if (p === "") return - draftPath = p + for (var i = 0; i < root.attachDrafts.length; i++) if (root.attachDrafts[i].path === p) return + if (root.attachDrafts.length >= root.attachMax) { + root.note = "at most " + root.attachMax + " files per message" + return + } var parts = p.split("/") - draftLabel = parts[parts.length - 1] || "file" + root.attachDrafts = root.attachDrafts.concat([{ path: p, label: parts[parts.length - 1] || "file" }]) Qt.callLater(function() { composeField.forceActiveFocus() }) } - function clearDraft() { - draftPath = "" - draftLabel = "" + /** Queue several at once — a multi-file drop. Reports what would not fit + * rather than dropping it on the floor. */ + function addAttachments(paths) { + var before = root.attachDrafts.length + for (var i = 0; i < (paths || []).length; i++) { + if (root.attachDrafts.length >= root.attachMax) { + root.note = "attached " + (root.attachDrafts.length - before) + " — at most " + + root.attachMax + " files per message" + return + } + root.addAttachment(paths[i]) + } + } + + function removeAttachment(path) { + root.attachDrafts = root.attachDrafts.filter(function(d) { return d.path !== String(path) }) + } + + function clearAttachments() { + root.attachDrafts = [] + root.fileQueue = [] } function startPaste() { @@ -1329,35 +1367,35 @@ FocusScope { if (trimmed.indexOf("/attach ") === 0) { var p = trimmed.slice(8).trim() if (p.indexOf("~/") === 0) p = root.home + p.slice(1) - setDraft(p) + addAttachment(p) composeField.text = "" - note = "attached — type a caption or press Enter to send" + note = root.attachCount > 1 + ? root.attachCount + " files attached — caption, or Enter to send" + : "attached — type a caption or press Enter to send" return } - if (draftPath === "" && trimmed === "") return + if (root.attachCount === 0 && trimmed === "") return if (!isSendable(root.active)) { note = "Read-only — group id unknown — send from your phone" return } - if (draftPath !== "") { + if (root.attachCount > 0) { if (sendProc.running || fileSendProc.running) { note = "a message is already sending" return } - note = "sending…" sendChat = String(root.active.chat) sendText = text - // send-file.ts owns target resolution (group guid or DM handle). - sendDraftPath = draftPath - // caption on stdin — never in this process's argv (audit #4, war room #1/#13) - fileSendProc.command = ["bun", root.sendFileScript, sendChat, draftPath, "--caption-stdin"] - .concat(/^(SMS|RCS)$/i.test(String(root.active.service || "")) ? ["--service", String(root.active.service).toUpperCase()] : []) - fileSendProc.stdinEnabled = true - fileSendProc.running = true - fileSendProc.write(trimmed !== "" ? text : "") - fileSendProc.stdinEnabled = false + // One part per file, in the order they were queued; the caption goes + // with the first only. fileSendProc is a single Process, so they ship + // strictly one at a time — pumpFileSend() starts the next as each lands. + root.fileQueue = root.attachDrafts.map(function(d) { return d.path }) + root.sendCaption = trimmed + var svc = String(root.active.service || "") + root.sendService = /^(SMS|RCS)$/i.test(svc) ? svc.toUpperCase() : "" + pumpFileSend() return } @@ -1436,6 +1474,27 @@ FocusScope { sendProc.stdinEnabled = false } + /** Ship the next queued file. The caption rides the FIRST part only, and + * goes over stdin — never this process's argv (audit #4, war room #1/#13). */ + function pumpFileSend() { + if (fileSendProc.running) return + if (root.fileQueue.length === 0) return + var path = root.fileQueue[0] + root.fileQueue = root.fileQueue.slice(1) + root.sendDraftPath = path + var left = root.fileQueue.length + root.note = left > 0 ? "sending… (" + (left + 1) + " left)" : "sending…" + // send-file.ts owns target resolution (group guid or DM handle). + fileSendProc.command = ["bun", root.sendFileScript, root.sendChat, path, "--caption-stdin"] + .concat(root.sendService !== "" ? ["--service", root.sendService] : []) + fileSendProc.stdinEnabled = true + fileSendProc.running = true + fileSendProc.write(root.sendCaption) + fileSendProc.stdinEnabled = false + // Spent: only the first part carries it. + root.sendCaption = "" + } + property string copyFeedback: "" Process { id: copyProc @@ -1681,7 +1740,7 @@ FocusScope { if (!root.inThread || String(root.active.chat) !== root.pasteChat) return try { var d = JSON.parse(text.trim()) - if (d.kind === "image" || d.kind === "file") root.setDraft(String(d.path || "")) + if (d.kind === "image" || d.kind === "file") root.addAttachment(String(d.path || "")) else if (d.kind === "text") { composeField.insert(composeField.cursorPosition, String(d.text || "")) } @@ -1703,18 +1762,33 @@ FocusScope { if (!ok) err = d.online === false ? "not sent — Mac unreachable" : String(d.error || err) } catch (e) { /* fall through */ } if (ok) { - // Only clear the draft this send actually shipped — the user may - // have queued a NEWER file while this one was in flight. - if (root.draftPath === root.sendDraftPath) root.clearDraft() + // Retire only the part that actually shipped — the user may have + // queued another file while this one was in flight. + root.removeAttachment(root.sendDraftPath) + root.sendDraftPath = "" + root.reloadChat = root.sendChat + reloadTimer.restart() + if (root.fileQueue.length > 0) { + // More parts to go: keep sendChat/sendText, do NOT clear the + // field or hand focus back mid-batch. + root.pumpFileSend() + return + } if (belongsHere) { root.note = "" if (composeField.text === root.sendText) composeField.text = "" } - root.reloadChat = root.sendChat - reloadTimer.restart() } else if (belongsHere) { - root.note = err + // Stop the batch: the parts still queued stay attached, so the user + // can retry them rather than hunt for which of five went out. + var left = root.fileQueue.length + root.note = left > 0 ? err + " — " + (left + 1) + " still attached" : err + root.fileQueue = [] + root.sendDraftPath = "" } + root.fileQueue = [] + root.sendCaption = "" + root.sendService = "" root.sendChat = "" root.sendText = "" if (belongsHere) composeField.forceActiveFocus() @@ -1985,7 +2059,7 @@ FocusScope { || (text >= "1" && text <= "9") if (!jump) return false if (searchField.activeFocus || newField.activeFocus || bubbleFocused) return false - if (composeField.activeFocus && (composeField.text.length > 0 || root.draftPath !== "")) + if (composeField.activeFocus && (composeField.text.length > 0 || root.attachCount > 0)) return false return handleTextKey(text) === true } @@ -3463,32 +3537,47 @@ FocusScope { foreground: root.foreground } - // queued attachment — one per message; ✕ removes it - RowLayout { + // queued attachments — ✕ removes one. ONE CHIP PER ROW, never a + // RowLayout of N: summed implicit widths stretch the whole column + // past the panel and take every right-aligned element off-screen + // with it (CLAUDE.md; the same rule the received chips follow). + ColumnLayout { + id: attachList Layout.fillWidth: true - visible: root.inThread && root.draftPath !== "" - spacing: 0 - Rectangle { - Layout.preferredWidth: Math.ceil(draftText.implicitWidth) + Style.space(18) - Layout.preferredHeight: Math.ceil(draftText.implicitHeight) + Style.space(12) - radius: Style.space(14) - color: root.mineFill - opacity: 0.9 - Text { - id: draftText - anchors.centerIn: parent - text: "📎 " + (root.draftLabel.length > 40 - ? root.draftLabel.slice(0, 37) + "…" - : root.draftLabel) + " ✕" - textFormat: Text.PlainText - color: root.mineText - font.family: root.fontFamily - font.pixelSize: root.fontCaption + visible: root.inThread && root.attachCount > 0 + spacing: Style.space(4) + Repeater { + model: root.attachDrafts + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 0 + Rectangle { + // Capped at the compose column: a long filename must not + // push the row wider than the panel. + Layout.preferredWidth: Math.min( + Math.ceil(attachChipText.implicitWidth) + Style.space(18), + attachList.width) + Layout.preferredHeight: Math.ceil(attachChipText.implicitHeight) + Style.space(12) + radius: Style.space(14) + color: root.mineFill + opacity: 0.9 + Text { + id: attachChipText + anchors.centerIn: parent + readonly property string label: String(modelData.label || "file") + text: "📎 " + (label.length > 40 ? label.slice(0, 37) + "…" : label) + " ✕" + textFormat: Text.PlainText + color: root.mineText + font.family: root.fontFamily + font.pixelSize: root.fontCaption + } + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.removeAttachment(modelData.path) } + } + Item { Layout.fillWidth: true } } - HoverHandler { cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: root.clearDraft() } } - Item { Layout.fillWidth: true } } RowLayout { @@ -3592,7 +3681,7 @@ FocusScope { // instead; send() is the authoritative online/sendability guard. enabled: true readOnly: !root.online || !root.isSendable(root.active) - placeholderText: root.draftPath !== "" + placeholderText: root.attachCount > 0 ? "caption (optional) — Enter sends the file" : root.isSendable(root.active) ? "iMessage" : "Read-only — group id unknown" color: root.foreground @@ -3651,7 +3740,7 @@ FocusScope { } // Actions on the selected bubble. Enter is free here: with no // text and no queued file, send() would do nothing anyway. - var b = empty && root.draftPath === "" ? root.selectedBubble() : null + var b = empty && root.attachCount === 0 ? root.selectedBubble() : null if (b) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { event.accepted = true; root.openBubble(b); return } if (event.matches(StandardKey.Copy)) { event.accepted = true; root.copyBubble(b); return } @@ -3670,7 +3759,7 @@ FocusScope { // send button — the blue arrow circle (lit when text OR a file is queued) Rectangle { Layout.alignment: Qt.AlignBottom - readonly property bool armed: composeField.text.trim() !== "" || root.draftPath !== "" + readonly property bool armed: composeField.text.trim() !== "" || root.attachCount > 0 width: Style.space(28); height: width; radius: width / 2 color: armed ? root.mineFill : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.15) Text { @@ -3757,9 +3846,16 @@ FocusScope { keys: ["text/uri-list"] onDropped: (drop) => { if (!drop.hasUrls || drop.urls.length === 0) return - var u = String(drop.urls[0]) - if (u.indexOf("file://") !== 0) return - root.setDraft(decodeURIComponent(u.replace(/^file:\/\//, ""))) + // EVERY dropped file, not just urls[0]: dropping five photos used to + // attach one and discard four without saying so. + var paths = [] + for (var i = 0; i < drop.urls.length; i++) { + var u = String(drop.urls[i]) + if (u.indexOf("file://") !== 0) continue + paths.push(decodeURIComponent(u.replace(/^file:\/\//, ""))) + } + if (paths.length === 0) return + root.addAttachments(paths) drop.accept() } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ecf90c..3021e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +- **A message can carry several files.** Dropping five photos on a + conversation attached one and threw the other four away without a word: the + drop handler read `urls[0]` and the draft was a single path. Drafts are a + list now — drag-and-drop takes every file, `/attach` and Ctrl+V add to it, + and each chip has its own ✕. They ship one part per file in the order you + queued them, with the caption on the first only (repeating it would post the + same sentence five times), and the service is captured when the batch starts + so switching threads mid-send cannot push a later part onto a different one. + A part that fails stops the batch and leaves the rest attached, saying how + many, rather than making you work out which of five went out. Capped at ten + files, because a stray drop of a folder should be refused and not become + eighty sends. Chips are one per row, like the received ones — a row of N + sums its implicit widths and drags the whole column off the panel. + + ## 2.5.0 — 2026-09-13 — photos at once, and sends that stay put - **Sending a link no longer opens the share sheet** (#54, @jondkinney). A diff --git a/collector.test.ts b/collector.test.ts index 6f3e7e5..830c58f 100644 --- a/collector.test.ts +++ b/collector.test.ts @@ -866,7 +866,7 @@ describe("a link that just arrived opens the share sheet", () => { test("every link of a message rides along; url stays the first", () => { const out = selectIncomingLinks([ - msg({ ts: "2026-09-02 12:30:00", from_me: false, text: "two: https://a.test/1, and https://b.test/2." }), + msg({ ts: "2026-09-02T12:30:00Z", from_me: false, text: "two: https://a.test/1, and https://b.test/2." }), ], WM, []); expect(out[0]!.url).toBe("https://a.test/1"); expect(out[0]!.urls).toEqual(["https://a.test/1", "https://b.test/2"]); @@ -945,13 +945,13 @@ describe("a re-keyed group is ONE conversation", () => { const email = "pat@example.com"; const out = foldThreadAliases( [ - thread(phone, "2026-09-04 21:32:29", 40, 0), - thread(email, "2026-09-05 17:24:01", 12, 1), + thread(phone, "2026-09-04T21:32:29Z", 40, 0), + thread(email, "2026-09-05T17:24:01Z", 12, 1), ], { [phone]: email }, ); expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ chat: email, last_ts: "2026-09-05 17:24:01", unread: 1 }); + expect(out[0]).toMatchObject({ chat: email, last_ts: "2026-09-05T17:24:01Z", unread: 1 }); }); }); diff --git a/thread.test.ts b/thread.test.ts index 19900d1..b2d177f 100644 --- a/thread.test.ts +++ b/thread.test.ts @@ -466,9 +466,9 @@ describe("selectThread", () => { const phone = "+15550100001"; const email = "pat@example.com"; const raw = [ - msg({ chat: phone, handle: phone, ts: "2026-09-04 21:32:29", text: "sms" }), - msg({ chat: email, handle: email, ts: "2026-09-05 17:24:01", text: "imessage" }), - msg({ chat: "+15550100002", handle: "+15550100002", ts: "2026-09-05 18:00:00", text: "other" }), + msg({ chat: phone, handle: phone, ts: "2026-09-04T21:32:29Z", text: "sms" }), + msg({ chat: email, handle: email, ts: "2026-09-05T17:24:01Z", text: "imessage" }), + msg({ chat: "+15550100002", handle: "+15550100002", ts: "2026-09-05T18:00:00Z", text: "other" }), ]; const aliases = { [phone]: email }; expect(selectThread(raw, email, false, 80, [], aliases).map((m) => m.text)) diff --git a/ui.test.ts b/ui.test.ts index 95b8e19..e532607 100644 --- a/ui.test.ts +++ b/ui.test.ts @@ -289,7 +289,9 @@ describe("QML safety invariants", () => { expect(panel).toContain("openThread(threads[i])"); const catcher = panel.slice(panel.indexOf("function catchNavText"), panel.indexOf("function catchEscape")); expect(catcher).toContain('text >= "1" && text <= "9"'); - expect(catcher).toContain("root.draftPath"); + // A queued attachment still blocks the 1-9 jump: with a draft armed the + // keystroke belongs to the caption, not to thread navigation. + expect(catcher).toContain("root.attachCount > 0"); expect(catcher).toContain("return handleTextKey(text) === true"); const fn = handleTextKeySource(); expect(fn).toContain("if (searching || newMode) return false"); @@ -421,7 +423,9 @@ describe("QML safety invariants", () => { test("bubble actions reuse the click handlers and never steal a real send", () => { // Enter/Ctrl+C/Ctrl+R act on the selection only with an empty field and // no queued file — a queued file's Enter is a send, and must stay one. - expect(panel).toContain('var b = empty && root.draftPath === "" ? root.selectedBubble() : null'); + // draftPath became attachCount when a message learned to carry SEVERAL + // files; the guard is the same one — no queued attachment. + expect(panel).toContain('var b = empty && root.attachCount === 0 ? root.selectedBubble() : null'); const open = qmlFunction("openBubble"); expect(open).toContain("openAttachment(b.attachments[0])"); expect(open).toContain("openShare(urls, false)"); // a link goes to the sheet, never straight to the browser @@ -751,7 +755,6 @@ test("the share sheet steps through a message's links", () => { expect(qmlFunction("showShareUrl")).not.toContain('shareQr = ""'); }); - test("a thread response taken before a local send or failure cannot replace bubbles", () => { const start = panel.indexOf("onStreamFinished: {", panel.indexOf("id: threadProc")); const brace = panel.indexOf("{", start); @@ -800,3 +803,77 @@ test("tapbacks on picture-only messages get a pill on the picture", () => { expect(run(1,1,qt,"sample",{y:0},rect,3)).toBe(false); expect(run(99,0,qt,"sample",{y:0},rect,3)).toBe(false); }); + +describe("a message can carry several files (multi-file drafts)", () => { + function source(fn: string, until: string) { + const start = panel.indexOf(`function ${fn}`); + return panel.slice(start, panel.indexOf(until, start)); + } + + test("a drop attaches EVERY file, not just the first", () => { + // drop.urls[0] attached one photo of five and discarded the rest with no + // message — worse than refusing the drop. + expect(panel).not.toContain("var u = String(drop.urls[0])"); + expect(panel).toContain("for (var i = 0; i < drop.urls.length; i++)"); + expect(panel).toContain("root.addAttachments(paths)"); + }); + + test("the draft is a list, and it is capped", () => { + expect(panel).toContain("property var attachDrafts: []"); + expect(panel).toContain("readonly property int attachMax: 10"); + // A stray drop of a whole folder is refused, not turned into 80 sends. + const add = source("addAttachment", "function addAttachments"); + expect(add).toContain("root.attachDrafts.length >= root.attachMax"); + // the same file twice is one attachment + expect(add).toContain("root.attachDrafts[i].path === p"); + }); + + test("files ship one part at a time, and only the first carries the caption", () => { + // copyProc on current main is a multi-line Process with onExited; slice + // to copyText, the next function after the pump. + const pump = source("pumpFileSend", "function copyText"); + // fileSendProc is a single Process: a second start would clobber the first. + expect(pump).toContain("if (fileSendProc.running) return"); + expect(pump).toContain("root.fileQueue = root.fileQueue.slice(1)"); + // caption over stdin, never argv (audit #4) + expect(pump).toContain("--caption-stdin"); + expect(pump).not.toContain("root.sendCaption]"); + // spent after the first part, so five files do not post one sentence five times + expect(pump).toContain('root.sendCaption = ""'); + }); + + test("only the part that shipped is retired; a failure keeps the rest attached", () => { + expect(panel).toContain("root.removeAttachment(root.sendDraftPath)"); + // mid-batch the field must not clear and focus must not jump + expect(panel).toContain("if (root.fileQueue.length > 0)"); + expect(panel).toContain("still attached"); + }); + + test("draft chips are one per row, never a RowLayout of N", () => { + // Summed implicit widths stretch the column past the panel and take every + // right-aligned element off-screen with it (CLAUDE.md). + expect(panel).toContain("id: attachList"); + expect(panel).toContain("model: root.attachDrafts"); + // each chip removes ITSELF, not the whole draft + expect(panel).toContain("root.removeAttachment(modelData.path)"); + expect(panel).toContain("attachList.width"); + }); + + test("switching threads still drops every queued file", () => { + // a queued file must never survive into another conversation + const clear = source("clearAttachments", "/** Ship the next queued file"); + expect(clear).toContain("root.attachDrafts = []"); + expect(clear).toContain("root.fileQueue = []"); + }); +}); + +describe("a multi-part send is pinned to the thread it started in", () => { + test("the service is captured once, not re-read per part", () => { + // root.active can change under a batch; a later part must not go out on a + // different service from the first (war room #2). + expect(panel).toContain("property string sendService"); + expect(panel).toContain('root.sendService !== "" ? ["--service", root.sendService] : []'); + const pump = panel.slice(panel.indexOf("function pumpFileSend"), panel.indexOf("function copyText")); + expect(pump).not.toContain("root.active.service"); + }); +});