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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ jobs:
python3 -m py_compile otp-desktop.py
python3 test_otp_desktop.py
- name: Mac tools compile (python3 syntax, no macOS needed)
run: python3 -m py_compile bridge/mac/imsg bridge/mac/imsg-send bridge/mac/contacts bridge/mac/tcc-check bridge/mac/blip-check bridge/mac/blip-dispatch bridge/mac/imsg-read bridge/mac/calling_codes.py bridge/mac/contact-save
run: python3 -m py_compile bridge/mac/imsg bridge/mac/imsg-send bridge/mac/contacts bridge/mac/tcc-check bridge/mac/blip-check bridge/mac/blip-dispatch bridge/mac/imsg-read bridge/mac/calling_codes.py bridge/mac/contact-save bridge/mac/read_state.py
- name: read pushes require verified completion (no macOS needed)
run: python3 bridge/mac/test_read_verification.py
- name: read state and action verification (synthetic SQLite, no macOS needed)
run: python3 bridge/mac/test_read_sync.py
- name: group-photo lookup (no macOS needed)
run: python3 bridge/mac/test_group_photo.py
- name: phone matching, the way Contacts does it (no macOS needed)
Expand Down
92 changes: 61 additions & 31 deletions BarWidget.qml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import QtQuick
import "ReadSync.mjs" as ReadSync
import Quickshell
import Quickshell.Io
import qs.Commons
Expand Down Expand Up @@ -105,7 +106,7 @@ BarWidget {
var st = JSON.parse(text())
var counts = st.unreadCounts || {}
var n = 0
for (var k in counts) n += Number(counts[k]) || 0
for (var k in counts) if ((Number(counts[k]) || 0) > 0) n++
root.unread = n; root.online = true; root.healthy = true
} catch (e) {}
}
Expand Down Expand Up @@ -320,6 +321,11 @@ BarWidget {
// run (`imsg chats`); a shallow poll returns the window's rows. Kept so a
// shallow result can be overlaid rather than replacing the model.
property var deepThreads: []
function unreadChatCount(list) {
var n = 0
for (var i = 0; i < list.length; i++) if ((Number(list[i].unread) || 0) > 0) n++
return n
}
function overlayThreads(deep, shallow) {
var byChat = {}
for (var i = 0; i < shallow.length; i++) byChat[String(shallow[i].chat)] = shallow[i]
Expand Down Expand Up @@ -351,36 +357,24 @@ BarWidget {
}
property bool collectorReserved: false // a queued run owns the next event-loop turn

function refresh(deep, markRead, readChat, seen) {
function refresh(deep, markRead, readChat, seen, unreadChat, act, actTarget) {
var req = { deep: deep === true, markRead: markRead === true,
readChat: String(readChat || ""), seen: String(seen || "") }
readChat: String(readChat || ""), seen: String(seen || ""),
unreadChat: String(unreadChat || ""),
act: String(act || ""), actTarget: String(actTarget || "") }
if (collector.running || collectorReserved) { enqueueRefresh(req); return }
runRefresh(req)
}
function enqueueRefresh(req) {
var q = refreshQueue.slice()
// Coalesce identical state transitions: plain refreshes merge with plain
// refreshes, and same-chat read refreshes merge too (viewing a thread
// makes every ping carry its readChat — without this the queue grows one
// entry per ping). mark-all stays FIFO and is never overwritten.
if (!req.markRead) {
for (var i = 0; i < q.length; i++) {
if (!q[i].markRead && q[i].readChat === req.readChat) {
q[i] = { deep: q[i].deep || req.deep, markRead: false, readChat: req.readChat,
seen: req.seen > q[i].seen ? req.seen : q[i].seen }
refreshQueue = q
return
}
}
}
q.push(req)
refreshQueue = q
refreshQueue = ReadSync.enqueueRefresh(refreshQueue, req)
}
function runRefresh(req) {
var args = ["bun", collectorPath]
if (req.deep) args.push("--deep")
if (req.markRead) args.push("--mark-read")
if (req.readChat !== "") {
if (req.unreadChat) args.push("--mark-unread", req.unreadChat)
if (req.act) { args.push("--act", req.act); if (req.actTarget) args.push("--target", req.actTarget) }
if (req.readChat !== "" && req.readChat !== req.unreadChat) {
args.push("--read", req.readChat)
if (req.seen !== "") args.push("--seen", req.seen)
}
Expand All @@ -398,19 +392,38 @@ BarWidget {
// genuinely NEWER inbound message exists. Entries expire once persisted
// state has caught up (or after 60s, whichever first).
property var localReads: ({})
property var localUnreads: ({})

function noteLocalRead(chat, lastTs) {
var m = Object.assign({}, localReads)
m[String(chat)] = { ts: String(lastTs || ""), at: Date.now() }
localReads = m
var u = Object.assign({}, localUnreads)
if (u[String(chat)]) { delete u[String(chat)]; localUnreads = u }
}

function noteLocalUnread(chat) {
var u = Object.assign({}, localUnreads)
u[String(chat)] = { at: Date.now() }
localUnreads = u
var m = Object.assign({}, localReads)
if (m[String(chat)]) { delete m[String(chat)]; localReads = m }
}

function applyLocalReads(list) {
var now = Date.now()
var m = Object.assign({}, localReads)
var u = Object.assign({}, localUnreads)
var dirty = false
var out = list.map(function(t) {
var r = m[String(t.chat)]
var id = String(t.chat)
var forced = u[id]
if (forced) {
if (now - forced.at > 60000) { delete u[id]; dirty = true }
else if (Number(t.unread) > 0) { delete u[id]; dirty = true; return t }
else return Object.assign({}, t, { unread: 1 })
}
var r = m[id]
if (!r) return t
// persistence caught up (or nothing left) — retire the entry so stale
// suppression rules can't linger for the TTL (Codex #5)
Expand All @@ -422,14 +435,14 @@ BarWidget {
if (String(t.last_ts) > r.ts) return t
return Object.assign({}, t, { unread: 0 })
})
if (dirty) localReads = m
if (dirty) { localReads = m; localUnreads = u }
return out
}

/** `seen` = the newest ts the surface actually rendered. Without it the
* mark went through the sidebar's last_ts, which can be a message that
* arrived after the snapshot the user is looking at (Astra A#3). */
function markThreadRead(chat, seen) {
function markThreadRead(chat, seen, act) {
var c = String(chat)
var lastTs = seen ? String(seen) : ""
var list = threads.map(function(t) {
Expand All @@ -439,11 +452,24 @@ BarWidget {
})
noteLocalRead(c, lastTs)
threads = list
unread = list.reduce(function(n, t) { return n + (Number(t.unread) || 0) }, 0)
refresh(true, false, c, lastTs)
unread = unreadChatCount(list)
refresh(true, false, c, lastTs, "", act || "", act ? c : "")
}

function markThreadUnread(chat) {
var c = String(chat)
var list = threads.map(function(t) {
if (String(t.chat) !== c) return t
return Number(t.unread) > 0 ? t : Object.assign({}, t, { unread: 1 })
})
noteLocalUnread(c)
threads = list
unread = unreadChatCount(list)
refresh(true, false, "", "", c)
}

function markAllRead() {
localUnreads = ({})
for (var i = 0; i < threads.length; i++)
noteLocalRead(String(threads[i].chat), String(threads[i].last_ts || ""))
threads = threads.map(function(t) {
Expand Down Expand Up @@ -513,21 +539,25 @@ BarWidget {
// Filter through the optimistic-read ledger: a poll that was
// already in flight when the user opened a thread must not
// resurrect its dot for one round-trip (the double-flash).
var list = root.applyLocalReads(Array.isArray(d.threads) ? d.threads : [])
var list = Array.isArray(d.threads) ? d.threads : []
// A shallow poll carries only the message window's rows. Overlay
// it on the last complete list so the panel never opens onto a
// dozen rows that grow (and re-pin) a deep run later.
if (d.deep === true) root.deepThreads = list
else if (root.deepThreads.length > 0) list = root.overlayThreads(root.deepThreads, list)
if (d.unreadCounts) list = list.map(function(t) {
return Object.assign({}, t, { unread: Number(d.unreadCounts[String(t.chat)]) || 0 })
})
list = root.applyLocalReads(list)
// Reassigning `threads` rebuilds the panel's list Repeater and
// resets its scroll — with push, that was every few seconds.
// Skip the assignment when nothing actually changed.
var j = JSON.stringify(list)
if (j !== root.threadsJson) {
root.threads = list
}
root.unread = root.threads.reduce(function(n, t) { return n + (Number(t.unread) || 0) }, 0)
root.healthy = d.persisted !== false
root.unread = root.unreadChatCount(root.threads)
root.healthy = d.persisted !== false && root.lastError === ""
// A message that carries a security code gets the code toast only:
// its ordinary preview would put the digits into the daemon's
// on-disk history like any other body (Astra #1).
Expand Down Expand Up @@ -578,7 +608,7 @@ BarWidget {
// (Mac down, watcher restarting) it is the old 6 s poll.
// offline: back off to 30 s — a Mac that is off for the night must not
// eat a bun + ssh probe every 6 s (war room #19); "ready" restores 6 s
interval: root.watchAlive ? 60000 : (root.online ? 6000 : 30000)
interval: root.watchAlive ? 10000 : (root.online ? 6000 : 30000)
running: root.leader
repeat: true
triggeredOnStart: true
Expand Down Expand Up @@ -998,7 +1028,7 @@ BarWidget {
var parts = []
if (!root.online) parts.push("Mac unreachable — iMessage bridge offline")
else if (root.unread === 0) parts.push("No unread messages")
else parts.push(root.unread + " unread message" + (root.unread === 1 ? "" : "s"))
else parts.push(root.unread + " unread")

if (root.online && root.unread > 0) {
var hot = root.threads.filter(function(t){ return t.unread > 0 }).slice(0, 4)
Expand Down
46 changes: 41 additions & 5 deletions BlipView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,8 @@ FocusScope {
}
/** The one gate for "this thread was looked at": a surface that marks read,
* and not a thread merely peeked. */
function markRead(chat, seen) {
if (hostWidget && readActive && !peeking) hostWidget.markThreadRead(chat, seen)
function markRead(chat, seen, act) {
if (hostWidget && readActive && !peeking) hostWidget.markThreadRead(chat, seen, act)
}
function showThread(t) {
messageMenu.close()
Expand Down Expand Up @@ -803,6 +803,11 @@ FocusScope {
if (!root.hostWidget || root.unread === 0) return
root.hostWidget.markAllRead()
}
function markUnread(t) {
if (!t || !root.hostWidget) return
if (isShowing(t) || (inThread && String(active.chat) === String(t.chat))) back()
root.hostWidget.markThreadUnread(String(t.chat))
}

/** Chip icon for an attachment's mime type. */
function attachmentIcon(mime) {
Expand Down Expand Up @@ -1745,7 +1750,7 @@ FocusScope {
var seen = ""
for (var k = 0; k < list.length; k++) {
if (list[k].pending === true || list[k].scheduled === true) continue
var ts = String(list[k].ts || ""); if (ts > seen) seen = ts
var ts = String(list[k].seen_ts || list[k].ts || ""); if (ts > seen) seen = ts
}
// thread.ts hands back the sends it is still waiting on for this
// chat; keep asking for a few seconds, then leave it to the next
Expand Down Expand Up @@ -2221,6 +2226,12 @@ FocusScope {
openThread(threads[i])
return true
}
if (text === "u" || text === "U") {
if (searching || newMode) return false
var t = threads[cursor]
if (t) markUnread(t)
return true
}
if ((inThread && !splitView) || searching || newMode) return false
if (text === "r" || text === "R") { if (hostWidget) hostWidget.refresh(true, false); return true }
if (text === "a" || text === "A") { markAllRead(); return true }
Expand All @@ -2229,6 +2240,7 @@ FocusScope {
function catchNavText(text) {
if (contactReview.opened) return false
var jump = text === "/" || text === "n" || text === "N"
|| text === "u" || text === "U"
|| (text >= "1" && text <= "9")
if (!jump) return false
if (searchField.activeFocus || newField.activeFocus || bubbleFocused) return false
Expand Down Expand Up @@ -2450,8 +2462,6 @@ FocusScope {
}
// Clear local marks and, unless push_read=off, ask Messages
// on the Mac to mark its conversations read too.
// TapHandler, not MouseArea: the thread rows' proven pattern —
// the MouseArea version could lose clicks to the dismiss layer.
Text {
id: markAllBtn
visible: root.unread > 0 && !root.searchShowing && !root.newMode
Expand Down Expand Up @@ -4052,12 +4062,38 @@ FocusScope {
}

property var contactContext: null
function isDmChat(t) {
var c = t ? String(t.chat || "") : ""
return /^\+?[0-9]{3,15}$/.test(c) || c.indexOf("@") > 0
}
function menuIcon(name) { return Qt.resolvedUrl("icons/" + name + ".svg") }
Menu {
id: contactMenu
width: 240
MenuItem {
text: "Review contact"
onTriggered: if (root.contactContext) contactReview.review(root.contactContext)
}
MenuItem {
visible: !(root.contactContext && Number(root.contactContext.unread || 0) > 0)
height: visible ? implicitHeight : 0
text: "Mark as Unread"
icon.source: root.menuIcon("unread")
enabled: root.isDmChat(root.contactContext)
onTriggered: if (root.contactContext) root.markUnread(root.contactContext)
}
MenuItem {
visible: root.contactContext && Number(root.contactContext.unread || 0) > 0
height: visible ? implicitHeight : 0
text: "Mark as Read"
icon.source: root.menuIcon("read")
onTriggered: {
var t = root.contactContext
if (!t) return
root.peeking = false
root.markRead(String(t.chat), String(t.last_ts || ""), root.isDmChat(t) ? "read" : "")
}
}
}
ContactReview {
id: contactReview
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
default player opens. Re-run `blip-setup` so the Mac's `imsg` picks this up.
Audio plays with no window (mpv opened an empty black one); click the chip
again to stop it.
- **Conversation menu.** Right-click is Mark as Unread or Mark as Read.
Read-state clicks Messages' own menu (DMs). Mark as Unread stays on direct
messages. A group read still reaches the Mac through
Messages' groupid link. Mark-all-read stays on the list, the `a` key, and
the bar right-click.
- **Blue dots follow Messages' `is_read`, not only the read cursor.** Mark as
Unread can leave `last_read_message_timestamp` ahead of a row that is still
unread. The dot follows the newest inbound `is_read=0`, including a reaction.
The header and the bar badge count conversations, not messages.
- **Read sync retries until the Mac agrees.** A read or unread click is saved
before the push and retried with backoff. `read_state.py` is the shared
metadata snapshot, including manual unread below Apple's cursor and merged
phone/email DMs. Re-run `blip-setup` so the Mac picks up `read_state.py`.

## 2.6.2 — 2026-09-26 — scrolling that fits your mouse, pins that stay pinned

Expand Down
Loading
Loading