Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 158 additions & 62 deletions BlipView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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) })
Comment on lines +1013 to +1014

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove deleted files from the active send queue

While the first part is in flight, clicking ✕ on a later attachment only removes it from attachDrafts; the fileQueue snapshot still contains that path, so pumpFileSend() subsequently transmits a file the user explicitly removed. Either remove not-yet-started paths from fileQueue as well or disable removal for the active batch.

AGENTS.md reference: AGENTS.md:L8-L10

Useful? React with 👍 / 👎.

}

function clearAttachments() {
root.attachDrafts = []
root.fileQueue = []
}

function startPaste() {
Expand Down Expand Up @@ -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"
Comment on lines +1370 to +1374

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the attachment-limit error for /attach

When ten files are already queued, addAttachment(p) reports the limit and returns without adding the requested path, but these following assignments clear the command and overwrite that error with “10 files attached.” The user is therefore told the command succeeded and may send a batch that silently omits the requested file; only clear the field and show the success note when an attachment was actually added.

Useful? React with 👍 / 👎.

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
}

Expand Down Expand Up @@ -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
Comment on lines +1481 to +1484

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move the file-send state machine into tested TypeScript

This adds batching decisions—queue advancement, ordering, caption consumption, and retry state—directly to QML, while the new tests only search the QML source for strings and cannot execute these transitions. Extract this state machine into TypeScript and unit-test success, failure, removal, and navigation transitions, leaving QML to render and dispatch effects as required by the repository.

AGENTS.md reference: AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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 || ""))
}
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
}
}
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading