Skip to content

Add Pin and Hide Alerts to the conversation menu - #122

Merged
nixfred merged 7 commits into
nixfred:mainfrom
damonjanis:pr/pin-alerts
Oct 4, 2026
Merged

nixfred merged 7 commits into
nixfred:mainfrom
damonjanis:pr/pin-alerts

Conversation

@damonjanis

Copy link
Copy Markdown
Contributor

Right-click a direct message to Pin or Unpin it, or to Hide or Show Alerts. The row updates immediately. imsg-read then clicks Messages' own menu item and checks the pinning plist or ignoreAlertsFlag. An unreadable database is a failure, not success.

Hide Alerts also stops Blip's own toasts for that chat. The conversation stays in the list. The muted ids come from the last full chat list and are kept across shallow polls. Groups are refused, because these items address a handle.

This is 3 of 3, stacked on #120 and #121. Review the top commit. Do not merge this before those two.

Test plan:

  • bun test
  • python3 scripts/test-read-ui.py
  • python3 bridge/mac/test_read.py
  • python3 bridge/mac/test_read_sync.py
  • python3 bridge/mac/test_read_verification.py

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.
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.
Right-click a direct message to Pin or Unpin it, or to Hide or Show
Alerts. Blip updates the row at once. Then imsg-read selects the chat on
the Mac, clicks Messages' own menu item, and checks the pinning plist or
the ignoreAlertsFlag in chat.properties for the new state. If the action
fails, the error shows as the collector error.

`imsg chats` now reports muted from chat.properties, so the menu shows
the right label. Groups are refused because these menu items address a
handle.
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.
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.
@nixfred

nixfred commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Reviewed as part of the series; the full list is on #120. The #122-specific blocker is the focus order in click_conversation_item (select_chat before wake_messages). I will merge the series together once #120's fixes land.

WHAT: Pin, Unpin, Hide Alerts and Show Alerts now run through one
conversation_action(). The front app is captured BEFORE `open imessage:`
selects the DM, and every exit path (selection failure, menu error,
failed verification) hands focus back to it. chat_pinned and chat_muted
are tri-state (True / False / None) and scoped to the conversation
cluster: chat_pinned reads Messages' ordered pin list (pD.pP with pZ
aliases, via imsg's new ordered_pins) and matches every cluster id, guid
and group id the way `imsg chats` does; chat_muted reads ignoreAlertsFlag
on every chat row of the cluster. An unreadable pin list, an unknown pin
layout, a missing chat, a malformed properties plist or an unreadable
chat.db is None, and None is a failure (exit 75) for the off actions as
well as the on ones. settle_flag keeps waiting through a transient None.
Opening a DM to pin or mute it no longer silently reads it: the unread
count is taken first and, if opening the conversation read it, Mark as
Unread puts it back while the chat is still selected; a restore that
cannot land is reported on stderr. The four flags refuse group ids on
the Mac side too. `imsg chats` reports `muted` over the same cluster
(muted_conversations), so the list and the write agree.

WHY: The review of nixfred#122 found click_conversation_item selected first and
captured the front app second, so it restored Messages to itself; that
chat_pinned flattened every string in the plist, compared one handle,
and returned False when nothing could be read; and that chat_muted
turned a malformed plist or missing chat into False. Together those
could acknowledge an Unpin or Show Alerts that never happened.

ENABLES: Pin and alerts acknowledged only on Messages' own records;
verified, failed and unknown outcomes covered by mocked tests that never
open Messages.

CONTEXT: crew task blip-pr122-fixes-a2, fixes 1, 2 and 4 from the review
of PR nixfred#122 (Add Pin and Hide Alerts to the conversation menu). Added on
top of the contributor's commits.

TAGS: imsg-read, pin, hide-alerts, focus, verification, tri-state,
cluster, merged-dm, unread, tests

LR-T: imsg-read, pin, hide-alerts, verification, focus-restore
LR-D: blip, mac-bridge
LR-K: tri-state-verification, cluster-aware-pins, capture-front-before-select, keep-unread
WHAT: New pure applyAlertsOff(threads, alertsOff) re-applies the cached
Hide Alerts set to a shallow poll's threads, the same way applyPins
re-applies cached pins. collect() computes alertsOff before the threads
and runs the shallow path through applyPins then applyAlertsOff.
Unchanged rows are returned as the same objects so the widget can still
skip identical lists. Tests cover deep -> shallow (muted survives),
Show Alerts at the next chat list (muted clears), row reuse, and a
conversationAct failure carrying the Mac's reason. CHANGELOG states the
verification and unread behaviour.

WHY: buildThreads initializes muted: false and only applyPins ran on
shallow output, so a muted conversation rendered as unmuted after every
shallow poll; BarWidget's overlay replaced the deep row wholesale and
the menu offered Hide Alerts for a chat whose alerts were already off.

ENABLES: The conversation menu shows the right Hide/Show Alerts label
between deep runs.

CONTEXT: crew task blip-pr122-fixes-a2, fix 3 from the review of PR nixfred#122
(Add Pin and Hide Alerts to the conversation menu).

TAGS: collector, hide-alerts, shallow-poll, muted, threads, tests

LR-T: collector, hide-alerts, shallow-poll
LR-D: blip, collector
LR-K: apply-alerts-off, shallow-poll-state
@nixfred

nixfred commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Thank you, @damonjanis. Pin and Hide Alerts in the conversation menu fit Blip well: they use Messages' own menu, add no config surface, and treat alertsOff as a cache of remote state. The review asked for four fixes before merge. I added them as two commits on top of yours (cb0ebfc, 57162ff), and your commits are unchanged:

  1. Focus is captured before the conversation is selected. click_conversation_item ran select_chat() first and wake_messages() second. open imessage: can bring Messages forward on its own, so the "previous" app was sometimes Messages and focus was never handed back. conversation_action() now calls frontmost() before selecting, and every exit path restores that app: selection failure, a menu error, and failed verification.
  2. Verification has three states, covers the whole cluster, and fails closed. chat_pinned used to search every string in the plist, compared only one handle, and returned False when nothing could be read. It now reads Messages' ordered pin list (pD.pP with pZ aliases, through a new ordered_pins() in imsg) and matches every id in the conversation cluster, plus guids and group ids, the same way imsg chats does. chat_muted reads ignoreAlertsFlag on every chat row in the cluster. These all count as unknown: an unreadable or unrecognised pin list, a missing chat, a malformed properties plist, or an unreadable chat.db. Unknown exits 75 for Unpin and Show Alerts as well as for Pin and Hide Alerts, so a failed "off" action can no longer report success. imsg chats now reports muted over the same cluster, so the list and the write define "muted" the same way. The four flags also refuse group ids on the Mac side.
  3. Mute state survives shallow polls. buildThreads sets muted: false, and only applyPins ran on shallow output, so a muted chat showed as unmuted after the next shallow poll and the menu offered "Hide Alerts" again. The new applyAlertsOff() reapplies the cached set, the same way pins are reapplied.
  4. Tests for the four mutations and the read side effect. The mocked tests in bridge/mac/test_read.py never open Messages. For Pin, Unpin, Hide and Show they check: the front app is captured before selection and restored last; a dormant menu wakes Messages; selection failure never clicks; verified, failed and unknown outcomes are handled for each flag; groups are refused. On the read side effect: opening a DM to pin or mute it used to read it silently. The unread count is now recorded first and put back with Mark as Unread while the chat is still selected, including when the action itself fails. A restore that cannot land is reported on stderr. Synthetic-SQLite tests cover a pin stored under a merged DM's other handle, an empty pin list as a verified unpin, unknown pin layouts, and malformed or missing mute state. Collector tests cover deep→shallow mute state and a write that cannot be verified. Before relying on these, I reverted fixes 1 and 2 in a scratch copy: the new tests failed against both.

Results (GitHub CI on 57162ff, merged with current main, all steps green):

bun test: 720 pass, 0 fail (721 tests, 25 files)
QML badge regression (scripts/test-read-ui.py): 6 passed, 0 failed
OTP desktop adapter, Mac tools py_compile: pass
bridge/mac Python suites (all 17 CI steps): OK, incl. test_read.py 32 tests
bash -n + shellcheck -S warning: pass

I also ran the same checks locally on the branch and on its merge with main. Everything passed except read-sync.test.ts "every four-click offline read/unread sequence…", which hit its 20 s budget on a heavily loaded machine. It fails the same way there on 325c823, before these commits, and passes in CI.

Overlaps: #83 touches the same collector and bridge paths, and #25 touches the same UI files. Whichever lands second will need a rebase.

nixfred will merge it. Thanks again for building this.

@nixfred
nixfred merged commit 7a028da into nixfred:main Oct 4, 2026
1 check passed
@nixfred

nixfred commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Merged, thank you Damon. Pin and Hide Alerts are live on both my machines with the new Mac bridge. The four review fixes went in on top of your commits, as described above. I re-checked the focus order myself before merging: the front app is captured before the conversation is selected and handed back on every exit. With #120 and #121 that completes the series.

nixfred added a commit that referenced this pull request Oct 5, 2026
… they sat open (one-way doors)

LR-T: blip, weekly-notes, change-log, roster, door-classifier, #127, #122, #83, #131
LR-D: blip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeRMdPWNC2myz9U8hEeeCi
nixfred pushed a commit that referenced this pull request Oct 7, 2026
The conversation menu's Pin, Hide Alerts and read/unread icons (#122) live in
icons/, which the documented cp never copied. A plugin deployed that way logs
'Cannot open: .../icons/*.svg' and shows the menu without its icons. Clones
from the README install path were never affected.
nixfred added a commit that referenced this pull request Oct 7, 2026
WHAT: the developer deploy line in CLAUDE.md, AGENTS.md and CONTRIBUTING.md
now copies icons/ (cp -r ... icons). From Ian Swope (github.com/ianswope,
X @ianfs).
WHY: #122 added icons/{pin,moon,read,unread}.svg, loaded by BlipView's
menuIcon() via Qt.resolvedUrl('icons/<name>.svg'); a plugin deployed by the
old line logged 'Cannot open ... icons/*.svg' and the conversation menu
rendered without icons. README git-clone installs were unaffected. gus and
vic already have icons/ (Larry's deploys rsync icons/***).
VERIFIED: docs only, door two-way, CI green.

LR-T: blip, docs, deploy, icons, claude-md, ianswope
LR-D: blip
LR-R: #135

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeRMdPWNC2myz9U8hEeeCi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants