From fd612e3fe213a58f647584bf074c25108f46dada Mon Sep 17 00:00:00 2001 From: Damon Janis Date: Sat, 26 Sep 2026 21:07:27 -0500 Subject: [PATCH 1/4] Sync read state through a durable, verified queue A read pushed to the Mac was a detached imsg-read call. When it failed, the Mac and the iPhone stayed unread and Blip showed nothing. Collector: every explicit read is saved to pendingReads in state.json before any Mac call. Each poll runs one due action. A failed action keeps its bounded error, retries with 2-60 s backoff, and survives restarts. Only a complete read snapshot from the Mac retires an intent. Under push_read=thread, per-chat reads cover DMs and groups, the same ids pushReadArgs already accepts. A read carries --through, so an inbound newer than the rendered --seen stays unread and a stale retry cannot clear it. Each intent records a desired read/unread state, so the saved format stays the same when other gestures use the queue. While the watcher is alive, the fallback poll runs every 10 s instead of every 60 s, so a queued retry is not held for a minute. Mac: read_state.py is the metadata-only unread snapshot that `imsg read-state` and imsg-read share. It follows is_read: it counts the trailing unread inbound rows, reactions included, and a read row is a barrier against old ghost rows. It folds merged phone/email DMs and never selects message bodies. imsg-read --chat counts over the whole conversation cluster, rechecks --through, and wakes a dormant menu once. It exits 0 only when chat.db agrees. `imsg chats` keeps quiet unread conversations past the sidebar limit, and rich rows carry the reaction's activity_ts. UI: blue dots and the badge follow the snapshot's per-chat counts. The badge and the header count conversations, not messages. A reaction in the rendered view advances seen_ts without moving its bubble. Refresh coalescing moves to read-sync.ts and never merges across mark-all. Mark-all stays on the bar right-click, the list button, and the a key. The conversation menu is unchanged. --- .github/workflows/ci.yml | 4 +- BarWidget.qml | 42 +++-- BlipView.qml | 4 +- CHANGELOG.md | 8 + CLAUDE.md | 55 ++++--- README.md | 34 ++-- ReadSync.mjs | 20 +++ bridge/mac/imsg | 52 +++++- bridge/mac/imsg-read | 83 ++++++---- bridge/mac/install.sh | 2 +- bridge/mac/read_state.py | 67 ++++++++ bridge/mac/test_read_sync.py | 186 +++++++++++++++++++++ collector.test.ts | 22 ++- collector.ts | 306 +++++++++++++++++++++++++++++------ docs/PRIVACY.md | 10 +- read-sync-qml.ts | 3 + read-sync.test.ts | 186 +++++++++++++++++++++ read-sync.ts | 89 ++++++++++ scripts/test-read-ui.py | 14 +- thread.test.ts | 11 ++ thread.ts | 3 + ui.test.ts | 20 +++ 22 files changed, 1058 insertions(+), 163 deletions(-) create mode 100644 ReadSync.mjs create mode 100644 bridge/mac/read_state.py create mode 100644 bridge/mac/test_read_sync.py create mode 100644 read-sync-qml.ts create mode 100644 read-sync.test.ts create mode 100644 read-sync.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 957db2e..40c0790 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/BarWidget.qml b/BarWidget.qml index a21ede1..e51bece 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -1,4 +1,5 @@ import QtQuick +import "ReadSync.mjs" as ReadSync import Quickshell import Quickshell.Io import qs.Commons @@ -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) {} } @@ -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] @@ -358,23 +364,7 @@ BarWidget { 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] @@ -439,7 +429,7 @@ BarWidget { }) noteLocalRead(c, lastTs) threads = list - unread = list.reduce(function(n, t) { return n + (Number(t.unread) || 0) }, 0) + unread = unreadChatCount(list) refresh(true, false, c, lastTs) } @@ -513,12 +503,16 @@ 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. @@ -526,8 +520,8 @@ BarWidget { 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). @@ -578,7 +572,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 @@ -998,7 +992,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) diff --git a/BlipView.qml b/BlipView.qml index 750ce1a..174b24a 100644 --- a/BlipView.qml +++ b/BlipView.qml @@ -1745,7 +1745,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 @@ -2450,8 +2450,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5523bd4..cc0db90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ 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. +- **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 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 diff --git a/CLAUDE.md b/CLAUDE.md index b24327c..e2b216e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,12 +58,18 @@ what it is handed. Keep it that way. `read`; phone-synced via Messages in iCloud) AND newer than the local mark. Phone-read clears Blip within a poll; Blip-read also reaches the Mac when `push_read=thread` is enabled (DMs and groups, through Messages, never SQL writes). - Tapback rows and the self-thread never count (no Apple client badges them). + Complete read-state snapshots reconcile the Mac independently of the preview + window; local marks apply to local-only reads. The self-thread never counts. + Tapback rows never count toward the badge, but an incoming reaction DOES + participate in the read-state snapshot: Messages can mark a reaction unread + without changing the prior text row. chat.db carries GHOST is_read=0 rows years old — never trust is_read alone. - **Read marks are per-chat, clamped to now, and --seen-based.** A message can carry a FUTURE timestamp (tz skew); a mark taken from the global max once suppressed unrelated threads until "tomorrow". The panel passes the - newest VISIBLE ts (`--seen`) so mid-round-trip arrivals stay unread. + newest VISIBLE activity ts (`--seen`) so mid-round-trip arrivals stay unread. + Rich reactions carry `activity_ts`; decorated bubbles expose `seen_ts` without + changing their original display timestamp. Pending sends never advance it. - **Every stamp inside Blip is UTC; local time is a DISPLAY concern.** The bridge emits ISO-8601 UTC to the second (`2026-09-07T18:33:12Z`, `fmt_ts`), because fixed-width UTC is the one format whose LEXICAL order is @@ -185,21 +191,27 @@ what it is handed. Keep it that way. with the physical modifiers still held from the hotkey, and the digits fired Super+Shift+ binds (found the hard way, 2026-09-04). Never a group, never the self-thread, once per message through the `code:` ring. -- **A per-thread read push fires on the TRANSITION, not on the poll.** Every - poll while a thread is open carries its `readChat` (that is what stops a - message landing in the open conversation from flashing unread), so - `pushReadArgs` gates `--chat` on `clearedUnread` — did THIS run turn unread - into read? Without it the Mac was told once per poll, and each telling opens - the conversation there, because aiming Messages' menu at one chat means - opening it: five ssh round trips a minute, four of them "nothing unread" - (measured 2026-09-08). Consequence to keep in mind: a conversation Blip - already considers read but Apple still counts unread is never pushed - per-thread; `--all` is what clears those. `push_read` defaults to `all`, - which pushes ONLY on the mark-all gesture — reading a thread then leaves the - iPhone badge alone, which looks exactly like a broken push, so `status` - reports the live policy as `read_push=`. The watcher field beside it is - `watch=`; it was called `push=` until 2.4.0 and the collision sent a - diagnosis the wrong way. +- **Read sync is an acknowledged, durable action queue.** `pendingReads` in + state.json stores only chat ids, desired read/unread state, visible timestamps, + retry counters/deadlines and bounded bridge status errors. Persist BEFORE any + Mac mutation, then execute one due action per collector run. Never detach it. + A failed action survives restarts and retries with backoff (2–60 seconds). + `imsg read-state` returns the COMPLETE metadata-only unread snapshot; it is + independent of the message preview window. `read_state.py` is shared with + `imsg-read` so the writer and reader agree, including manual unread below + Apple's read cursor, old ghost rows and merged phone/email DMs. + Under `push_read=thread`, confirmed Mac state replaces historical local + read marks for DMs and for groups (Messages' groupid link). A pending intent + alone overrides it. + Compare remote unread against the rendered `--seen` BEFORE updating local + marks. A stale read must not clear a newer inbound; recheck with `--through` + on the Mac. Newer explicit gestures replace old same-chat pending intents. + Mark-all is ordered before subsequent per-chat intents; refresh coalescing + never crosses an explicit gesture. Mac menu actions hold an advisory lock + because Messages selection is process-global. `push_read` still defaults to + `all`; this installation may opt into `thread` in bridge.conf. `off` suppresses + explicit menu read pushes too. `read_push=` reports policy; `watch=` reports + watcher health. Never conflate those. - **No message content in state.json.** `~/.local/state/blip/state.json` holds timestamps, counts, opaque SHA-256 toast keys, self-chat ids, and group metadata. It is atomic and `0600`; no message bodies are allowed. EXCEPTION @@ -501,8 +513,8 @@ to whatever has focus otherwise. app. `imsg-read` used to read "disabled" as "nothing unread" and exit 0, so every push was a silent no-op unless you happened to be using Messages. Now it counts what Messages itself calls unread in chat.db - (inbound, non-tapback, `item_type=0`, newer than the chat's - `last_read_message_timestamp` — the Dock-badge definition) before and + (trailing inbound unread rows, including reactions but excluding announcements; an + explicit unread may sit below `last_read_message_timestamp`) before and after; when the menu is dormant it activates Messages for 0.7 s, clicks, hands focus straight back, and exits 75 with a reason unless the final count is zero. Partial progress or an unreadable database is not success. The collector records every push in @@ -539,3 +551,8 @@ The composer keeps arrow/Home/End keys for native text editing; PageUp/PageDown select history bubbles. `ComposerInput.qml` exposes the editable accessibility field and draws spelling ranges supplied by `spellcheck.ts`. Draft text stays on bounded stdin, never argv or disk; the helper emits only UTF-16 ranges. + +Read-sync UI regression: `python3 scripts/test-read-ui.py` executes the shipping +QML badge bindings and read/unread functions with synthetic data under QtTest. +`threadsJson` must remain bound to the CURRENT threads, including optimistic +edits; a poll-only cache allowed a 3-unread badge alongside only 2 unread rows. diff --git a/README.md b/README.md index c3e16df..cc54ada 100644 --- a/README.md +++ b/README.md @@ -779,20 +779,26 @@ poll. Yes, that shipped once. **Unread is a ledger, not a window.** The latest 150 rows are enough for normal previews, but unread counts and oldest-unread timestamps live in a metadata-only -per-chat ledger. Blip expands the fetch to cover new arrivals and the oldest -outstanding unread, then rebuilds exact counts from that range. An unread cannot -fall off the preview window or remain counted after deletion. - -**Reads reach the Mac through its menu bar.** Nothing writes `is_read` into -`chat.db` — that would not sync to your phone. Instead *mark all read* has the -Mac click Messages' own **Conversation ▸ Mark All as Read**, and Messages does -the syncing. One catch, found the hard way: AppKit only validates an app's -menus while that app is active, so with Messages in the background every item -in that menu reports *disabled* — which used to read as "nothing unread" and -silently did nothing. Blip now checks what Messages itself counts as unread in -`chat.db` before and after, and when the menu is dormant it activates Messages -for well under a second, clicks, and hands focus straight back to whatever you -had in front. +per-chat ledger. The Mac's complete read-state snapshot refreshes this ledger +without fetching message bodies. Older bridges fall back to expanding the fetch +to cover new arrivals and outstanding unread. An unread cannot fall off the +preview window or remain counted after deletion. + +**Reads reach the Mac through its menu bar.** Set `push_read=thread` in +`~/.config/blip/bridge.conf` to synchronize each direct conversation you read. +The default `all` synchronizes only the explicit mark-all gesture; `off` keeps +read actions local. Per-thread actions briefly select the conversation in +Messages and restore the previous app's focus. With `push_read=thread`, a +group read reaches the Mac through Messages' groupid link, not only mark-all. + +Read actions are saved before contacting the Mac, verified against +Messages' database, and retried after temporary failures or reconnects. +Permission errors stay visible in Blip until resolved. A newer inbound beyond +the visible `--seen` timestamp cancels an old read retry so it stays unread. +A complete metadata-only `imsg read-state` snapshot reconciles blue dots even +for conversations outside the recent-message window. Confirmed local overrides +are retired, allowing later Mac/iPhone changes to take effect. Both updated +Mac tools require the sibling `read_state.py` module. `push_read` in `bridge.conf` takes three values, and the default surprises people: **`all`** (the default) pushes *only* on the mark-all gesture, so diff --git a/ReadSync.mjs b/ReadSync.mjs new file mode 100644 index 0000000..583cf71 --- /dev/null +++ b/ReadSync.mjs @@ -0,0 +1,20 @@ +// read-sync.ts +function enqueueRefresh(queue, req) { + const q = queue.slice(); + const barrier = (r) => r.markRead; + if (!barrier(req)) { + for (let i = q.length - 1;i >= 0; i--) { + if (barrier(q[i])) + break; + if (q[i].readChat === req.readChat) { + q[i] = Object.assign({}, req, { deep: q[i].deep || req.deep, seen: req.seen > q[i].seen ? req.seen : q[i].seen }); + return q; + } + } + } + q.push(req); + return q; +} +export { + enqueueRefresh +}; diff --git a/bridge/mac/imsg b/bridge/mac/imsg index 7f2d20b..076f4c2 100755 --- a/bridge/mac/imsg +++ b/bridge/mac/imsg @@ -37,6 +37,7 @@ import unicodedata from glob import glob sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +from read_state import read_state from calling_codes import REGION_TO_CALLING_CODE, split_calling_code # noqa: E402 (sibling module) DB_PATH = os.path.expanduser("~/Library/Messages/chat.db") @@ -922,6 +923,11 @@ def enrich(con, rows, with_names: bool): if key not in latest or r["date"] > latest[key]["date"]: latest[key] = r for (who, tg), r in latest.items(): + # A rendered reaction (or its removal) is newer activity than the + # target bubble. Preserve its timestamp for the client's read boundary. + e = extras[targets[tg]] + activity = fmt_ts(r["date"]) + e["activity_ts"] = max(e.get("activity_ts", ""), activity) if not (2000 <= r["t"] < 3000): continue # net-removed kind = r["t"] - 2000 @@ -1025,9 +1031,10 @@ def render(rows, as_json: bool, with_names: bool = True, con=None, rich: bool = "service": r["service"], "chat": r["room_name"] or r["chat_identifier"], "text": message_text(r), - # The APPLE-side read state — is_read syncs from the iPhone - # via Messages in iCloud, so a client can mirror the phone's - # badge. Own messages are trivially "read". + # Apple-side read: message.is_read, synced from the iPhone. + # Do NOT fold in chat.last_read_message_timestamp — Mark as + # Unread leaves the cursor ahead of the row while is_read=0, + # which is exactly the blue-dot state Messages shows. "read": bool(r["is_from_me"]) or bool(r["is_read"]), # edited / retracted ride the plain rows as well: the collector # polls without --rich, and its self-thread unsend handling @@ -1155,6 +1162,11 @@ def base_select(con) -> str: if _has_col(con, "schedule_type") else "0 AS schedule_type, 0 AS schedule_state," ) + last_read_col = ( + "c.last_read_message_timestamp" + if _chat_has_col(con, "last_read_message_timestamp") + else "NULL AS last_read_message_timestamp" + ) _BASE_SELECT_CACHE = f""" SELECT m.ROWID, m.date, m.is_from_me, m.text, m.attributedBody, m.service, h.id AS handle, c.chat_identifier, c.room_name, @@ -1163,7 +1175,8 @@ def base_select(con) -> str: m.thread_originator_guid AS orig_guid, m.expressive_send_style_id AS style_id, m.is_audio_message, - m.balloon_bundle_id, m.payload_data + m.balloon_bundle_id, m.payload_data, + {last_read_col} FROM {MESSAGE_SRC} m LEFT JOIN handle h ON h.ROWID = m.handle_id LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID @@ -1296,6 +1309,13 @@ def cmd_contacts(con, args): print(f"{fmt_ts_local(r['last_date'])} {r['n']:>6} {r['service']:>8} {r['handle']}{suffix}") +def conversation_window(rows, limit, read_rows): + unread_ids = {cid for r in read_rows if r["unread"] > 0 + for cid in [r["chat"], *r.get("aliases", [])]} + return [r for i, r in enumerate(rows) + if i < max(0, limit) or r["chat_identifier"] in unread_ids] + + def cmd_chats(con, args): # Every conversation, newest activity first — the COMPLETE list a client # should build its sidebar from (deriving it from a window of recent @@ -1319,10 +1339,15 @@ def cmd_chats(con, args): {filt} GROUP BY c.ROWID HAVING n > 0 - ORDER BY last_date DESC - LIMIT ?""", - (args.n,), + ORDER BY last_date DESC""" ).fetchall() + # Metadata selection is cheap; only selected rows resolve names/previews. + # A quiet unread must not disappear behind the normal sidebar row limit. + try: + unread_rows = read_state(con) + except sqlite3.OperationalError: + unread_rows = [] # legacy/minimal schemas retain the bounded list + rows = conversation_window(rows, args.n, unread_rows) if args.json: pin_ids = expand_pins_via_lookup(con, pinned_chat_identifiers()) # One entry per CONVERSATION: a group re-keyed by Messages leaves @@ -2072,6 +2097,17 @@ def cmd_attachment(con, args): pass +def cmd_read_state(con, args): + predicate = filtered_chat_predicate( + bool(getattr(args, "hide_spam", False)), + bool(getattr(args, "hide_unknown", False)), _chat_has_col(con, "is_filtered")) + rows = read_state(con, predicate, include_deleted=not HIDE_DELETED) + for row in rows: + row["oldest"] = fmt_ts(row["oldest"]) if row["oldest"] is not None else "" + row["latest"] = fmt_ts(row["latest"]) if row["latest"] is not None else "" + print(json.dumps({"version": 1, "chats": rows})) + + def cmd_watch(con, args): """Block forever, emitting one line whenever chat.db changes — a push channel for remote clients (Blip) that otherwise poll. @@ -2291,6 +2327,8 @@ def main() -> None: s.add_argument("handle", nargs="?", default="") s.add_argument("--chat", default="", help="group chat_identifier (32-hex / chat): stream its GroupPhotoImage") s.set_defaults(func=cmd_avatar) + s = sub.add_parser("read-state", parents=[common], help="complete metadata-only unread snapshot") + s.set_defaults(func=cmd_read_state) s = sub.add_parser("watch", parents=[common], help="emit a line whenever chat.db changes (push channel)") s.set_defaults(func=cmd_watch) diff --git a/bridge/mac/imsg-read b/bridge/mac/imsg-read index 61fd75a..df206cf 100755 --- a/bridge/mac/imsg-read +++ b/bridge/mac/imsg-read @@ -23,8 +23,9 @@ way to aim the menu at one conversation is to open it, and opening it brings Messages to the front. That is why Blip's default only pushes the explicit mark-all-read gesture. -Exit 0 when there was nothing to do — the menu item is disabled when nothing -is unread, which is a free guard against pointless clicks. +Exit 0 only after the database confirms the requested state. A disabled menu +alone is not success. --chat accepts --through to reject a stale +read when a newer inbound has arrived. Needs Accessibility for whatever runs it. Over Blip's ssh key that is /usr/libexec/sshd-keygen-wrapper, the same binary that already needs Full Disk @@ -44,6 +45,9 @@ import stat from contextlib import contextmanager from urllib.parse import urlencode +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +from read_state import read_state + MENU = 'menu 1 of menu bar item "Conversation" of menu bar 1' TIMEOUT = 150 # the first System Events call can be a consent prompt; give it the ~2 minutes macOS gives it (Astra C#8) @@ -94,21 +98,6 @@ def osa(script: str) -> tuple[int, str]: DB_PATH = os.path.expanduser("~/Library/Messages/chat.db") -# What Messages ITSELF still counts as unread: an inbound, non-tapback, -# non-announcement row newer than its chat's own read cursor. That cursor -# (chat.last_read_message_timestamp) is what "Mark All as Read" advances and -# what the Dock badge follows; message.is_read alone carries ghosts. -UNREAD_SQL = """ -SELECT count(DISTINCT c.ROWID) - FROM message m - JOIN chat_message_join j ON j.message_id = m.ROWID - JOIN chat c ON c.ROWID = j.chat_id - WHERE m.is_from_me = 0 AND m.is_read = 0 AND m.item_type = 0 - AND (m.associated_message_type IS NULL OR m.associated_message_type = 0) - AND m.date > c.last_read_message_timestamp -""" - - IMSG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "imsg") _IMSG_MODULE = None @@ -144,29 +133,43 @@ def cluster_ids(con, handle): def unread_on_mac(handle=None): - """Conversations Messages still counts as unread (see UNREAD_SQL), or None - when chat.db cannot be read — a guess would be worse than not knowing. + """Conversations read_state still counts as unread, or None when chat.db + cannot be read. That snapshot follows is_read, so a manual unread below + Apple's read cursor still counts, and so does an unread reaction. A handle is scoped to its whole conversation cluster, never to one chat - row: the count is the referee for the push, so it has to cover every row - the push can clear.""" + row: the count is the referee for the push.""" try: con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=5) # imsg's cluster queries read their columns by name. con.row_factory = sqlite3.Row try: - sql, args = UNREAD_SQL, () - if handle: - ids = cluster_ids(con, handle) - sql += f" AND c.chat_identifier IN ({', '.join('?' * len(ids))})" - args = tuple(ids) - return int(con.execute(sql, args).fetchone()[0]) + rows = read_state(con) + if not handle: + return sum(1 for row in rows if row["unread"] > 0) + ids = set(cluster_ids(con, handle)) + return sum(1 for row in rows if row["unread"] > 0 and ( + row["chat"] in ids or handle in (row["aliases"] or []) + or any(alias in ids for alias in row["aliases"] or []))) finally: con.close() except Exception: return None +def newer_than_seen(handle, seen): + """Recheck on the Mac immediately before selecting a chat for a read.""" + from datetime import datetime, timezone + try: + boundary = int(datetime.fromisoformat(seen.replace("Z", "+00:00")).timestamp() - 978307200) * 1000000000 + # Wire stamps have second precision; include the whole visible second. + with sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=5) as con: + return any(row["latest"] >= boundary + 1000000000 for row in read_state(con) + if row["chat"] == handle or handle in row["aliases"]) + except Exception: + return True # cannot safely apply a stale read + + def settle(before, handle=None, tries=8, pause=0.4): """Wait up to ~3 s for the requested scope to become fully read. @@ -189,8 +192,8 @@ def report(before, after, ok, what): successful click can precede database writes. Neither proves completion; unavailable verification or a partially cleared count must remain failures. """ - if after is None: - sys.stderr.write("imsg-read: could not verify read state in chat.db\n") + if after is None or (before is None and after != 0): + sys.stderr.write("imsg-read: cannot verify read state in chat.db\n") sys.exit(75) if after == 0: print("nothing unread" if before == 0 else "marked (0 unread)") @@ -379,22 +382,36 @@ def main() -> None: if len(argv) < 2: sys.stderr.write("imsg-read: --chat needs a handle\n") sys.exit(64) - before = unread_on_mac(argv[1]) + handle = argv[1] + before = unread_on_mac(handle) if before == 0: print("nothing unread") # and no reason to pull Messages forward return + if "--through" in argv: + i = argv.index("--through") + if i + 1 >= len(argv) or newer_than_seen(handle, argv[i + 1]): + sys.stderr.write("imsg-read: newer inbound arrived; leaving it unread\n") + sys.exit(75) prev = frontmost() try: - why = select_chat(argv[1]) + why = select_chat(handle) if why: sys.stderr.write(f"imsg-read: {why}\n") sys.exit(64) # Never toggle: a conversation already read offers Mark as Unread. - ok, err = click("Mark as Read") + item = "Mark as Read" + ok, err = click(item) + # A disabled menu is dormant while Messages is in the background. + # Wake it and click once more; a real error still fails immediately. + if not ok and not err: + wake_messages() + ok, err = click(item) if err: sys.stderr.write(f"imsg-read: {err}\n") sys.exit(1) - report(before, settle(before, argv[1]), ok, "Mark as Read") + # Opening can itself read the chat. Always check the database, + # even when the menu was absent, disabled, or the count unknown. + report(before, settle(before, handle), ok, item) finally: restore_front(prev) return diff --git a/bridge/mac/install.sh b/bridge/mac/install.sh index 19797dc..d90b8ef 100755 --- a/bridge/mac/install.sh +++ b/bridge/mac/install.sh @@ -21,7 +21,7 @@ dest="$HOME/.blip/bin" # denial (#36, Astra #11). check=1; for a in "$@"; do [[ $a == --no-check ]] && check=0; done mkdir -p "$dest" -for t in imsg imsg-send imsg-read contacts contact-repair.js contact-save contact-save.js tcc-check blip-check blip-dispatch calling_codes.py; do +for t in imsg imsg-send imsg-read contacts contact-repair.js contact-save contact-save.js tcc-check blip-check blip-dispatch calling_codes.py read_state.py; do if [[ -f "$here/$t" ]]; then install -m 0755 "$here/$t" "$dest/$t" else diff --git a/bridge/mac/read_state.py b/bridge/mac/read_state.py new file mode 100644 index 0000000..dfa191b --- /dev/null +++ b/bridge/mac/read_state.py @@ -0,0 +1,67 @@ +"""Metadata-only read state shared by the reader and action verifier. + +A read cursor can stay ahead of a message explicitly marked unread. Count +only the trailing unread inbound rows instead; a newer read inbound is a +barrier against historical is_read=0 ghosts. Incoming reactions participate: +Messages can mark the latest reaction unread without changing the prior text +message. They remain folded for rendering, but are not excluded from read-state +verification. Never select message bodies. +""" + + +def valid_chat_id(value): + # Same boundary as the chat-list reader. Old databases can contain broken + # identifiers; one unaddressable row must not invalidate the whole inbox. + return (isinstance(value, str) and 0 < len(value) <= 512 + and not any(ord(c) < 32 or 127 <= ord(c) <= 159 + or 0x202a <= ord(c) <= 0x202e or 0x2066 <= ord(c) <= 0x2069 for c in value)) + + +def read_state(con, predicate="", include_deleted=False): + tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")} + deleted = ("AND m.ROWID NOT IN (SELECT message_id FROM chat_recoverable_message_join)" + if not include_deleted and "chat_recoverable_message_join" in tables else "") + filt = "AND " + predicate if predicate else "" + # Messages can merge phone/email DMs under one group_id. Compute their + # canonical id from activity metadata, exactly like imsg's DM clustering. + cols = {r[1] for r in con.execute("PRAGMA table_info(chat)")} + aliases = {} + if {"group_id", "style"} <= cols: + groups = {} + for cid, gid, rid, latest in con.execute(f""" + SELECT c.chat_identifier,c.group_id,c.ROWID,MAX(m.date) + FROM chat c LEFT JOIN chat_message_join j ON j.chat_id=c.ROWID + LEFT JOIN message m ON m.ROWID=j.message_id AND m.item_type=0 {deleted} + WHERE c.style!=43 AND c.group_id IS NOT NULL AND TRIM(c.group_id)!='' + GROUP BY c.ROWID + """): + if valid_chat_id(cid): + groups.setdefault(gid, []).append((latest or -1, rid, cid)) + for members in groups.values(): + canonical = max(members)[2] + for _, _, cid in members: + aliases[cid] = canonical + con.create_function("blip_read_chat", 1, lambda cid: aliases.get(cid, cid)) + rows = con.execute(f""" + WITH inbound AS ( + SELECT DISTINCT blip_read_chat(c.chat_identifier) AS chat, m.ROWID AS id, + m.date, m.is_read + FROM chat c JOIN chat_message_join j ON j.chat_id=c.ROWID + JOIN message m ON m.ROWID=j.message_id + WHERE m.is_from_me=0 AND m.item_type=0 + AND c.chat_identifier IS NOT NULL {deleted} {filt} + ), ranked AS ( + SELECT *, SUM(CASE WHEN is_read=1 THEN 1 ELSE 0 END) OVER ( + PARTITION BY chat ORDER BY date DESC, id DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS read_barriers FROM inbound + ) + SELECT chat, + SUM(CASE WHEN read_barriers=0 THEN 1 ELSE 0 END) AS unread, + MIN(CASE WHEN read_barriers=0 THEN date END) AS oldest, + MAX(date) AS latest + FROM ranked GROUP BY chat + """) + return [{"chat": r[0], "unread": int(r[1]), "oldest": r[2], "latest": r[3], + "aliases": [cid for cid, canon in aliases.items() if canon == r[0] and cid != r[0]]} + for r in rows if valid_chat_id(r[0])] diff --git a/bridge/mac/test_read_sync.py b/bridge/mac/test_read_sync.py new file mode 100644 index 0000000..a69bd81 --- /dev/null +++ b/bridge/mac/test_read_sync.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Read/unread metadata and menu verification, using synthetic data only.""" +import contextlib +import importlib.machinery +import importlib.util +import io +import sqlite3 +import sys +import unittest +from pathlib import Path +from unittest.mock import patch, Mock + +sys.path.insert(0, str(Path(__file__).parent)) +from read_state import read_state + + +def load_tool(name): + loader = importlib.machinery.SourceFileLoader('test_' + name.replace('-', '_'), str(Path(__file__).with_name(name))) + spec = importlib.util.spec_from_loader(loader.name, loader) + mod = importlib.util.module_from_spec(spec) + loader.exec_module(mod) + return mod + + +class Metadata(unittest.TestCase): + def setUp(self): + self.con = sqlite3.connect(':memory:') + self.con.executescript(''' + CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, + last_read_message_timestamp INTEGER, is_filtered INTEGER, group_id TEXT, style INTEGER); + CREATE TABLE message (ROWID INTEGER PRIMARY KEY, date INTEGER, is_read INTEGER, + is_from_me INTEGER, item_type INTEGER, associated_message_type INTEGER); + CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER); + CREATE TABLE chat_recoverable_message_join (message_id INTEGER); + INSERT INTO chat VALUES (1,'+15551234567',999999,0,'dm',45); + ''') + + def add(self, ident, date, read=0, chat=1, outbound=0, item=0, tapback=0): + self.con.execute('INSERT INTO message VALUES (?,?,?,?,?,?)', (ident, date, read, outbound, item, tapback)) + self.con.execute('INSERT INTO chat_message_join VALUES (?,?)', (chat, ident)) + + def count(self): + return read_state(self.con)[0]['unread'] + + def test_manual_unread_below_read_cursor_is_detected(self): + self.add(1, 100, read=0) + self.assertEqual(self.count(), 1) + + def test_read_tip_hides_old_ghosts_and_same_time_uses_rowid(self): + self.add(1, 100) + self.add(2, 100, read=1) + self.assertEqual(self.count(), 0) + self.add(3, 100) + self.assertEqual(self.count(), 1) + + def test_announcements_deleted_and_outbound_do_not_badge(self): + self.add(1, 100, read=1) + self.add(2, 101, read=1, tapback=2000) + self.add(3, 102, item=1) + self.add(4, 103, outbound=1) + self.add(5, 104) + self.con.execute('INSERT INTO chat_recoverable_message_join VALUES (5)') + self.assertEqual(self.count(), 0) + self.assertEqual(read_state(self.con, include_deleted=True)[0]['unread'], 1) + + def test_unread_reaction_after_read_message_matches_messages(self): + self.add(1, 100, read=1) + self.add(2, 101, outbound=1) + self.add(3, 102, tapback=2004) + self.assertEqual(self.count(), 1) + self.con.execute('UPDATE message SET is_read=1 WHERE ROWID=3') + self.assertEqual(self.count(), 0) + + def test_read_reaction_is_a_barrier_against_older_unread_ghosts(self): + self.add(1, 100) + self.add(2, 101, read=1, tapback=2000) + self.assertEqual(self.count(), 0) + + def test_duplicate_service_join_is_counted_once(self): + self.add(1, 100) + self.con.execute('INSERT INTO chat VALUES (2,"+15551234567",999999,0,"dm",45)') + self.con.execute('INSERT INTO chat_message_join VALUES (2,1)') + self.assertEqual(self.count(), 1) + + def test_merged_email_phone_share_read_barrier_and_aliases(self): + self.add(1, 100) + self.con.execute('INSERT INTO chat VALUES (2,"you@example.com",999999,0,"dm",45)') + self.add(2, 200, read=1, chat=2) + rows = read_state(self.con) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['unread'], 0) + self.assertEqual(rows[0]['chat'], 'you@example.com') + self.assertIn('+15551234567', rows[0]['aliases']) + + def test_filters_and_minimal_older_schema(self): + self.add(1, 100) + self.assertEqual(read_state(self.con, 'c.is_filtered NOT IN (0)'), []) + self.con.execute('DROP TABLE chat_recoverable_message_join') + self.assertEqual(self.count(), 1) + + def test_malformed_old_identifier_does_not_poison_complete_snapshot(self): + self.add(1, 100) + self.con.execute('INSERT INTO chat VALUES (2,?,999999,0,"broken",45)', ('x' * 513,)) + self.add(2, 200, chat=2) + rows = read_state(self.con) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['chat'], '+15551234567') + + def test_verifier_uses_identical_metadata_definition(self): + helper = load_tool('imsg-read') + self.add(1, 100) + with patch.object(helper.sqlite3, 'connect') as connect: + # Do not let the helper close the fixture connection. + wrapper = Mock(wraps=self.con) + wrapper.close = Mock() + connect.return_value = wrapper + self.assertEqual(helper.unread_on_mac('+15551234567'), 1) + + +class ConversationWindow(unittest.TestCase): + def test_unread_beyond_sidebar_limit_is_included_without_adding_read_backlog(self): + imsg = load_tool('imsg') + rows = [{"chat_identifier": x} for x in ['recent', 'read-old', 'unread-old', 'alias']] + states = [{"chat": 'unread-old', "unread": 1, "aliases": ['alias']}] + self.assertEqual(imsg.conversation_window(rows, 1, states), [rows[0], rows[2], rows[3]]) + + +class ReactionReadBoundary(unittest.TestCase): + def test_folded_reactions_and_removals_keep_their_activity_timestamp(self): + imsg = load_tool('imsg') + target = {"ROWID": 1, "guid": "target", "assoc_type": 0, + "date_read": 0, "is_from_me": 1, "orig_guid": None, + "style_id": None, "is_audio_message": 0, + "balloon_bundle_id": None, "payload_data": None} + for kind in [2004, 3004]: + con = Mock() + con.execute.return_value = [{"ag": "p:0/target", "t": kind, + "date": 810000000000000000, "is_from_me": 0, "handle": "+15551234567"}] + with patch.object(imsg, '_has_col', return_value=False), patch.object(imsg, '_chunked_in', return_value=[]): + kept, extras = imsg.enrich(con, [target], with_names=False) + self.assertEqual(kept, [target]) + self.assertEqual(extras[1]['activity_ts'], imsg.fmt_ts(810000000000000000)) + self.assertEqual(len(extras[1].get('tapbacks', [])), 1 if kind == 2004 else 0) + + +class Actions(unittest.TestCase): + def setUp(self): + self.tool = load_tool('imsg-read') + + def test_unverified_action_is_failure_not_success(self): + for before, after in ((None, None), (1, None)): + with self.assertRaises(SystemExit) as ctx, contextlib.redirect_stderr(io.StringIO()): + self.tool.report(before, after, True, 'Mark as Read') + self.assertEqual(ctx.exception.code, 75) + + def test_partial_mark_all_is_not_acknowledged(self): + with self.assertRaises(SystemExit) as ctx, contextlib.redirect_stderr(io.StringIO()): + self.tool.report(3, 2, True, 'Mark All as Read') + self.assertEqual(ctx.exception.code, 75) + + def main(self, flag, before, after): + stack = contextlib.ExitStack() + self.addCleanup(stack.close) + mocks = {} + for name, result in [('ensure_messages', ''), ('accessibility', ''), ('unread_on_mac', before), + ('frontmost', 'Previous'), ('select_chat', ''), ('wake_messages', 'Previous'), + ('click', (False, '')), ('settle', after), ('restore_front', None)]: + mocks[name] = stack.enter_context(patch.object(self.tool, name, return_value=result)) + stack.enter_context(patch.object(sys, 'argv', ['imsg-read', flag, '+15551234567'])) + stack.enter_context(contextlib.redirect_stdout(io.StringIO())) + self.tool.main() + return mocks + + def test_read_wakes_menu_and_accepts_selection_read_with_absent_item(self): + mocks = self.main('--chat', 1, 0) + mocks['wake_messages'].assert_called_once() + mocks['settle'].assert_called_once_with(1, '+15551234567') + mocks['restore_front'].assert_called_once_with('Previous') + + def test_settle_waits_until_all_are_read_not_just_one(self): + with patch.object(self.tool, 'unread_on_mac', side_effect=[2, 1, 0]), patch.object(self.tool.time, 'sleep'): + self.assertEqual(self.tool.settle(3), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/collector.test.ts b/collector.test.ts index a8b703c..522cd32 100644 --- a/collector.test.ts +++ b/collector.test.ts @@ -1048,10 +1048,30 @@ describe("phone-synced read state (imsg ≥1.9.0 `read`)", () => { expect(counts["+15551234567"]).toBe(1); }); - test("locally-read messages stay read regardless of Apple state", () => { + test("Apple-unread still badges below the global floor (iPhone badge)", () => { const counts = unreadCounts([m({ read: false })], "2026-08-31T23:00:00Z", {}); + expect(counts["+15551234567"]).toBe(1); + }); + + test("opening a thread still hides its Apple-unread rows", () => { + const counts = unreadCounts( + [m({ read: false })], + "2026-08-31T00:00:00Z", + { "+15551234567": "2026-08-31T12:00:00Z" }, + ); expect(counts["+15551234567"]).toBeUndefined(); }); + + test("a read tip hides older is_read=0 ghosts; an unread tip badges", () => { + expect(unreadCounts([ + m({ ts: "2026-08-31T10:00:00Z", read: false }), + m({ ts: "2026-08-31T12:00:00Z", read: true }), + ], "2026-08-31T00:00:00Z", {})).toEqual({}); + expect(unreadCounts([ + m({ ts: "2026-08-31T10:00:00Z", read: true }), + m({ ts: "2026-08-31T12:00:00Z", read: false }), + ], "2026-08-31T23:00:00Z", {})["+15551234567"]).toBe(1); + }); }); describe("future-dated messages must not poison read marks", () => { diff --git a/collector.ts b/collector.ts index 8b5e85e..60dc99b 100644 --- a/collector.ts +++ b/collector.ts @@ -22,8 +22,10 @@ import { openSync, writeSync, fsyncSync, closeSync } from "node:fs"; import { homedir } from "node:os"; import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { parseReadSnapshot, parseReadIntents, queueReadIntent, retryReadIntent, reconcileReadIntents, + type ReadSnapshot, type ReadIntents } from "./read-sync"; const HOME = process.env.HOME ?? homedir(); @@ -58,6 +60,8 @@ export interface ImsgMessage { // lexical order IS chronological order — which every ledger, watermark and // sort in this file relies on. Local time is a DISPLAY concern (thread.ts). ts: string; + /** Newest reaction activity folded onto this message by the rich bridge. */ + activity_ts?: string; from_me: boolean; /** True only when the Mac bridge knows this is the configured self chat. */ self_chat?: boolean; @@ -152,6 +156,7 @@ export interface GroupInfo { } export interface BlipState { + pendingReads?: ReadIntents; watermark: string; readMark: string; /** Exact unread ledger, independent of the bounded preview window. */ @@ -185,6 +190,7 @@ export interface BlipOutput { error: string; ts: string; unread: number; + unreadCounts?: Record; threads: Thread[]; toast: Toast[]; /** Your own recent sends that died (chat.db error≠0); toasted once each. */ @@ -265,6 +271,7 @@ export function loadState(path = STATE_PATH): BlipState { const ledgerComplete = Object.entries(unreadCounts) .every(([chat, count]) => count === 0 || unreadOldest[chat]); return { + ...(s.pendingReads ? { pendingReads: parseReadIntents(s.pendingReads) } : {}), watermark, // Pre-two-mark state files have no readMark. Inheriting the watermark is // the safe migration: it reports zero unread rather than a fake backlog. @@ -671,12 +678,13 @@ export function buildThreads( const sorted = [...list].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0)); const sent = sorted.filter((m) => m.scheduled !== true); const last = sent.length ? sent[sent.length - 1]! : sorted[sorted.length - 1]!; - const mark = readMarks[chat] && readMarks[chat]! > watermark ? readMarks[chat]! : watermark; const unread = unreadCounts ? unreadCounts[chat] ?? 0 - : watermark - ? sorted.filter((m) => !m.from_me && m.ts > mark && m.read !== true).length - : 0; + : sorted.filter((m) => { + const mark = unreadMark(chat, m, watermark, readMarks); + if (!mark && m.read !== false) return false; + return isUnread(m, mark); + }).length; threads.push({ chat, guid: isGroupChat(chat) ? groups[chat]?.guid ?? "" : "", @@ -1104,6 +1112,14 @@ export function pushReadArgs( return ["--chat", chat]; } +/** A chat id Messages can open for a read push: a DM, or a groupid deep link. */ +export function canPushChat(chat: string): boolean { + const id = String(chat || ""); + return /^\+?[0-9]{3,15}$/.test(id) + || /^[^@\s\x00-\x1f\x7f]+@[^@\s\x00-\x1f\x7f]+$/.test(id) + || /^(?:chat[0-9]{1,40}|[a-fA-F0-9]{32})$/.test(id); +} + /** * Tell the Mac, without making the caller wait. The click costs a second or * two of AppleScript; a poll must not stall behind it, and a failure must @@ -1457,8 +1473,8 @@ export function keepUnverifiedUnread( * from the iPhone via Messages in iCloud) — reading on the PHONE clears * Blip within a poll. * - Local side: newer than the effective read mark — reading in BLIP - * clears it here (the phone keeps its own badge; write-back is - * impossible). + * clears it here. The collector separately queues Mac read actions when + * the configured policy enables them. * A row without the `read` field (older imsg) falls back to local-only. */ /** @@ -1500,6 +1516,7 @@ export function toUtcStamp(ts: string): string { /** Every stamp a message carries, normalised to the wire format. */ export function normalizeMsgStamps(m: T): T { if (typeof m.ts === "string" && !m.ts.includes("T")) m = { ...m, ts: toUtcStamp(m.ts) }; + if (typeof m.activity_ts === "string") m = { ...m, activity_ts: toUtcStamp(m.activity_ts) }; if (typeof m.read_at === "string" && !m.read_at.includes("T")) m = { ...m, read_at: toUtcStamp(m.read_at) }; return m; } @@ -1509,6 +1526,71 @@ export function isUnread(m: ImsgMessage, mark: string): boolean { return !m.from_me && m.ts > mark && m.read !== true && m.tapback !== true; } +/** The stamp a thread is measured against. A stale per-thread read mark may + * not sit below the global floor. */ +export function effectiveMark( + chat: string, + readMark: string, + readMarks: Record, +): string { + return readMarks[chat] && readMarks[chat]! > readMark ? readMarks[chat]! : readMark; +} + +/** Mark used for ONE message. Apple-unread (read===false) is the iPhone + * badge: the global floor must not hide it (first-run high-water would + * wipe every already-unread thread). Opening that conversation still + * hides it via readMarks[chat]. Missing `read` keeps the old local-only + * floor so a backlog cannot dump on install. */ +export function unreadMark( + chat: string, + m: ImsgMessage, + readMark: string, + readMarks: Record, +): string { + if (m.read === false) return readMarks[chat] || ""; + return effectiveMark(chat, readMark, readMarks); +} + +export function lastInboundTs(msgs: ImsgMessage[], chat: string): string { + let ts = ""; + for (const m of msgs) { + if (chatKey(m) !== chat) continue; + if (m.from_me || m.tapback === true) continue; + if (m.ts > ts) ts = m.ts; + } + return ts; +} + +/** + * Unread is the TRAILING inbound that Messages still flags is_read=0. + * Ghost is_read=0 rows under a read tip do not badge. Mark as Unread sets + * is_read=0 on the latest inbound even when last_read_message_timestamp is + * already past it — that is the blue-dot state. Opening the thread here + * still hides it via readMarks[chat]. + */ +export function trailingUnread( + inbound: ImsgMessage[], + readMark: string, + localMark: string, +): { count: number; oldest: string } { + const list = [...inbound].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0)); + const newest = list[list.length - 1]; + if (!newest) return { count: 0, oldest: "" }; + if (newest.read === true) return { count: 0, oldest: "" }; + if (newest.read !== false && !readMark && !localMark) return { count: 0, oldest: "" }; + let count = 0; + let oldest = ""; + for (let i = list.length - 1; i >= 0; i--) { + const m = list[i]!; + if (m.read === true) break; + if (localMark && m.ts <= localMark) break; + if (m.read !== false && readMark && m.ts <= readMark) break; + count++; + oldest = m.ts; + } + return { count, oldest }; +} + export function unreadCounts( msgs: ImsgMessage[], readMark: string, @@ -1516,13 +1598,18 @@ export function unreadCounts( selfChats: string[] = [], ): Record { const counts: Record = {}; - if (!readMark) return counts; const self = new Set(selfChats); + const byChat = new Map(); for (const m of msgs) { const chat = chatKey(m); - if (self.has(chat)) continue; // your own echoes never badge (phone parity) - const mark = readMarks[chat] && readMarks[chat]! > readMark ? readMarks[chat]! : readMark; - if (isUnread(m, mark)) counts[chat] = (counts[chat] ?? 0) + 1; + if (self.has(chat) || m.from_me || m.tapback === true) continue; + const list = byChat.get(chat); + if (list) list.push(m); + else byChat.set(chat, [m]); + } + for (const [chat, list] of byChat) { + const { count } = trailingUnread(list, readMark, readMarks[chat] || ""); + if (count) counts[chat] = count; } return counts; } @@ -1534,13 +1621,18 @@ export function unreadOldest( selfChats: string[] = [], ): Record { const oldest: Record = {}; - if (!readMark) return oldest; const self = new Set(selfChats); + const byChat = new Map(); for (const m of msgs) { const chat = chatKey(m); - if (self.has(chat)) continue; - const mark = readMarks[chat] && readMarks[chat]! > readMark ? readMarks[chat]! : readMark; - if (isUnread(m, mark) && (!oldest[chat] || m.ts < oldest[chat]!)) oldest[chat] = m.ts; + if (self.has(chat) || m.from_me || m.tapback === true) continue; + const list = byChat.get(chat); + if (list) list.push(m); + else byChat.set(chat, [m]); + } + for (const [chat, list] of byChat) { + const hit = trailingUnread(list, readMark, readMarks[chat] || ""); + if (hit.count && hit.oldest) oldest[chat] = hit.oldest; } return oldest; } @@ -1857,12 +1949,57 @@ export function fetchGroups(runner = spawnSync): Record | nul // ---------------------------------------------------------------- main +export function runReadAction(args: string[], runner = spawnSync): { ok: boolean; error: string } { + try { + const r = runner(shimPath("imsg-read"), args, { encoding: "utf8", timeout: 180000 }); + const ok = r.status === 0; + try { + const log = pushReadLogPath(); + mkdirSync(dirname(log), { recursive: true, mode: 0o700 }); + appendFileSync(log, `${new Date().toISOString()} ${args.join(" ")} exit=${r.status ?? "timeout"} ${String(r.stderr || r.stdout).trim().replace(/[\r\n]/g, " ").slice(0, 240)}\n`, { mode: 0o600 }); + const lines = readFileSync(log, "utf8"); + if (lines.length > 65536) writeFileSync(log, lines.split("\n").slice(-101).join("\n"), { mode: 0o600 }); + } catch { /* read intent remains durable even if diagnostics cannot be saved */ } + // Status only: no message contents. Keep errors visible until retry succeeds. + return { ok, error: ok ? "" : String(r.stderr || r.stdout || "Mac read action failed").trim().slice(-240) }; + } catch { return { ok: false, error: "Mac read action could not start" }; } +} + +export function fetchReadSnapshot(runner = spawnSync): ReadSnapshot | null { + try { + const r = runner(shimPath("imsg"), ["--json", "read-state"], { + encoding: "utf8", timeout: 20000, maxBuffer: 16 * 1024 * 1024, + }); + return r.status === 0 ? parseReadSnapshot(JSON.parse(String(r.stdout))) : null; + } catch { return null; } +} + export function collect(deep: boolean, markRead = false, readChat = "", seenTs = ""): BlipOutput { // When this POLL ran — the output's own metadata, not a message stamp. // Distinct from nowTs() below, which is the clock message stamps are // measured against and therefore has to be in the wire format. const producedAt = new Date().toISOString(); const state = loadState(); + const readPush = pushReadPolicy(); + let pendingReads = readPush === "off" ? {} : { ...state.pendingReads }; + // Persist explicit intent before any network call. An offline click survives + // the collector exiting and is retried after reconnection. + if (readPush !== "off") { + // Stamp the click. A later --all retry is not safe (it would mark new + // arrivals read on the phone), so this intent is attempted once and only + // hides rows that already existed. + if (markRead) pendingReads = queueReadIntent(pendingReads, "*", false, nowTs()); + if (readPush === "thread" && (state.unreadCounts[readChat] ?? 0) > 0 && readChat && canPushChat(readChat)) pendingReads = queueReadIntent(pendingReads, readChat, false, seenTs); + } + if (JSON.stringify(pendingReads) !== JSON.stringify(state.pendingReads ?? {}) && + !saveState({ ...state, pendingReads })) { + throw new Error("state write failed; read action was not queued"); + } + const remoteReads = fetchReadSnapshot(); + if (remoteReads) pendingReads = reconcileReadIntents(pendingReads, remoteReads); + // Per-chat catch-up. A single global oldest-unread cutoff made every poll + // walk the whole history; windowCutoff stays at the watermark and stale + // chats are fetched on their own below. const cutoff = windowCutoff(state); const fetched = fetchMessagesAfter(cutoff, deep ? DEEP_WINDOW : POLL_WINDOW); @@ -1925,10 +2062,14 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = const readMark = markRead ? (highest <= now ? highest : now) : state.readMark; // Opening one conversation clears only that thread's dot — marked with // THAT chat's newest ts, never the global max. - const readMarks = { ...state.readMarks }; + // Under thread sync, confirmed Mac state replaces historical local marks. + // Failed actions are represented by pendingReads instead. + const readMarks = remoteReads && readPush === "thread" + ? Object.fromEntries(Object.entries(state.readMarks).filter(([chat]) => isGroupChat(chat))) + : { ...state.readMarks }; if (markRead) { for (const [c, ts] of Object.entries(chatMax)) { - if (ts > readMark) readMarks[c] = ts; + readMarks[c] = ts > now ? ts : now; } } if (readChat) { @@ -1957,8 +2098,8 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = const deduped = dedupeSelfEcho(caughtUp, selfChats); const muted = mutedChats(deduped, mute); const msgs = dropMuted(deduped, muted); - let exactCounts = unreadCounts(msgs, state.readMark, state.readMarks, selfChats); - let exactOldest = unreadOldest(msgs, state.readMark, state.readMarks, selfChats); + let exactCounts = unreadCounts(msgs, state.readMark, readMarks, selfChats); + let exactOldest = unreadOldest(msgs, state.readMark, readMarks, selfChats); // Deep runs complete the sidebar from `imsg chats`. A capped catch-up // needs that list too: otherwise a chat hide_spam dropped in SQL is // restored from the ledger (Astra B#3) and pins every later poll. @@ -1980,26 +2121,16 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = exactCounts = kept.counts; exactOldest = kept.oldest; } - // Did THIS run actually turn unread into read for the chat being viewed? - // Every poll while a thread is open carries its readChat (that is what keeps - // a message landing in the open conversation from flashing unread), so - // without this the Mac was told again on every single poll. Each of those - // costs an ssh AND pulls Messages to the front, because aiming its menu at - // one conversation means opening it — five pushes in one minute, four of - // them "nothing unread" (measured, 2026-09-08). - let clearedUnread = false; if (markRead) { exactCounts = {}; exactOldest = {}; - } else if (readChat) { - clearedUnread = (exactCounts[readChat] ?? 0) > 0; - delete exactCounts[readChat]; - delete exactOldest[readChat]; } + // Per-chat counts were already calculated through --seen above. Do not + // zero them wholesale: that would hide arrivals newer than the rendered view. // Prune per-thread marks the global mark has overtaken (Codex finding #13): // they no longer affect any count and would otherwise accumulate forever. for (const [chat, ts] of Object.entries(readMarks)) { - if (ts <= readMark) delete readMarks[chat]; + if (!remoteReads && ts <= readMark) delete readMarks[chat]; } const preferImessage = preferImessagePolicy(); const windowThreads = buildThreads(msgs, readMark, readMarks, groups, exactCounts, preferImessage); @@ -2010,21 +2141,69 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = // bridge names the older ones as aliases of the live row, and the map is // cached so shallow polls fold identically (a conversation must never // blink into two between a deep run and the next poll). - const chatAliases = chats ? aliasesFromChats(chats) : state.chatAliases; + const chatAliases = { ...(chats ? aliasesFromChats(chats) : state.chatAliases) }; + for (const [chat, row] of Object.entries(remoteReads ?? {})) { + delete chatAliases[chat]; + for (const alias of row.aliases ?? []) chatAliases[alias] = chat; + } const pins = chats ? pinsFromChats(chats) : state.pins; if (readChat) { for (const a of aliasesOf(chatAliases, readChat)) { - if (readSeen > readMark) readMarks[a] = readSeen; // same prune rule as the canonical - // An alias carrying the unread counts too: reading the canonical row - // cleared it, so the Mac is worth telling. - if ((exactCounts[a] ?? 0) > 0) clearedUnread = true; - delete exactCounts[a]; - delete exactOldest[a]; + if (remoteReads || readSeen > readMark) readMarks[a] = readSeen; // same prune rule as the canonical + const remaining = unreadCounts(msgs, state.readMark, readMarks, selfChats); + const oldestRemaining = unreadOldest(msgs, state.readMark, readMarks, selfChats); + if (remaining[a]) { + exactCounts[a] = remaining[a]!; + exactOldest[a] = oldestRemaining[a]!; + } else { + delete exactCounts[a]; + delete exactOldest[a]; + } + } + } + if (remoteReads) { + exactCounts = {}; + exactOldest = {}; + for (const [chat, row] of Object.entries(remoteReads)) { + if (selfChats.includes(chat) || muted.includes(chat) || mute.includes(chat)) continue; + const canonical = chatAliases[chat] ?? chat; + if (selfChats.includes(canonical) || muted.includes(canonical) || mute.includes(canonical)) continue; + if (row.unread > 0) { + exactCounts[chat] = row.unread; + exactOldest[chat] = row.oldest; + } + const reading = canonical === readChat; + // Compare to the visible snapshot BEFORE advancing any read mark. An + // unseen inbound must keep its dot and must not be cleared on the Mac. + if (reading && readPush === "thread" && row.unread > 0 && seenTs && row.latest <= seenTs && canPushChat(readChat)) { + pendingReads = queueReadIntent(pendingReads, readChat, false, seenTs); + } + const intent = pendingReads[canonical] ?? pendingReads["*"]; + const localMark = readMarks[chat] || readMarks[canonical] || ""; + if (markRead || (reading && seenTs && row.latest <= seenTs) || + // Outside per-thread sync, the global mark is what mark-all and the + // first-run adoption write. It covers chats the recent window never + // fetched, including when the Mac push failed or push_read is off. + // A later inbound has a newer latest and stays unread. Thread sync + // does not use it: mark-unread has to sit below that floor. + (readPush !== "thread" && row.latest <= readMark) || + (readPush !== "thread" && localMark && row.latest <= localMark) || + (intent && !intent.unread && intent.seen !== "" && row.latest <= intent.seen)) { + delete exactCounts[chat]; delete exactOldest[chat]; + } + } + } else if (readPush === "thread" && readChat && canPushChat(readChat)) { + // Older/offline bridges still retain the transition until it is verified. + const before = unreadCounts(msgs, state.readMark, state.readMarks, selfChats); + if (seenTs && lastInboundTs(msgs, readChat) > seenTs) { + delete pendingReads[readChat]; + } else if ((before[readChat] ?? 0) > 0 || (state.unreadCounts[readChat] ?? 0) > 0) { + pendingReads = queueReadIntent(pendingReads, readChat, false, seenTs); } } exactCounts = foldChatRecord(exactCounts, chatAliases, (a, b) => a + b); exactOldest = foldChatRecord(exactOldest, chatAliases, (a, b) => (a < b ? a : b)); - const foldedWindow = foldThreadAliases(windowThreads, chatAliases); + const foldedWindow = foldThreadAliases(windowThreads, chatAliases).map(t => ({ ...t, unread: exactCounts[t.chat] ?? 0 })); const threads = chats ? mergeChats(foldedWindow, chats, groups, exactCounts) : applyPins(foldedWindow, pins); // The conversation on screen covers its alias rows, exactly as the read // marks above do: a message arriving under a retired chat row is the same @@ -2034,14 +2213,17 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = const failures = selectFailures(fetched.msgs, state.toasted, now); const links = selectIncomingLinks(msgs, state.watermark, state.toasted, selfChats); const codes = selectCodes(msgs, state.watermark, state.toasted, selfChats); - const unread = Object.values(exactCounts).reduce((n, count) => n + count, 0); + // Header/badge count conversations with a blue dot, not inbound rows. + // Two chats with two unread messages each used to say "4 UNREAD". + const unread = Object.values(exactCounts).filter((count) => count > 0).length; // Both marks advance only on a good fetch, so an outage cannot silently // swallow the messages that arrived during it. // A row dated tomorrow (tz skew) must not become the mark everything is // measured against — nothing would badge or toast until "tomorrow" (Astra B#4). const highestNow = highest <= now ? highest : now; - const persisted = saveState({ + const nextState: BlipState = { + pendingReads, watermark: highestNow, // First ever run: adopt the current high-water rather than reporting the // whole preview window as unread the moment the plugin is installed. @@ -2056,22 +2238,46 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = pins, toasted: [...state.toasted, ...toast.map((t) => t.key), ...failures.map((f) => f.key), ...links.map((l) => l.key), ...codes.map((c) => c.key)], - }); - - // Only after the local state is committed: if the write failed the user - // will be asked to read these again, and the Mac must agree. - const readPush = pushReadPolicy(); - if (persisted) pushRead(pushReadArgs(readPush, { markRead, readChat, clearedUnread })); - + }; + const persisted = saveState(nextState); + + // Actions are synchronous and serialized with the collector, never detached. + // Process one due intent per run so a failed chat cannot starve the rest. + let syncError = ""; + if (persisted && readPush !== "off") { + const candidates = pendingReads["*"] ? [["*", pendingReads["*"]] as const] : Object.entries(pendingReads); + const due = candidates.filter(([, r]) => r.retryAt <= Date.now()) + .sort((a, b) => a[1].retryAt - b[1].retryAt)[0]; + if (due) { + const [chat, intent] = due; + const result = chat === "*" ? runReadAction(["--all"]) + : runReadAction([intent.unread ? "--unread" : "--chat", chat, + ...(!intent.unread && intent.seen ? ["--through", intent.seen] : [])]); + if (result.ok) delete pendingReads[chat]; + else if (chat === "*" || intent.attempts >= 4) { + // --all cannot be bounded, so it is never retried. A per-chat push + // stops after five tries so Messages is not brought forward forever. + delete pendingReads[chat]; + syncError = result.error || "read sync stopped"; + } else { + pendingReads[chat] = retryReadIntent(intent, Date.now(), result.error); + syncError = result.error; + } + if (!saveState({ ...nextState, pendingReads })) syncError = "read acknowledgement could not be saved; will verify again"; + } + } + const remaining = Object.keys(pendingReads).length; const warning = !persisted ? "state write failed; notifications paused" - : ""; + : syncError || (remaining ? (Object.values(pendingReads).find(r => r.error)?.error || `Read sync pending (${remaining}); retrying automatically`) : + !remoteReads ? "Read sync snapshot unavailable; update/check the Mac bridge" : ""); return { ok: true, online: true, error: warning, ts: producedAt, unread, + unreadCounts: remoteReads ? exactCounts : undefined, threads, // Never emit notifications that could not be committed to the dedupe ring. toast: persisted ? toast : [], diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 50c3e59..6ce355f 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -12,7 +12,7 @@ inventory of what lands on disk. | `~/.ssh/blip_ed25519` | Blip's dedicated ssh key — confined on the Mac to the bridge tools | — | | `~/.config/blip/allowlist.json` | handles allowed to raise desktop toasts | message text | | `~/.config/blip/mutelist.json` | handles and phrases you typed, whose conversations Blip hides entirely | message text Blip received | -| `~/.local/state/blip/state.json` (0600, atomic) | poll watermark, read marks, per-chat unread counts and oldest-unread timestamps, self-chat ids, group names/members, opaque SHA-256 toast keys | **message bodies — ever** | +| `~/.local/state/blip/state.json` (0600, atomic) | poll watermark, read marks, per-chat unread counts and oldest-unread timestamps, pending read actions and retry status, self-chat ids, group names/members, opaque SHA-256 toast keys | **message bodies — ever** | | `~/.local/state/blip/audit-cache.json` (0600, parent 0700) | bounded contact-scan summaries: handles, candidate names, source labels, counts, opaque card tokens, and freshness fingerprints | message bodies, photos, full contact cards | | `~/.local/state/blip/window.json` | whether the app window was open, its size | anything else | | `~/.cache/blip/att/` (0700, files 0600, 500 MB LRU, no expiry) | attachments you viewed, plus images ≤ 5 MB and link-preview thumbnails in any conversation you *open* (they render inline, so they are fetched when the thread is). HEIC arrives converted to JPEG. File names carry the Mac's attachment row id and a sanitized name whose extension follows the MIME type | attachments in conversations you never opened | @@ -44,8 +44,8 @@ toasts show a sender name and a preview through your notification daemon, gated by the allowlist — and your notification daemon may keep its own history. Blip itself keeps one log, `~/.local/state/blip/push-read.log` (timestamps, the `imsg-read` arguments — `--all`, or a handle when `push_read=thread` — exit codes and its status line; never message content), and nothing else; the shell's stderr (journald) sees recipients and exit codes, never bodies (`imsg-send` prints a byte count). -Message bodies do pass through process arguments on both machines, visible -to other processes running as you. +Message bodies do not pass through bridge process arguments; they use stdin. +The read-state snapshot contains identifiers, counts, timestamps and aliases only. Threat model and the audit findings behind these notes: [SECURITY.md](SECURITY.md). @@ -80,7 +80,9 @@ Messages' pinning preferences read-only, and drive Messages.app through AppleScript. Contact creation writes a new card through the native address book API. Contact drafts stay in memory and cross bounded stdin, never argv or persistent Linux storage. Messages.app itself keeps your -conversation history exactly as it always has. +conversation history exactly as it always has. Read actions use +`~/.blip/messages-ui.lock` to serialize access to Messages' +selected conversation; the lock contains no message data. Contact review reads bounded names, account labels, matching-field counts, and opaque card tokens from Mac Contacts. Raw database identifiers stay on the diff --git a/read-sync-qml.ts b/read-sync-qml.ts new file mode 100644 index 0000000..5ede813 --- /dev/null +++ b/read-sync-qml.ts @@ -0,0 +1,3 @@ +// Export only renderer operations: Qt's JS parser does not accept object spread. +// Rebuild: bun build read-sync-qml.ts --target browser --format esm --outfile ReadSync.mjs +export { enqueueRefresh } from "./read-sync"; diff --git a/read-sync.test.ts b/read-sync.test.ts new file mode 100644 index 0000000..a94a455 --- /dev/null +++ b/read-sync.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { parseReadSnapshot, parseReadIntents, queueReadIntent, reconcileReadIntents, retryReadIntent } from "./read-sync"; + +const chat = "+15551234567"; +const old = "2026-09-01T10:00:00Z"; +const recent = "2026-09-01T11:00:00Z"; +const snapshot = (unread = 1, latest = old) => ({ version: 1, chats: [{ chat, unread, oldest: unread ? old : "", latest }] }); + +describe("read sync state machine", () => { + test("a group id is a durable read intent", () => { + const group = "ce5a593a78af408282d61461ade89135"; + const pending = queueReadIntent({}, group, false, old); + expect(parseReadIntents(pending)[group]!.unread).toBe(false); + expect(parseReadIntents({ "not a chat": pending[group]! })).toEqual({}); + }); + test("partial or malformed snapshots cannot clear the ledger", () => { + expect(parseReadSnapshot({ chats: [] })).toBeNull(); + expect(parseReadSnapshot({ version: 1, chats: [{ chat, unread: -1 }] })).toBeNull(); + expect(parseReadSnapshot({ version: 1, chats: [] })).toEqual({}); + expect(parseReadIntents({ [chat]: { unread: false, seen: old, attempts: -1, retryAt: 0 } })).toEqual({}); + }); + test("failed action survives identical polls and uses bounded backoff", () => { + const pending = queueReadIntent({}, chat, false, old); + pending[chat] = retryReadIntent(pending[chat]!, 1000); + expect(queueReadIntent(pending, chat, false, old)).toBe(pending); + expect(pending[chat]!.retryAt).toBe(3000); + expect(retryReadIntent({ ...pending[chat]!, attempts: 100 }, 1000).retryAt).toBe(61000); + expect(reconcileReadIntents(pending, parseReadSnapshot(snapshot())!)).toEqual(pending); + expect(reconcileReadIntents(pending, parseReadSnapshot(snapshot(0))!)).toEqual({}); + }); + test("later intent wins, and a stale read cannot clear a new inbound", () => { + const pending = queueReadIntent(queueReadIntent({}, chat, false, old), chat, true); + expect(pending[chat]!.unread).toBe(true); + expect(reconcileReadIntents(queueReadIntent({}, chat, false, old), parseReadSnapshot(snapshot(1, recent))!)).toEqual({}); + }); + test("a mark-all does not cover a message that arrives after the click", () => { + const pending = queueReadIntent({}, "*", false, old); + const later = { version: 1, chats: [ + { chat, unread: 0, oldest: "", latest: old }, + { chat: "+15550001111", unread: 1, oldest: recent, latest: recent }, + ]}; + expect(reconcileReadIntents(pending, parseReadSnapshot(later)!)).toEqual({}); + const still = { version: 1, chats: [ + { chat, unread: 1, oldest: old, latest: old }, + { chat: "+15550001111", unread: 1, oldest: recent, latest: recent }, + ]}; + expect(reconcileReadIntents(pending, parseReadSnapshot(still)!)["*"]).toBeTruthy(); + }); + test("mark-all followed by unread keeps the unread until mark-all lands", () => { + const pending = queueReadIntent(queueReadIntent({}, "*", false), chat, true); + expect(reconcileReadIntents(pending, parseReadSnapshot(snapshot(1))!)).toEqual(pending); + expect(reconcileReadIntents(pending, parseReadSnapshot(snapshot(0))!)).toEqual({ [chat]: pending[chat]! }); + }); +}); + +/** A complete collector process with an isolated HOME and synthetic bridge. + * Nothing here calls the real Mac or reads the user's state. */ +function fixture(unread = 1) { + const home = mkdtempSync(join(tmpdir(), "blip-sync-")); + mkdirSync(join(home, "bin")); + mkdirSync(join(home, ".config/blip"), { recursive: true }); + mkdirSync(join(home, ".local/state/blip"), { recursive: true }); + writeFileSync(join(home, ".config/blip/bridge.conf"), "push_read=thread\n"); + const put = (file: string, v: unknown) => writeFileSync(join(home, file), JSON.stringify(v)); + put("remote.json", snapshot(unread)); + put("control.json", {}); + const bridge = `#!/usr/bin/env python3 +import json,os,sys +home=os.environ['HOME']; args=sys.argv[1:] +ctl=json.load(open(home+'/control.json')) +if ctl.get('offline'): sys.exit(69) +if 'read-state' in args: + if ctl.get('snapshot_fail'): sys.exit(64) + print(open(home+'/remote.json').read()) +elif 'groups' in args: print('[]') +elif 'chats' in args: print(json.dumps([{'id':'${chat}','last':'${old}','last_text':'fixture','last_from_me':False}])) +else: + print(json.dumps([{'id':1,'chat':'${chat}','handle':'${chat}','name':None,'service':'iMessage','ts':ctl.get('latest','${old}'),'from_me':False,'text':'fixture','read':False}])) +`; + writeFileSync(join(home, "bin/imsg"), bridge, { mode: 0o755 }); + writeFileSync(join(home, "bin/imsg-read"), `#!/usr/bin/env python3 +import json,os,sys +home=os.environ['HOME']; args=sys.argv[1:] +with open(home+'/actions.jsonl','a') as f: f.write(json.dumps(args)+'\\n') +ctl=json.load(open(home+'/control.json')) +if ctl.get('fail'): + print('imsg-read: Accessibility is not granted',file=sys.stderr); sys.exit(77) +data=json.load(open(home+'/remote.json')) +for r in data['chats']: + if '--all' in args or r['chat']==args[1]: + r['unread']=1 if '--unread' in args else 0 + r['oldest']=r['latest'] if r['unread'] else '' +with open(home+'/remote.json','w') as f: json.dump(data,f) +print('verified') +`, { mode: 0o755 }); + const statePath = join(home, ".local/state/blip/state.json"); + const state = () => JSON.parse(readFileSync(statePath, "utf8")); + const run = (...args: string[]) => { + const result = spawnSync(process.execPath, [join(import.meta.dir, "collector.ts"), "--deep", ...args], { + encoding: "utf8", env: { ...process.env, HOME: home }, timeout: 10000, + }); + expect(result.status).toBe(0); + const out = JSON.parse(result.stdout); + expect(out.error).not.toContain("TypeError"); + return out; + }; + const actions = () => { try { return readFileSync(join(home, "actions.jsonl"), "utf8").trim().split("\n").filter(Boolean).map(l => JSON.parse(l)); } catch { return []; } }; + return { home, put, run, state, actions, save: (s: unknown) => writeFileSync(statePath, JSON.stringify(s)) }; +} + +describe("collector read sync lifecycle", () => { + test("opening an unread chat pushes once, even if a local mark already hid it", () => { + const f = fixture(); f.run(); + f.save({ ...f.state(), readMarks: { [chat]: recent }, unreadCounts: {} }); + expect(f.run("--read", chat, "--seen", old).unread).toBe(0); + expect(f.actions()).toEqual([["--chat", chat, "--through", old]]); + f.run("--read", chat, "--seen", old); + expect(f.actions()).toHaveLength(1); + expect(f.state().pendingReads).toEqual({}); + }); + test("failure stays queued, survives restart, then clears on verified retry", () => { + const f = fixture(); f.put("control.json", { fail: true }); + expect(f.run("--read", chat, "--seen", old).error).toContain("Accessibility"); + expect(f.state().pendingReads[chat].attempts).toBe(1); + f.run("--read", chat, "--seen", old); + expect(f.actions()).toHaveLength(1); + f.put("control.json", {}); + const st = f.state(); st.pendingReads[chat].retryAt = 0; f.save(st); + f.run(); + expect(f.actions()).toHaveLength(2); + expect(f.state().pendingReads).toEqual({}); + }); + test("new inbound beyond --seen remains unread and cancels an old retry", () => { + const f = fixture(); f.run(); + f.save({ ...f.state(), pendingReads: queueReadIntent({}, chat, false, old) }); + f.put("remote.json", snapshot(1, recent)); + expect(f.run("--read", chat, "--seen", old).unread).toBe(1); + expect(f.actions()).toHaveLength(0); + }); + test("old chats outside previews are present in the complete unread metadata", () => { + const f = fixture(0); + const data = snapshot(0); + data.chats.push({ chat: "+15557654321", unread: 1, oldest: "2020-01-01T00:00:00Z", latest: "2020-01-01T00:00:00Z" }); + f.put("remote.json", data); + const out = f.run(); + expect(out.unreadCounts["+15557654321"]).toBe(1); + expect(out.unread).toBe(1); + }); +}); + + +describe("policy and ordering integration", () => { + test("gesture-only policy keeps a local read of an old message across polls", () => { + const f = fixture(); + writeFileSync(join(f.home, ".config/blip/bridge.conf"), "push_read=all\n"); + f.run(); + expect(f.run("--read", chat, "--seen", old).unread).toBe(0); + expect(f.run().unread).toBe(0); + expect(f.actions()).toHaveLength(0); + }); + test("fallback snapshot failure does not consume a newer unseen inbound", () => { + const f = fixture(); f.run(); + f.put("control.json", { snapshot_fail: true, latest: recent }); + expect(f.run("--read", chat, "--seen", old).unread).toBe(1); + expect(f.actions()).toHaveLength(0); + }); +}); + + +test("a due retry cannot jump ahead of a fresh action for another chat", () => { + const other = "+15557654321"; + const f = fixture(); f.run(); + const remote = snapshot(1); + remote.chats.push({ chat: other, unread: 1, oldest: old, latest: old }); + f.put("remote.json", remote); + f.save({ ...f.state(), pendingReads: { + [chat]: { unread: false, seen: old, attempts: 2, retryAt: Date.now() - 1 }, + [other]: { unread: false, seen: old, attempts: 0, retryAt: 0 }, + } }); + f.run(); + expect(f.actions()[0]).toEqual(["--chat", other, "--through", old]); +}); diff --git a/read-sync.ts b/read-sync.ts new file mode 100644 index 0000000..6a5e4cb --- /dev/null +++ b/read-sync.ts @@ -0,0 +1,89 @@ +// QML adapter: see read-sync-qml.ts for the renderer build command. +/** Read synchronization metadata. No message bodies or contact names. */ +export interface RemoteRead { unread: number; oldest: string; latest: string; aliases?: string[] } +export type ReadSnapshot = Record; +export interface ReadIntent { unread: boolean; seen: string; attempts: number; retryAt: number; error?: string } +export type ReadIntents = Record; + +export function parseReadSnapshot(value: unknown): ReadSnapshot | null { + const v = value as { version?: number; chats?: unknown[] }; + if (!v || v.version !== 1 || !Array.isArray(v.chats)) return null; + const out: ReadSnapshot = Object.create(null); + const stamp = (s: unknown) => typeof s === "string" && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(s); + for (const raw of v.chats) { + const r = raw as RemoteRead & { chat: string }; + if (!r || typeof r.chat !== "string" || !r.chat || r.chat.length > 512 || + /[\x00-\x1f\x7f]/.test(r.chat) || !Number.isSafeInteger(r.unread) || r.unread < 0 || + !stamp(r.latest) || (r.unread > 0 ? !stamp(r.oldest) : r.oldest !== "")) return null; + if (r.aliases !== undefined && (!Array.isArray(r.aliases) || r.aliases.some(a => typeof a !== "string" || !a || a.length > 512))) return null; + out[r.chat] = { unread: r.unread, oldest: r.oldest, latest: r.latest, aliases: r.aliases ?? [] }; + } + return out; +} + +export function parseReadIntents(value: unknown): ReadIntents { + const out: ReadIntents = Object.create(null); + if (!value || typeof value !== "object" || Array.isArray(value)) return out; + for (const [chat, raw] of Object.entries(value)) { + const r = raw as ReadIntent; + if (!r || typeof r.unread !== "boolean" || typeof r.seen !== "string" || + !Number.isSafeInteger(r.attempts) || r.attempts < 0 || + !Number.isFinite(r.retryAt) || r.retryAt < 0) continue; + if (chat !== "*" && !/^\+?[0-9]{3,15}$/.test(chat) && !/^[^@\s]+@[^@\s]+$/.test(chat) + && !/^(?:chat[0-9]{1,40}|[a-fA-F0-9]{32})$/.test(chat)) continue; + out[chat] = { unread: r.unread, seen: r.seen, attempts: r.attempts, retryAt: r.retryAt, + ...(typeof r.error === "string" ? { error: r.error.slice(0, 240).replace(/[\x00-\x1f\x7f]/g, " ") } : {}) }; + } + return out; +} + +/** Latest user intent wins; identical polls never reset a failed action's backoff. */ +export function queueReadIntent(pending: ReadIntents, chat: string, unread: boolean, seen = ""): ReadIntents { + const old = pending[chat]; + if (old && old.unread === unread && (unread || old.seen >= seen)) return pending; + const next = chat === "*" ? {} : { ...pending }; + next[chat] = { unread, seen, attempts: 0, retryAt: 0 }; + return next; +} + +export function retryReadIntent(intent: ReadIntent, now: number, error = ""): ReadIntent { + return { ...intent, ...(error ? { error: error.slice(0, 240).replace(/[\x00-\x1f\x7f]/g, " ") } : {}), attempts: intent.attempts + 1, + retryAt: now + Math.min(60000, 2000 * 2 ** Math.min(intent.attempts, 5)) }; +} + +/** Only a read snapshot may acknowledge a queued operation. A later inbound + * is not covered by --seen and must never be marked read by an old retry. */ +export function reconcileReadIntents(pending: ReadIntents, snapshot: ReadSnapshot): ReadIntents { + const next = { ...pending }; + for (const [chat, intent] of Object.entries(next)) { + if (chat === "*") { + const covered = Object.values(snapshot).filter(r => !intent.seen || r.latest <= intent.seen); + if (covered.every(r => r.unread === 0)) delete next[chat]; + continue; + } + if (next["*"]) continue; // a preceding mark-all must land before this gesture + const remote = snapshot[chat] ?? Object.values(snapshot).find(r => r.aliases?.includes(chat)); + if (remote && ((remote.unread > 0) === intent.unread || + (!intent.unread && intent.seen && remote.latest > intent.seen))) delete next[chat]; + } + return next; +} + +/** Coalescing cannot cross an explicit gesture: a read queued after + * mark-all must stay after it instead of merging into an earlier read. */ +export function enqueueRefresh(queue: T[], req: T): T[] { + const q = queue.slice(); + const barrier = (r: T) => r.markRead; + if (!barrier(req)) { + for (let i = q.length - 1; i >= 0; i--) { + if (barrier(q[i]!)) break; + if (q[i]!.readChat === req.readChat) { + q[i] = Object.assign({}, req, { deep: q[i]!.deep || req.deep, seen: req.seen > q[i]!.seen ? req.seen : q[i]!.seen }); + return q; + } + } + } + q.push(req); + return q; +} diff --git a/scripts/test-read-ui.py b/scripts/test-read-ui.py index 402f96f..95221e5 100644 --- a/scripts/test-read-ui.py +++ b/scripts/test-read-ui.py @@ -28,6 +28,7 @@ def function(name): id: root property var threads: [] property var localReads: ({}) + property var localUnreads: ({}) property int unread: 0 property int generation: 0 @BINDING@ @@ -49,18 +50,19 @@ def function(name): root.generation++ root.threads = [] root.localReads = ({}) + root.localUnreads = ({}) root.unread = 0 } function test_expired_read_then_identical_poll() { var original = rows() root.poll(original) root.markThreadRead("A", original[0].last_ts) - compare(root.unread, 2) + compare(root.unread, 1) // conversations, not the remaining message rows // A slow/failed collector write can outlive optimistic suppression. root.localReads = {A: {ts: original[0].last_ts, at: Date.now() - 61000}} root.poll(root.applyLocalReads(original)) compare(root.threads[0].unread, 1) - compare(root.unread, 3) + compare(root.unread, 2) } function test_mark_all_then_identical_poll() { var original = rows() @@ -71,18 +73,18 @@ def function(name): root.poll(root.applyLocalReads(original)) compare(root.threads[0].unread, 1) compare(root.threads[1].unread, 2) - compare(root.unread, 3) + compare(root.unread, 2) } function test_read_suppresses_inflight_poll_until_new_activity() { var original = rows() root.poll(original) root.markThreadRead("A", original[0].last_ts) root.poll(root.applyLocalReads(original)) - compare(root.unread, 2) + compare(root.unread, 1) compare(root.threads[0].unread, 0) original[0].last_ts = "2026-09-01T11:00:00Z" root.poll(root.applyLocalReads(original)) - compare(root.unread, 3) + compare(root.unread, 2) compare(root.threads[0].unread, 1) } function test_identical_poll_does_not_replace_model() { @@ -94,7 +96,7 @@ def function(name): } } '''.replace("@BINDING@", binding).replace("@FUNCTIONS@", "\n".join( - function(name) for name in ("noteLocalRead", "applyLocalReads", + function(name) for name in ("unreadChatCount", "noteLocalRead", "applyLocalReads", "markThreadRead", "markAllRead") )).replace("@UPDATE@", source[start:end]) diff --git a/thread.test.ts b/thread.test.ts index 21b2f27..3591fb3 100644 --- a/thread.test.ts +++ b/thread.test.ts @@ -731,3 +731,14 @@ describe("Send Later bubbles", () => { expect(b!.scheduled).toBeUndefined(); }); }); + +test("reading a displayed reaction covers its activity without moving the original bubble", () => { + const original = "2026-09-01T10:00:00Z"; + const reaction = "2026-09-01T11:00:00Z"; + const bubbles = decorate([{ ts: original, activity_ts: reaction, from_me: true, + chat: "+15551234567", handle: "+15551234567", name: null, service: "iMessage", text: "fixture", + tapbacks: [{ emoji: "❤️", from_me: false, by: null }] }], "2026-09-01"); + expect(bubbles[0]!.ts).toBe(original); + expect(bubbles[0]!.seen_ts).toBe(reaction); + expect(bubbles[0]!.tapbacks).toHaveLength(1); +}); diff --git a/thread.ts b/thread.ts index 2b5433c..d14843c 100644 --- a/thread.ts +++ b/thread.ts @@ -55,6 +55,8 @@ export function linkHost(url: string): string { export const GROUP_GAP_MINUTES = 15; export interface Bubble { + /** Read boundary includes reactions without moving the bubble's display time. */ + seen_ts?: string; ts: string; from_me: boolean; name: string; @@ -303,6 +305,7 @@ export function decorate(msgs: ImsgMessage[], today: string, formats = DEFAULT_F out.push({ ts: m.ts, + seen_ts: m.activity_ts && m.activity_ts > m.ts ? m.activity_ts : m.ts, from_me: m.from_me, name: m.name ?? m.handle ?? "", // U+FFFC is the object-replacement placeholder Messages leaves where an diff --git a/ui.test.ts b/ui.test.ts index 70a36c0..c22a51a 100644 --- a/ui.test.ts +++ b/ui.test.ts @@ -718,6 +718,12 @@ test("no source file carries a merge conflict marker", () => { // pinned conversation's only unread signal — one unread in a pinned group // showed badge 1 and "nothing new in the app". The tile carries the same blue // dot the list rows do. +test("the header unread count is conversations with a blue dot, not inbound rows", () => { + expect(widget).toContain("function unreadChatCount"); + expect(widget).toContain("if ((Number(list[i].unread) || 0) > 0) n++"); + expect(widget).toContain("root.unread = root.unreadChatCount(root.threads)"); +}); + test("a pinned tile shows the unread dot", () => { expect(panel).toContain("id: pinnedUnreadDot"); const dot = panel.slice(panel.indexOf("id: pinnedUnreadDot"), panel.indexOf("id: pinnedUnreadDot") + 700); @@ -1089,3 +1095,17 @@ describe("a multi-part send is pinned to the thread it started in", () => { expect(pump).not.toContain("root.active.service"); }); }); + +// Poll no-op detection must follow optimistic reads, unreads and deletes too. +// A cache assigned only by polls lets a stale result change the count without +// updating the actual list, producing a badge with more entries than its tooltip. +test("poll snapshots compare against the current rendered list", () => { + expect(widget).toContain("readonly property string threadsJson: JSON.stringify(threads)"); + expect(widget).not.toContain("root.threadsJson ="); + expect(widget).toContain("root.unread = root.unreadChatCount(root.threads)"); +}); + +test("rendered reactions advance the visible read boundary", () => { + expect(panel).toContain('String(list[k].seen_ts || list[k].ts || "")'); + expect(panel).toContain("if (list[k].pending === true || list[k].scheduled === true) continue"); +}); From c8e52c9ecc1bb60f4264220f1a54d81182dfc8d2 Mon Sep 17 00:00:00 2001 From: Damon Janis Date: Sat, 26 Sep 2026 21:07:27 -0500 Subject: [PATCH 2/4] Add Mark as Unread and Mark as Read to the conversation menu Right-click a conversation and choose Mark as Unread. If it already has a dot, the item is Mark as Read. You can also press U in the list. The menu now holds these read-state items. Review contact is still on the conversation header's button. Mark as Unread works on direct messages only. Messages' menu item addresses a handle, and a group has no handle to select, so groups stay local. For a DM, the gesture queues an unread intent in the same pendingReads queue that reads use. imsg-read --unread selects the chat, clicks Mark as Unread, and exits 0 only when read_state counts the chat unread. It never opens a chat that is already unread, because opening it would mark it read. Locally, unreadSince is a per-chat override that can sit below the global read mark, so only that chat badges again. localUnreads keeps the dot through a poll that is already in flight. With push_read=off the override stays. Otherwise a complete snapshot retires it once the Mac agrees. Mark as Read from the menu is an explicit read (--act read) and queues even under the default push_read=all. Refresh coalescing treats both gestures as barriers, so read, unread, read keeps its order. --- BarWidget.qml | 50 ++++++++++-- BlipView.qml | 42 +++++++++- CHANGELOG.md | 9 ++- CLAUDE.md | 13 +-- README.md | 3 +- ReadSync.mjs | 2 +- bridge/mac/imsg-read | 51 ++++++++++-- bridge/mac/test_read_sync.py | 15 +++- collector.test.ts | 71 ++++++++++++++++- collector.ts | 148 ++++++++++++++++++++++++++++------- docs/PRIVACY.md | 4 +- icons/read.svg | 3 + icons/unread.svg | 3 + read-sync.test.ts | 89 ++++++++++++++++++++- read-sync.ts | 10 +-- scripts/demo/DemoShell.qml | 3 +- ui.test.ts | 15 +++- 17 files changed, 458 insertions(+), 73 deletions(-) create mode 100644 icons/read.svg create mode 100644 icons/unread.svg diff --git a/BarWidget.qml b/BarWidget.qml index e51bece..99cdefb 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -357,9 +357,11 @@ 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) } @@ -370,7 +372,9 @@ BarWidget { 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) } @@ -388,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) @@ -412,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) { @@ -430,10 +453,23 @@ BarWidget { noteLocalRead(c, lastTs) threads = list unread = unreadChatCount(list) - refresh(true, false, c, lastTs) + 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) { diff --git a/BlipView.qml b/BlipView.qml index 174b24a..d24f232 100644 --- a/BlipView.qml +++ b/BlipView.qml @@ -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() @@ -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) { @@ -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 } @@ -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 @@ -4050,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0db90..7c21ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,17 @@ 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 is saved before the - push and retried with backoff. `read_state.py` is the shared +- **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`. diff --git a/CLAUDE.md b/CLAUDE.md index e2b216e..5c6a599 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,7 +202,8 @@ what it is handed. Keep it that way. Apple's read cursor, old ghost rows and merged phone/email DMs. Under `push_read=thread`, confirmed Mac state replaces historical local read marks for DMs and for groups (Messages' groupid link). A pending intent - alone overrides it. + alone overrides it; a later Mac read clears a previously marked-unread Blip + dot. Mark-unread stays on direct messages. Compare remote unread against the rendered `--seen` BEFORE updating local marks. A stale read must not clear a newer inbound; recheck with `--through` on the Mac. Newer explicit gestures replace old same-chat pending intents. @@ -484,14 +485,8 @@ to whatever has focus otherwise. (SIP on, the same grant `imsg-read` already holds) is reported there. Nothing from that issue is in-tree; do not treat the menu-item note above as a claim that outbound tapbacks ship. -- Selecting a GROUP on the Mac from Linux. `imessage://` addresses a handle; - a group's `chat` id has no URL form. So per-conversation read-push - is DMs only; groups clear through `--all`. NOT closed for good: Bluetooth MAP - marks a message read by setting `Read` on an `org.bluez.obex.Message1` - object, which needs no Mac and no URL, and might cover groups. Untested here - — dex's Realtek radio will not stay up long enough to pair (ROADMAP, Prior - art, 2026-09-09). Do not lift BlueFerry's code to try it: it is GPL and Blip - is MIT. +- Mark-unread on a GROUP. That menu item addresses a + handle. A group read already uses Messages' `imessage:open?groupid=` link. ## Things that ARE possible (verified) diff --git a/README.md b/README.md index cc54ada..5b79e21 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Linux side. If the Mac is asleep, the widget dims and says so. - contact photo (from the Mac's Contacts), a group's own photo from Messages when it has one, or initials · name, preview, time - **pinned conversations mirrored from Messages on the Mac**, kept in the same order at the top (read-only) - **blue dot** that stays until you open *that* conversation — iMessage semantics, not "I glanced at the list" +- **mark as unread** — right-click a row, or `U` in the list; DMs also click Messages' own menu on the Mac so the phone can follow - groups titled the way Messages.app titles them: the group's name, else its members @@ -791,7 +792,7 @@ read actions local. Per-thread actions briefly select the conversation in Messages and restore the previous app's focus. With `push_read=thread`, a group read reaches the Mac through Messages' groupid link, not only mark-all. -Read actions are saved before contacting the Mac, verified against +Read/unread actions are saved before contacting the Mac, verified against Messages' database, and retried after temporary failures or reconnects. Permission errors stay visible in Blip until resolved. A newer inbound beyond the visible `--seen` timestamp cancels an old read retry so it stays unread. diff --git a/ReadSync.mjs b/ReadSync.mjs index 583cf71..ee7fe08 100644 --- a/ReadSync.mjs +++ b/ReadSync.mjs @@ -1,7 +1,7 @@ // read-sync.ts function enqueueRefresh(queue, req) { const q = queue.slice(); - const barrier = (r) => r.markRead; + const barrier = (r) => r.markRead || r.unreadChat || r.act; if (!barrier(req)) { for (let i = q.length - 1;i >= 0; i--) { if (barrier(q[i])) diff --git a/bridge/mac/imsg-read b/bridge/mac/imsg-read index df206cf..9f59baf 100755 --- a/bridge/mac/imsg-read +++ b/bridge/mac/imsg-read @@ -15,6 +15,7 @@ Going through Messages means the change syncs the way any read does. imsg-read --all mark every conversation read imsg-read --chat mark ONE conversation read (DM or group) + imsg-read --unread mark ONE conversation unread (DMs only) imsg-read --status report whether this Mac can do it at all --all leaves the Mac alone: no window is raised, no selection changes, and @@ -185,6 +186,34 @@ def settle(before, handle=None, tries=8, pause=0.4): return after +def settle_unread(handle=None, tries=8, pause=0.4): + """Wait until Messages counts this chat (or any chat) as unread.""" + after = unread_on_mac(handle) + for _ in range(tries): + if after is None or after > 0: + return after + time.sleep(pause) + after = unread_on_mac(handle) + return after + + +def report_unread(before, after, ok): + """Exit 0 when the chat is unread on the Mac, including already-unread.""" + if before is None or after is None: + sys.stderr.write("imsg-read: cannot verify read state in chat.db\n") + sys.exit(75) + if after > (before or 0): + print(f"marked ({before} -> {after} unread)") + return + if after > 0: + print("already unread") + return + why = ("Mark as Unread stayed disabled even after waking Messages, so the click could not land" + if not ok else "Mark as Unread was clicked but the chat is still read") + sys.stderr.write(f"imsg-read: {why}\n") + sys.exit(75) + + def report(before, after, ok, what): """Acknowledge only a verified zero unread count in the requested scope. @@ -378,16 +407,19 @@ def main() -> None: report(before, settle(before) if ok else before, ok, "Mark All as Read") return - if argv[0] == "--chat": + if argv[0] in ("--chat", "--unread"): if len(argv) < 2: - sys.stderr.write("imsg-read: --chat needs a handle\n") + sys.stderr.write("imsg-read: action needs a handle\n") sys.exit(64) handle = argv[1] + want_unread = argv[0] == "--unread" before = unread_on_mac(handle) - if before == 0: - print("nothing unread") # and no reason to pull Messages forward + # Idempotency avoids opening an already-unread chat (which reads it) + # and prevents polling from repeatedly stealing focus. + if before is not None and ((before > 0) == want_unread): + print("already unread" if want_unread else "nothing unread") return - if "--through" in argv: + if not want_unread and "--through" in argv: i = argv.index("--through") if i + 1 >= len(argv) or newer_than_seen(handle, argv[i + 1]): sys.stderr.write("imsg-read: newer inbound arrived; leaving it unread\n") @@ -398,8 +430,7 @@ def main() -> None: if why: sys.stderr.write(f"imsg-read: {why}\n") sys.exit(64) - # Never toggle: a conversation already read offers Mark as Unread. - item = "Mark as Read" + item = "Mark as Unread" if want_unread else "Mark as Read" ok, err = click(item) # A disabled menu is dormant while Messages is in the background. # Wake it and click once more; a real error still fails immediately. @@ -411,7 +442,11 @@ def main() -> None: sys.exit(1) # Opening can itself read the chat. Always check the database, # even when the menu was absent, disabled, or the count unknown. - report(before, settle(before, handle), ok, item) + after = settle_unread(handle) if want_unread else settle(before, handle) + if want_unread: + report_unread(before, after, ok) + else: + report(before, after, ok, item) finally: restore_front(prev) return diff --git a/bridge/mac/test_read_sync.py b/bridge/mac/test_read_sync.py index a69bd81..1f3f916 100644 --- a/bridge/mac/test_read_sync.py +++ b/bridge/mac/test_read_sync.py @@ -152,6 +152,9 @@ def test_unverified_action_is_failure_not_success(self): with self.assertRaises(SystemExit) as ctx, contextlib.redirect_stderr(io.StringIO()): self.tool.report(before, after, True, 'Mark as Read') self.assertEqual(ctx.exception.code, 75) + with self.assertRaises(SystemExit) as ctx, contextlib.redirect_stderr(io.StringIO()): + self.tool.report_unread(before, after, True) + self.assertEqual(ctx.exception.code, 75) def test_partial_mark_all_is_not_acknowledged(self): with self.assertRaises(SystemExit) as ctx, contextlib.redirect_stderr(io.StringIO()): @@ -164,19 +167,29 @@ def main(self, flag, before, after): mocks = {} for name, result in [('ensure_messages', ''), ('accessibility', ''), ('unread_on_mac', before), ('frontmost', 'Previous'), ('select_chat', ''), ('wake_messages', 'Previous'), - ('click', (False, '')), ('settle', after), ('restore_front', None)]: + ('click', (False, '')), ('settle', after), ('settle_unread', after), ('restore_front', None)]: mocks[name] = stack.enter_context(patch.object(self.tool, name, return_value=result)) stack.enter_context(patch.object(sys, 'argv', ['imsg-read', flag, '+15551234567'])) stack.enter_context(contextlib.redirect_stdout(io.StringIO())) self.tool.main() return mocks + def test_already_unread_is_not_opened_and_read_accidentally(self): + mocks = self.main('--unread', 1, 1) + mocks['select_chat'].assert_not_called() + mocks['click'].assert_not_called() + def test_read_wakes_menu_and_accepts_selection_read_with_absent_item(self): mocks = self.main('--chat', 1, 0) mocks['wake_messages'].assert_called_once() mocks['settle'].assert_called_once_with(1, '+15551234567') mocks['restore_front'].assert_called_once_with('Previous') + def test_unread_waits_for_verified_state_and_restores_focus(self): + mocks = self.main('--unread', 0, 1) + mocks['settle_unread'].assert_called_once_with('+15551234567') + mocks['restore_front'].assert_called_once_with('Previous') + def test_settle_waits_until_all_are_read_not_just_one(self): with patch.object(self.tool, 'unread_on_mac', side_effect=[2, 1, 0]), patch.object(self.tool.time, 'sleep'): self.assertEqual(self.tool.settle(3), 0) diff --git a/collector.test.ts b/collector.test.ts index 522cd32..6632158 100644 --- a/collector.test.ts +++ b/collector.test.ts @@ -48,6 +48,11 @@ import { CATCHUP_CHAT_ROWS, unreadCounts, unreadOldest, + stampBefore, + lastInboundTs, + effectiveMark, + pushUnreadArgs, + markUnreadOnMac, type ImsgMessage, type ChatInfo, } from "./collector"; @@ -171,6 +176,22 @@ describe("buildThreads", () => { expect(threads[0]!.unread).toBe(0); }); + test("unreadSince may sit below the global mark and resurrects that chat only", () => { + const threads = buildThreads( + [ + msg({ chat: "A", handle: "A", ts: "2026-08-30T09:30:00Z", read: true }), + msg({ chat: "B", handle: "B", ts: "2026-08-30T09:30:00Z", read: true }), + ], + "2026-08-30T10:00:00Z", + {}, + {}, + undefined, + { A: "2026-08-30T09:00:00Z" }, + ); + const byChat = Object.fromEntries(threads.map((t) => [t.chat, t.unread])); + expect(byChat).toEqual({ A: 1, B: 0 }); + }); + test("a null chat falls back to the handle — never the string \"null\"", () => { // Seen live: a spam SMS came back with chat:null and rendered as "null". const threads = buildThreads( @@ -709,6 +730,7 @@ describe("state and allowlist I/O", () => { unreadInitialized: true, selfChats: ["SELF"], readMarks: { A: "2026-08-30T09:30:00Z" }, + unreadSince: {}, groups: {}, chatAliases: { OLD: "A" }, pins: { A: 0 }, @@ -720,7 +742,7 @@ describe("state and allowlist I/O", () => { test("a missing state file yields a safe empty watermark", () => { expect(loadState(join(tmp(), "nope.json"))).toEqual({ watermark: "", readMark: "", unreadCounts: {}, unreadOldest: {}, unreadInitialized: false, - selfChats: [], readMarks: {}, groups: {}, chatAliases: {}, pins: {}, toasted: [], + selfChats: [], readMarks: {}, unreadSince: {}, groups: {}, chatAliases: {}, pins: {}, toasted: [], }); }); @@ -729,7 +751,7 @@ describe("state and allowlist I/O", () => { writeFileSync(p, "{ this is not json"); expect(loadState(p)).toEqual({ watermark: "", readMark: "", unreadCounts: {}, unreadOldest: {}, unreadInitialized: false, - selfChats: [], readMarks: {}, groups: {}, chatAliases: {}, pins: {}, toasted: [], + selfChats: [], readMarks: {}, unreadSince: {}, groups: {}, chatAliases: {}, pins: {}, toasted: [], }); }); @@ -737,7 +759,7 @@ describe("state and allowlist I/O", () => { const p = join(tmp(), "big.json"); saveState({ watermark: "x", readMark: "x", unreadCounts: {}, unreadOldest: {}, unreadInitialized: true, - selfChats: [], readMarks: {}, groups: {}, toasted: Array.from({ length: 500 }, (_, i) => `k${i}`), + selfChats: [], readMarks: {}, unreadSince: {}, groups: {}, toasted: Array.from({ length: 500 }, (_, i) => `k${i}`), }, p); expect(loadState(p).toasted).toHaveLength(200); }); @@ -757,7 +779,7 @@ describe("state and allowlist I/O", () => { writeFileSync(blocker, "x"); expect(saveState({ watermark: "x", readMark: "x", unreadCounts: {}, unreadOldest: {}, unreadInitialized: true, - selfChats: [], readMarks: {}, groups: {}, toasted: [], + selfChats: [], readMarks: {}, unreadSince: {}, groups: {}, toasted: [], }, join(blocker, "state.json"))).toBe(false); }); @@ -1553,6 +1575,47 @@ describe("pushing read state back to the Mac", () => { expect(pushReadArgs("thread", { markRead: false, readChat: "ce5a593a78af408282d61461ade89135" })).toEqual(["--chat", "ce5a593a78af408282d61461ade89135"]); expect(pushReadArgs("thread", { markRead: false, readChat: "" })).toBeNull(); }); + + test("mark-unread pushes --unread for DMs only", () => { + expect(pushUnreadArgs("+15550100011")).toEqual(["--unread", "+15550100011"]); + expect(pushUnreadArgs("them@example.com")).toEqual(["--unread", "them@example.com"]); + expect(pushUnreadArgs("ce5a593a78af408282d61461ade89135")).toBeNull(); + expect(pushUnreadArgs("chat900000000000000001")).toBeNull(); + expect(pushUnreadArgs("")).toBeNull(); + }); + + test("mark-unread waits for the Mac and surfaces a failure", () => { + const ok = () => ({ status: 0, stdout: "marked\n", stderr: "" }) as never; + expect(markUnreadOnMac("+15550100011", "/home/u", ok)).toEqual({ ok: true, error: "" }); + const no = () => ({ status: 77, stdout: "", stderr: "imsg-read: Accessibility is not granted.\n" }) as never; + expect(markUnreadOnMac("+15550100011", "/home/u", no).ok).toBe(false); + expect(markUnreadOnMac("ce5a593a78af408282d61461ade89135").error).toContain("groups"); + }); +}); + +describe("mark as unread", () => { + test("stampBefore is one second earlier", () => { + expect(stampBefore("2026-08-30T10:00:00Z")).toBe("2026-08-30T09:59:59Z"); + }); + + test("lastInboundTs skips outbound and tapbacks", () => { + expect(lastInboundTs([ + msg({ ts: "2026-08-30T09:00:00Z" }), + msg({ ts: "2026-08-30T11:00:00Z", from_me: true }), + msg({ ts: "2026-08-30T12:00:00Z", tapback: true }), + ], "+15551234567")).toBe("2026-08-30T09:00:00Z"); + }); + + test("effectiveMark prefers unreadSince even below the global floor", () => { + expect(effectiveMark("A", "2026-08-30T10:00:00Z", { A: "2026-08-30T11:00:00Z" }, { A: "2026-08-30T09:00:00Z" })) + .toBe("2026-08-30T09:00:00Z"); + }); + + test("unreadCounts honours unreadSince even when Apple already marked the row read", () => { + const rows = [msg({ chat: "A", handle: "A", ts: "2026-08-30T09:30:00Z", read: true })]; + expect(unreadCounts(rows, "2026-08-30T10:00:00Z", {}, [], { A: "2026-08-30T09:00:00Z" })).toEqual({ A: 1 }); + expect(unreadCounts(rows, "2026-08-30T10:00:00Z", {}, [])).toEqual({}); + }); }); describe("which service a DM sends on (@lukejmorrison, PR #4)", () => { diff --git a/collector.ts b/collector.ts index 60dc99b..e08d04c 100644 --- a/collector.ts +++ b/collector.ts @@ -15,6 +15,7 @@ * bun collector.ts --deep # panel open: wider window, full thread list * bun collector.ts --mark-read # clear every dot (right-click) * bun collector.ts --read # clear one thread's dot (opened it) + * bun collector.ts --mark-unread # blue-dot that thread again */ import { bridgeFor, shimPath } from "./shim-path"; @@ -173,6 +174,10 @@ export interface BlipState { /** Per-thread read marks — iMessage semantics: the blue dot stays on a * thread until THAT conversation is opened, not until the list is viewed. */ readMarks: Record; + /** Per-thread marks that MAY sit below the global readMark. "Mark as + * Unread" cannot lower the global floor (that would resurrect every other + * thread); it stores an override here so only this chat badges again. */ + unreadSince: Record; /** Older chat rows of a re-keyed group → the row Messages writes to now. * Refreshed on --deep with the chat list; cached so a shallow poll folds * the same way and a conversation never blinks into two. */ @@ -289,6 +294,13 @@ export function loadState(path = STATE_PATH): BlipState { .map(([chat, ts]) => [chat, toUtcStamp(ts as string)]), ) : {}, + unreadSince: s.unreadSince && typeof s.unreadSince === "object" + ? Object.fromEntries( + Object.entries(s.unreadSince) + .filter(([, ts]) => typeof ts === "string" && ts !== "") + .map(([chat, ts]) => [chat, toUtcStamp(ts as string)]), + ) + : {}, // Every cached group goes through the same shape fetchGroups() enforces: // a participants OBJECT in state.json threw inside groupName() on every // poll until the next deep refresh (Astra B#6). @@ -306,7 +318,7 @@ export function loadState(path = STATE_PATH): BlipState { } catch { return { watermark: "", readMark: "", unreadCounts: {}, unreadOldest: {}, unreadInitialized: false, - selfChats: [], readMarks: {}, groups: {}, chatAliases: {}, pins: {}, toasted: [], + selfChats: [], readMarks: {}, unreadSince: {}, groups: {}, chatAliases: {}, pins: {}, toasted: [], }; } } @@ -661,6 +673,7 @@ export function buildThreads( readMarks: Record = {}, groups: Record = {}, unreadCounts?: Record, + unreadSince: Record = {}, preferImessage = false, ): Thread[] { const byHandle = new Map(); @@ -681,9 +694,9 @@ export function buildThreads( const unread = unreadCounts ? unreadCounts[chat] ?? 0 : sorted.filter((m) => { - const mark = unreadMark(chat, m, watermark, readMarks); - if (!mark && m.read !== false) return false; - return isUnread(m, mark); + const mark = unreadMark(chat, m, watermark, readMarks, unreadSince); + if (!mark && m.read !== false && !unreadSince[chat]) return false; + return isUnread(m, mark, Boolean(unreadSince[chat])); }).length; threads.push({ chat, @@ -1112,6 +1125,29 @@ export function pushReadArgs( return ["--chat", chat]; } +/** Explicit "mark as unread" always tells the Mac (unless push_read=off). + * Groups have no imessage:// form, same as per-thread mark-read. */ +export function pushUnreadArgs(chat: string): string[] | null { + const id = String(chat || ""); + if (!/^\+?[0-9]{3,15}$/.test(id) && !/^[^@\s]+@[^@\s]+$/.test(id)) return null; + return ["--unread", id]; +} + +/** Wait for Messages to mark the chat unread. Detached push hid Accessibility + * failures while Blip showed a local dot. */ +export function markUnreadOnMac(chat: string, home = HOME, runner = spawnSync): { ok: boolean; error: string } { + const args = pushUnreadArgs(chat); + if (!args) return { ok: false, error: "groups cannot be marked unread on the Mac" }; + try { + const r = runner(shimPath("imsg-read", home), args, { encoding: "utf8", timeout: 60000 }); + if ((r.status ?? 1) === 0) return { ok: true, error: "" }; + const msg = String(r.stderr || r.stdout || "unread push failed").trim().split("\n").pop() || "unread push failed"; + return { ok: false, error: msg.slice(0, 240) }; + } catch (e) { + return { ok: false, error: String(e).slice(0, 240) }; + } +} + /** A chat id Messages can open for a read push: a DM, or a groupid deep link. */ export function canPushChat(chat: string): boolean { const id = String(chat || ""); @@ -1521,18 +1557,20 @@ export function normalizeMsgStamps(m: T): T { return m; } -export function isUnread(m: ImsgMessage, mark: string): boolean { +export function isUnread(m: ImsgMessage, mark: string, ignoreAppleRead = false): boolean { // Tapbacks preview but never badge — matches every Apple client. - return !m.from_me && m.ts > mark && m.read !== true && m.tapback !== true; + return !m.from_me && m.ts > mark && m.tapback !== true && (ignoreAppleRead || m.read !== true); } -/** The stamp a thread is measured against. A stale per-thread read mark may - * not sit below the global floor. */ +/** The stamp a thread is measured against. unreadSince may sit below the + * global floor; a stale per-thread read mark may not. */ export function effectiveMark( chat: string, readMark: string, readMarks: Record, + unreadSince: Record = {}, ): string { + if (unreadSince[chat]) return unreadSince[chat]!; return readMarks[chat] && readMarks[chat]! > readMark ? readMarks[chat]! : readMark; } @@ -1546,9 +1584,17 @@ export function unreadMark( m: ImsgMessage, readMark: string, readMarks: Record, + unreadSince: Record = {}, ): string { + if (unreadSince[chat]) return unreadSince[chat]!; if (m.read === false) return readMarks[chat] || ""; - return effectiveMark(chat, readMark, readMarks); + return effectiveMark(chat, readMark, readMarks, unreadSince); +} + +/** One second before an ISO stamp, so that message itself counts as unread. */ +export function stampBefore(ts: string): string { + const ms = Date.parse(ts); + return Number.isNaN(ms) ? "" : nowTs(new Date(ms - 1000)); } export function lastInboundTs(msgs: ImsgMessage[], chat: string): string { @@ -1572,8 +1618,13 @@ export function trailingUnread( inbound: ImsgMessage[], readMark: string, localMark: string, + forceSince = "", ): { count: number; oldest: string } { const list = [...inbound].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0)); + if (forceSince) { + const hit = list.filter((m) => isUnread(m, forceSince, true)); + return { count: hit.length, oldest: hit[0]?.ts ?? "" }; + } const newest = list[list.length - 1]; if (!newest) return { count: 0, oldest: "" }; if (newest.read === true) return { count: 0, oldest: "" }; @@ -1596,6 +1647,7 @@ export function unreadCounts( readMark: string, readMarks: Record, selfChats: string[] = [], + unreadSince: Record = {}, ): Record { const counts: Record = {}; const self = new Set(selfChats); @@ -1608,7 +1660,7 @@ export function unreadCounts( else byChat.set(chat, [m]); } for (const [chat, list] of byChat) { - const { count } = trailingUnread(list, readMark, readMarks[chat] || ""); + const { count } = trailingUnread(list, readMark, readMarks[chat] || "", unreadSince[chat] || ""); if (count) counts[chat] = count; } return counts; @@ -1619,6 +1671,7 @@ export function unreadOldest( readMark: string, readMarks: Record, selfChats: string[] = [], + unreadSince: Record = {}, ): Record { const oldest: Record = {}; const self = new Set(selfChats); @@ -1631,7 +1684,7 @@ export function unreadOldest( else byChat.set(chat, [m]); } for (const [chat, list] of byChat) { - const hit = trailingUnread(list, readMark, readMarks[chat] || ""); + const hit = trailingUnread(list, readMark, readMarks[chat] || "", unreadSince[chat] || ""); if (hit.count && hit.oldest) oldest[chat] = hit.oldest; } return oldest; @@ -1974,7 +2027,7 @@ export function fetchReadSnapshot(runner = spawnSync): ReadSnapshot | null { } catch { return null; } } -export function collect(deep: boolean, markRead = false, readChat = "", seenTs = ""): BlipOutput { +export function collect(deep: boolean, markRead = false, readChat = "", seenTs = "", unreadChat = "", explicitRead = false): BlipOutput { // When this POLL ran — the output's own metadata, not a message stamp. // Distinct from nowTs() below, which is the clock message stamps are // measured against and therefore has to be in the wire format. @@ -1989,7 +2042,8 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = // arrivals read on the phone), so this intent is attempted once and only // hides rows that already existed. if (markRead) pendingReads = queueReadIntent(pendingReads, "*", false, nowTs()); - if (readPush === "thread" && (state.unreadCounts[readChat] ?? 0) > 0 && readChat && canPushChat(readChat)) pendingReads = queueReadIntent(pendingReads, readChat, false, seenTs); + if (unreadChat && pushUnreadArgs(unreadChat)) pendingReads = queueReadIntent(pendingReads, unreadChat, true); + if ((explicitRead || (readPush === "thread" && (state.unreadCounts[readChat] ?? 0) > 0)) && readChat && readChat !== unreadChat && canPushChat(readChat)) pendingReads = queueReadIntent(pendingReads, readChat, false, seenTs); } if (JSON.stringify(pendingReads) !== JSON.stringify(state.pendingReads ?? {}) && !saveState({ ...state, pendingReads })) { @@ -2063,16 +2117,26 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = // Opening one conversation clears only that thread's dot — marked with // THAT chat's newest ts, never the global max. // Under thread sync, confirmed Mac state replaces historical local marks. - // Failed actions are represented by pendingReads instead. + // Legacy permanent unread overrides are retired as soon as a full snapshot + // is available. Failed actions are represented by pendingReads instead. const readMarks = remoteReads && readPush === "thread" ? Object.fromEntries(Object.entries(state.readMarks).filter(([chat]) => isGroupChat(chat))) : { ...state.readMarks }; + const unreadSince = remoteReads && readPush !== "off" ? {} : { ...state.unreadSince }; if (markRead) { for (const [c, ts] of Object.entries(chatMax)) { readMarks[c] = ts > now ? ts : now; } + for (const c of Object.keys(unreadSince)) delete unreadSince[c]; + } + if (unreadChat) { + const inbound = lastInboundTs(fetched.msgs, unreadChat); + const pivot = inbound || chatMax[unreadChat] || now; + unreadSince[unreadChat] = stampBefore(pivot) || "1970-01-01T00:00:00Z"; + delete readMarks[unreadChat]; } - if (readChat) { + if (readChat && readChat !== unreadChat) { + delete unreadSince[readChat]; // Mark through what the user actually SAW (the panel passes the newest // bubble ts as --seen; it includes a future-dated row when the chat has // one on screen). A message arriving between the click and this run has @@ -2081,7 +2145,7 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = const own = chatMax[readChat] ?? ""; readMarks[readChat] = seenTs !== "" ? seenTs : (own > now ? own : now); } - const readSeen = readChat ? readMarks[readChat]! : ""; + const readSeen = readChat && readChat !== unreadChat ? readMarks[readChat]! : ""; // Group metadata is ~1000 rows; refresh it only on a deep (panel) fetch and // keep the last good copy if the lookup fails. const groups = (deep ? fetchGroups() : null) ?? state.groups; @@ -2098,8 +2162,8 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = const deduped = dedupeSelfEcho(caughtUp, selfChats); const muted = mutedChats(deduped, mute); const msgs = dropMuted(deduped, muted); - let exactCounts = unreadCounts(msgs, state.readMark, readMarks, selfChats); - let exactOldest = unreadOldest(msgs, state.readMark, readMarks, selfChats); + let exactCounts = unreadCounts(msgs, state.readMark, readMarks, selfChats, unreadSince); + let exactOldest = unreadOldest(msgs, state.readMark, readMarks, selfChats, unreadSince); // Deep runs complete the sidebar from `imsg chats`. A capped catch-up // needs that list too: otherwise a chat hide_spam dropped in SQL is // restored from the ledger (Astra B#3) and pins every later poll. @@ -2127,13 +2191,17 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = } // Per-chat counts were already calculated through --seen above. Do not // zero them wholesale: that would hide arrivals newer than the rendered view. + if (unreadChat && (exactCounts[unreadChat] ?? 0) === 0) { + exactCounts[unreadChat] = 1; + if (!exactOldest[unreadChat]) exactOldest[unreadChat] = unreadSince[unreadChat] || now; + } // Prune per-thread marks the global mark has overtaken (Codex finding #13): // they no longer affect any count and would otherwise accumulate forever. for (const [chat, ts] of Object.entries(readMarks)) { if (!remoteReads && ts <= readMark) delete readMarks[chat]; } const preferImessage = preferImessagePolicy(); - const windowThreads = buildThreads(msgs, readMark, readMarks, groups, exactCounts, preferImessage); + const windowThreads = buildThreads(msgs, readMark, readMarks, groups, exactCounts, unreadSince, preferImessage); // A shallow poll returns the window's rows; the widget keeps its last // complete list in memory (it skips identical assignments anyway). const chats = deep ? listed : null; @@ -2147,11 +2215,12 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = for (const alias of row.aliases ?? []) chatAliases[alias] = chat; } const pins = chats ? pinsFromChats(chats) : state.pins; - if (readChat) { + if (readChat && readChat !== unreadChat) { for (const a of aliasesOf(chatAliases, readChat)) { if (remoteReads || readSeen > readMark) readMarks[a] = readSeen; // same prune rule as the canonical - const remaining = unreadCounts(msgs, state.readMark, readMarks, selfChats); - const oldestRemaining = unreadOldest(msgs, state.readMark, readMarks, selfChats); + delete unreadSince[a]; + const remaining = unreadCounts(msgs, state.readMark, readMarks, selfChats, unreadSince); + const oldestRemaining = unreadOldest(msgs, state.readMark, readMarks, selfChats, unreadSince); if (remaining[a]) { exactCounts[a] = remaining[a]!; exactOldest[a] = oldestRemaining[a]!; @@ -2161,6 +2230,13 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = } } } + if (unreadChat) { + for (const a of [unreadChat, ...aliasesOf(chatAliases, unreadChat)]) { + unreadSince[a] = unreadSince[unreadChat]!; + delete readMarks[a]; + if ((exactCounts[a] ?? 0) === 0) exactCounts[a] = 1; + } + } if (remoteReads) { exactCounts = {}; exactOldest = {}; @@ -2172,7 +2248,7 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = exactCounts[chat] = row.unread; exactOldest[chat] = row.oldest; } - const reading = canonical === readChat; + const reading = canonical === readChat && readChat !== unreadChat; // Compare to the visible snapshot BEFORE advancing any read mark. An // unseen inbound must keep its dot and must not be cleared on the Mac. if (reading && readPush === "thread" && row.unread > 0 && seenTs && row.latest <= seenTs && canPushChat(readChat)) { @@ -2192,15 +2268,24 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = delete exactCounts[chat]; delete exactOldest[chat]; } } - } else if (readPush === "thread" && readChat && canPushChat(readChat)) { + } else if (readPush === "thread" && readChat && canPushChat(readChat) && readChat !== unreadChat) { // Older/offline bridges still retain the transition until it is verified. - const before = unreadCounts(msgs, state.readMark, state.readMarks, selfChats); + const before = unreadCounts(msgs, state.readMark, state.readMarks, selfChats, state.unreadSince); if (seenTs && lastInboundTs(msgs, readChat) > seenTs) { delete pendingReads[readChat]; } else if ((before[readChat] ?? 0) > 0 || (state.unreadCounts[readChat] ?? 0) > 0) { pendingReads = queueReadIntent(pendingReads, readChat, false, seenTs); } } + if (readPush === "off") { + for (const [chat, since] of Object.entries(unreadSince)) { + exactCounts[chat] = Math.max(1, exactCounts[chat] ?? 0); + exactOldest[chat] ||= since; + } + } + for (const [chat, intent] of Object.entries(pendingReads)) { + if (intent.unread) { exactCounts[chat] = Math.max(1, exactCounts[chat] ?? 0); exactOldest[chat] ||= unreadSince[chat] || now; } + } exactCounts = foldChatRecord(exactCounts, chatAliases, (a, b) => a + b); exactOldest = foldChatRecord(exactOldest, chatAliases, (a, b) => (a < b ? a : b)); const foldedWindow = foldThreadAliases(windowThreads, chatAliases).map(t => ({ ...t, unread: exactCounts[t.chat] ?? 0 })); @@ -2233,6 +2318,7 @@ export function collect(deep: boolean, markRead = false, readChat = "", seenTs = unreadInitialized: true, selfChats, readMarks, + unreadSince, groups, chatAliases, pins, @@ -2299,11 +2385,19 @@ if (import.meta.main) { const deep = process.argv.includes("--deep"); const markRead = process.argv.includes("--mark-read"); const ri = process.argv.indexOf("--read"); - const readChat = ri >= 0 ? String(process.argv[ri + 1] ?? "") : ""; + let readChat = ri >= 0 ? String(process.argv[ri + 1] ?? "") : ""; const si = process.argv.indexOf("--seen"); const seenTs = si >= 0 ? String(process.argv[si + 1] ?? "") : ""; + const ui = process.argv.indexOf("--mark-unread"); + let unreadChat = ui >= 0 ? String(process.argv[ui + 1] ?? "") : ""; + const ai = process.argv.indexOf("--act"); + const act = ai >= 0 ? String(process.argv[ai + 1] ?? "") : ""; + const ti = process.argv.indexOf("--target"); + const actTarget = ti >= 0 ? String(process.argv[ti + 1] ?? "") : ""; try { - console.log(JSON.stringify(collect(deep, markRead, readChat, seenTs))); + if (act === "unread" && actTarget) unreadChat = unreadChat || actTarget; + if (act === "read" && actTarget) readChat = readChat || actTarget; + console.log(JSON.stringify(collect(deep, markRead, readChat, seenTs, unreadChat, act === "read"))); } catch (e) { console.log( JSON.stringify({ diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 6ce355f..90cf72b 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -80,8 +80,8 @@ Messages' pinning preferences read-only, and drive Messages.app through AppleScript. Contact creation writes a new card through the native address book API. Contact drafts stay in memory and cross bounded stdin, never argv or persistent Linux storage. Messages.app itself keeps your -conversation history exactly as it always has. Read actions use -`~/.blip/messages-ui.lock` to serialize access to Messages' +conversation history exactly as it always has. Read and unread +actions use `~/.blip/messages-ui.lock` to serialize access to Messages' selected conversation; the lock contains no message data. Contact review reads bounded names, account labels, matching-field counts, diff --git a/icons/read.svg b/icons/read.svg new file mode 100644 index 0000000..a979afe --- /dev/null +++ b/icons/read.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/unread.svg b/icons/unread.svg new file mode 100644 index 0000000..9b950a2 --- /dev/null +++ b/icons/unread.svg @@ -0,0 +1,3 @@ + + + diff --git a/read-sync.test.ts b/read-sync.test.ts index a94a455..8402a3e 100644 --- a/read-sync.test.ts +++ b/read-sync.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -import { parseReadSnapshot, parseReadIntents, queueReadIntent, reconcileReadIntents, retryReadIntent } from "./read-sync"; +import { enqueueRefresh, parseReadSnapshot, parseReadIntents, queueReadIntent, reconcileReadIntents, retryReadIntent } from "./read-sync"; const chat = "+15551234567"; const old = "2026-09-01T10:00:00Z"; @@ -55,6 +55,13 @@ describe("read sync state machine", () => { expect(reconcileReadIntents(pending, parseReadSnapshot(snapshot(1))!)).toEqual(pending); expect(reconcileReadIntents(pending, parseReadSnapshot(snapshot(0))!)).toEqual({ [chat]: pending[chat]! }); }); + test("coalescing preserves read-unread-read order", () => { + const read = { readChat: chat, seen: old, deep: false, markRead: false, unreadChat: "", act: "" }; + const unread = { ...read, readChat: "", unreadChat: chat }; + const q = enqueueRefresh(enqueueRefresh([read], unread), { ...read, seen: recent }); + expect(q.map(r => r.unreadChat ? "unread" : "read")).toEqual(["read", "unread", "read"]); + expect(enqueueRefresh([read], { ...read, seen: recent })).toHaveLength(1); + }); }); /** A complete collector process with an isolated HOME and synthetic bridge. @@ -134,6 +141,17 @@ describe("collector read sync lifecycle", () => { expect(f.actions()).toHaveLength(2); expect(f.state().pendingReads).toEqual({}); }); + test("offline mark-unread survives and a subsequent Mac read clears Blip", () => { + const f = fixture(0); f.put("control.json", { offline: true }); + expect(f.run("--mark-unread", chat).online).toBe(false); + expect(f.state().pendingReads[chat].unread).toBe(true); + f.put("control.json", {}); + expect(f.run().unread).toBe(1); + expect(f.actions()).toEqual([["--unread", chat]]); + f.put("remote.json", snapshot(0)); + expect(f.run().unread).toBe(0); + expect(f.state().unreadSince).toEqual({}); + }); test("new inbound beyond --seen remains unread and cancels an old retry", () => { const f = fixture(); f.run(); f.save({ ...f.state(), pendingReads: queueReadIntent({}, chat, false, old) }); @@ -141,6 +159,13 @@ describe("collector read sync lifecycle", () => { expect(f.run("--read", chat, "--seen", old).unread).toBe(1); expect(f.actions()).toHaveLength(0); }); + test("Mac mark-unread bypasses a historical local read mark; Mac read retires old unread override", () => { + const f = fixture(); f.run(); + f.save({ ...f.state(), readMarks: { [chat]: recent }, unreadSince: { [chat]: old } }); + expect(f.run().unread).toBe(1); + f.put("remote.json", snapshot(0)); + expect(f.run().unread).toBe(0); + }); test("old chats outside previews are present in the complete unread metadata", () => { const f = fixture(0); const data = snapshot(0); @@ -150,10 +175,23 @@ describe("collector read sync lifecycle", () => { expect(out.unreadCounts["+15557654321"]).toBe(1); expect(out.unread).toBe(1); }); + test("push_read=off never calls the Mac, including explicit menu reads", () => { + const f = fixture(); + writeFileSync(join(f.home, ".config/blip/bridge.conf"), "push_read=off\n"); + f.run("--act", "read", "--target", chat, "--read", chat, "--seen", old); + expect(f.actions()).toHaveLength(0); + }); }); describe("policy and ordering integration", () => { + test("local-only mark unread survives later snapshots without a Mac action", () => { + const f = fixture(0); + writeFileSync(join(f.home, ".config/blip/bridge.conf"), "push_read=off\n"); + expect(f.run("--mark-unread", chat).unread).toBe(1); + expect(f.run().unread).toBe(1); + expect(f.actions()).toHaveLength(0); + }); test("gesture-only policy keeps a local read of an old message across polls", () => { const f = fixture(); writeFileSync(join(f.home, ".config/blip/bridge.conf"), "push_read=all\n"); @@ -162,6 +200,20 @@ describe("policy and ordering integration", () => { expect(f.run().unread).toBe(0); expect(f.actions()).toHaveLength(0); }); + test("offline unread then read sends only the last requested state", () => { + const f = fixture(); f.put("control.json", { offline: true }); + f.run("--mark-unread", chat); + f.run("--act", "read", "--target", chat, "--read", chat, "--seen", old); + f.put("control.json", {}); f.run(); + expect(f.actions()).toEqual([["--chat", chat, "--through", old]]); + }); + test("unread queued after offline mark-all lands after mark-all", () => { + const f = fixture(); f.put("control.json", { offline: true }); + f.run("--mark-read"); f.run("--mark-unread", chat); + f.put("control.json", {}); f.run(); f.run(); + expect(f.actions()).toEqual([["--all"], ["--unread", chat]]); + expect(f.run().unread).toBe(1); + }); test("fallback snapshot failure does not consume a newer unseen inbound", () => { const f = fixture(); f.run(); f.put("control.json", { snapshot_fail: true, latest: recent }); @@ -171,6 +223,41 @@ describe("policy and ordering integration", () => { }); +describe("repeated read/unread convergence", () => { + test("every four-click offline read/unread sequence converges to the last click", () => { + for (let mask = 0; mask < 16; mask++) { + const f = fixture(mask % 2); + f.put("control.json", { offline: true }); + let wantUnread = false; + for (let bit = 0; bit < 4; bit++) { + wantUnread = Boolean(mask & (1 << bit)); + if (wantUnread) f.run("--mark-unread", chat); + else f.run("--act", "read", "--target", chat, "--read", chat, "--seen", old); + } + f.put("control.json", {}); + f.run(); + const out = f.run(); + expect(out.unread).toBe(wantUnread ? 1 : 0); + expect(f.state().pendingReads).toEqual({}); + expect(f.actions().length).toBeLessThanOrEqual(1); + } + }, 20000); + test("alternating local and Mac gestures converge without stale overrides", () => { + const f = fixture(0); + const steps = ["unread", "read", "mac-unread", "mac-read", "unread", "mac-read", "mac-unread", "read"]; + for (const step of steps) { + const wantUnread = step.endsWith("unread"); + if (step.startsWith("mac-")) f.put("remote.json", snapshot(wantUnread ? 1 : 0)); + else if (wantUnread) f.run("--mark-unread", chat); + else f.run("--read", chat, "--seen", old); + const out = f.run(); + expect(out.unread).toBe(wantUnread ? 1 : 0); + expect(out.threads.filter((t: { unread: number }) => t.unread > 0).length).toBe(out.unread); + expect(f.state().pendingReads).toEqual({}); + } + }, 10000); +}); + test("a due retry cannot jump ahead of a fresh action for another chat", () => { const other = "+15557654321"; const f = fixture(); f.run(); diff --git a/read-sync.ts b/read-sync.ts index 6a5e4cb..36e84f3 100644 --- a/read-sync.ts +++ b/read-sync.ts @@ -69,12 +69,12 @@ export function reconcileReadIntents(pending: ReadIntents, snapshot: ReadSnapsho return next; } -/** Coalescing cannot cross an explicit gesture: a read queued after - * mark-all must stay after it instead of merging into an earlier read. */ -export function enqueueRefresh(queue: T[], req: T): T[] { +/** Coalescing cannot cross an explicit gesture: read → unread → read must + * keep that order instead of moving the last read ahead of mark-unread. */ +export function enqueueRefresh(queue: T[], req: T): T[] { const q = queue.slice(); - const barrier = (r: T) => r.markRead; + const barrier = (r: T) => r.markRead || r.unreadChat || r.act; if (!barrier(req)) { for (let i = q.length - 1; i >= 0; i--) { if (barrier(q[i]!)) break; diff --git a/scripts/demo/DemoShell.qml b/scripts/demo/DemoShell.qml index 01e6d8b..2e87a7c 100644 --- a/scripts/demo/DemoShell.qml +++ b/scripts/demo/DemoShell.qml @@ -33,9 +33,10 @@ ShellRoot { property string timeFormat: Quickshell.env("BLIP_DEMO_TIME_FORMAT") || "h:mm AP" property string dateFormat: Quickshell.env("BLIP_DEMO_DATE_FORMAT") || "MMM d" property string dateFormatWithYear: Quickshell.env("BLIP_DEMO_DATE_FORMAT_WITH_YEAR") || "MMM d, yyyy" - function refresh(deep, markRead, readChat, seen) { collector.reload() } + function refresh(deep, markRead, readChat, seen, unreadChat) { collector.reload() } function markAllRead() { } function markThreadRead(chat) { } + function markThreadUnread(chat) { } function showApp() { } } diff --git a/ui.test.ts b/ui.test.ts index c22a51a..320ae14 100644 --- a/ui.test.ts +++ b/ui.test.ts @@ -289,6 +289,17 @@ describe("QML safety invariants", () => { expect(nav).toContain("Qt.ShiftModifier"); }); + test("mark as unread is a list action, not a compose jump that eats the letter u", () => { + expect(panel).toContain('text: "Review contact"'); + expect(panel).toContain('text: "Mark as Unread"'); + expect(qmlFunction("markUnread")).toContain("hostWidget.markThreadUnread"); + expect(widget).toContain("function markThreadUnread"); + expect(widget).toContain('--mark-unread'); + const fn = handleTextKeySource(); + expect(fn).toContain('text === "u"'); + expect(fn.indexOf('text === "u"')).toBeLessThan(fn.indexOf("inThread")); + }); + test("handleTextKey runs slash, n, and 1-9 before the inThread return", () => { const fn = handleTextKeySource(); expect(fn.indexOf('text === "/"')).toBeLessThan(fn.indexOf("inThread")); @@ -557,7 +568,7 @@ describe("QML safety invariants", () => { // Three read paths, all gated on `peeking`: the two post-load marks in // BlipView and readingSurface() in BarWidget (what the collector is told // is being read). Focus entering the compose field is the commit. - expect(qmlFunction("markRead")).toContain("if (hostWidget && readActive && !peeking) hostWidget.markThreadRead(chat, seen)"); + expect(qmlFunction("markRead")).toContain("if (hostWidget && readActive && !peeking) hostWidget.markThreadRead(chat, seen, act)"); expect(panel.split("root.markRead(root.threadRunningChat, seen)").length - 1).toBe(2); expect(panel).not.toContain("root.hostWidget.markThreadRead("); expect(panel).toContain("onActiveFocusChanged: if (activeFocus) root.commitPeek()"); @@ -854,7 +865,7 @@ test("reads require a rendered snapshot and carry its own timestamp", () => { expect(panel).toContain("root.markRead(root.threadRunningChat, seen)"); // through the peek gate, same `seen` expect(widget).toContain("s.rendered === true"); expect(widget).toContain('return s ? String(s.seenTs || "") : ""'); - expect(widget).toContain("function markThreadRead(chat, seen)"); + expect(widget).toContain("function markThreadRead(chat, seen, act)"); for (const host of ["./Panel.qml", "./BlipWindow.qml"]) { const src = readFileSync(new URL(host, import.meta.url), "utf8"); expect(src).toContain("readonly property bool rendered: view.rendered"); From a441b3b661a087bfbe3cb33e4a4209bd0f10f160 Mon Sep 17 00:00:00 2001 From: Damon Janis Date: Sun, 27 Sep 2026 09:10:15 -0500 Subject: [PATCH 3/4] fix: chat list queries skip a.uti when the column is absent Older chat.db schemas and the chat-metadata fixtures have mime_type and no uti. The voice-message MIME expression referenced a.uti unconditionally, so imsg chats aborted before it could list anything. --- bridge/mac/imsg | 18 +++++++++++++++--- bridge/mac/test_attachment_mime.py | 12 +++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/bridge/mac/imsg b/bridge/mac/imsg index 076f4c2..8e5608e 100755 --- a/bridge/mac/imsg +++ b/bridge/mac/imsg @@ -66,6 +66,18 @@ ATTACHMENT_MIME_SQL = """COALESCE(NULLIF(a.mime_type, ''), CASE a.uti WHEN 'com.apple.quicktime-movie' THEN 'video/quicktime' WHEN 'public.mpeg-4' THEN 'video/mp4' END)""" + + +def attachment_mime_sql(con) -> str: + """MIME expression for an attachment row. + + Older chat.db schemas, and the unit-test fixtures, have mime_type and no + uti column. Referencing a.uti there aborts the whole chat list.""" + # Index 1 is the column name for both a bare tuple and a sqlite3.Row. + cols = {r[1] for r in con.execute("PRAGMA table_info(attachment)")} + return ATTACHMENT_MIME_SQL if "uti" in cols else "a.mime_type" + + CONTACT_HELPER = os.path.expanduser("~/.blip/bin/contact-delete") # ---------- Contacts name resolution (built-in, no external deps) ---------- @@ -945,7 +957,7 @@ def enrich(con, rows, with_names: bool): for a in _chunked_in( con, f"""SELECT maj.message_id AS mid, a.ROWID AS aid, a.transfer_name AS name, - {ATTACHMENT_MIME_SQL} AS mime, a.total_bytes AS bytes + {attachment_mime_sql(con)} AS mime, a.total_bytes AS bytes FROM message_attachment_join maj JOIN attachment a ON a.ROWID = maj.attachment_id WHERE a.transfer_name IS NOT NULL @@ -1397,7 +1409,7 @@ def cmd_chats(con, args): attachments = {} for attachment in _chunked_in( con, - f"""SELECT maj.message_id AS mid, a.transfer_name AS name, {ATTACHMENT_MIME_SQL} AS mime + f"""SELECT maj.message_id AS mid, a.transfer_name AS name, {attachment_mime_sql(con)} AS mime FROM message_attachment_join maj JOIN attachment a ON a.ROWID = maj.attachment_id WHERE maj.message_id IN ({{marks}}) @@ -2003,7 +2015,7 @@ def cmd_attachment(con, args): if not re.fullmatch(r"[0-9]{1,18}", args.id): sys.exit("error: attachment id must be decimal digits") row = con.execute( - f"""SELECT a.filename, {ATTACHMENT_MIME_SQL} AS mime_type + f"""SELECT a.filename, {attachment_mime_sql(con)} AS mime_type FROM attachment a JOIN message_attachment_join maj ON maj.attachment_id = a.ROWID JOIN {MESSAGE_SRC} m ON m.ROWID = maj.message_id diff --git a/bridge/mac/test_attachment_mime.py b/bridge/mac/test_attachment_mime.py index dc9cbdb..c4d1461 100644 --- a/bridge/mac/test_attachment_mime.py +++ b/bridge/mac/test_attachment_mime.py @@ -53,7 +53,17 @@ def test_an_unknown_uti_stays_unknown(self) -> None: def test_marks_placeholder_survives_the_fstring(self) -> None: src = IMSG.read_text() self.assertNotIn("IN ({marks})\"\"\",\n rowids", src.replace("{{marks}}", "OK")) - self.assertEqual(src.count("{ATTACHMENT_MIME_SQL}"), 3) + self.assertEqual(src.count("{attachment_mime_sql(con)}"), 3) + + def test_schema_without_uti_uses_mime_type_only(self) -> None: + con = sqlite3.connect(":memory:") + con.execute("CREATE TABLE attachment (ROWID INTEGER PRIMARY KEY, mime_type TEXT)") + self.assertEqual(self.imsg.attachment_mime_sql(con), "a.mime_type") + con.execute("INSERT INTO attachment VALUES (1, 'image/png')") + got = con.execute( + f"SELECT {self.imsg.attachment_mime_sql(con)} FROM attachment a" + ).fetchone()[0] + self.assertEqual(got, "image/png") if __name__ == "__main__": From a3bac95932020626533e91ec132cafcdde6683b2 Mon Sep 17 00:00:00 2001 From: Damon Janis Date: Sun, 27 Sep 2026 09:12:00 -0500 Subject: [PATCH 4/4] fix: attachment MIME probe accepts stubbed execute results sqlite3.Row and a tuple expose the column name at index 1. A test that stubs execute() returns dicts, and indexing those with 1 raised KeyError before the attachment query could be skipped. --- bridge/mac/imsg | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/bridge/mac/imsg b/bridge/mac/imsg index 8e5608e..1699726 100755 --- a/bridge/mac/imsg +++ b/bridge/mac/imsg @@ -68,14 +68,39 @@ ATTACHMENT_MIME_SQL = """COALESCE(NULLIF(a.mime_type, ''), CASE a.uti END)""" +def _pragma_name(row): + """Column name from a PRAGMA table_info row, or None if this is not one. + + A sqlite3.Row accepts an integer index; a plain tuple does too. A dict, + which is what some tests make execute() return, does not.""" + if isinstance(row, dict): + name = row.get("name") + return name if isinstance(name, str) else None + try: + name = row["name"] + except Exception: + try: + name = row[1] + except Exception: + return None + return name if isinstance(name, str) else None + + def attachment_mime_sql(con) -> str: """MIME expression for an attachment row. Older chat.db schemas, and the unit-test fixtures, have mime_type and no - uti column. Referencing a.uti there aborts the whole chat list.""" - # Index 1 is the column name for both a bare tuple and a sqlite3.Row. - cols = {r[1] for r in con.execute("PRAGMA table_info(attachment)")} - return ATTACHMENT_MIME_SQL if "uti" in cols else "a.mime_type" + uti column. Referencing a.uti there aborts the whole chat list. A caller + that stubs execute() without a real table keeps the UTI expression; the + query itself is not run.""" + try: + rows = list(con.execute("PRAGMA table_info(attachment)")) + except Exception: + return ATTACHMENT_MIME_SQL + names = [_pragma_name(r) for r in rows] + if not names or any(name is None for name in names): + return ATTACHMENT_MIME_SQL + return ATTACHMENT_MIME_SQL if "uti" in names else "a.mime_type" CONTACT_HELPER = os.path.expanduser("~/.blip/bin/contact-delete")