Skip to content

Add Mark as Unread and Mark as Read to the conversation menu - #121

Merged
nixfred merged 4 commits into
nixfred:mainfrom
damonjanis:pr/mark-unread
Oct 2, 2026
Merged

nixfred merged 4 commits into
nixfred:mainfrom
damonjanis:pr/mark-unread

Conversation

@damonjanis

Copy link
Copy Markdown
Contributor

Right-click a conversation to Mark as Unread, or press U in the list. If it already has a dot, the item is Mark as Read. Review contact stays on this menu and on the conversation header.

Direct messages only. The gesture uses the same pendingReads queue as a read. imsg-read --unread clicks Messages' own item and exits 0 only when read_state counts the chat unread. It does not open a chat that is already unread, because opening it would mark it read. Groups stay local. Mark as Read from the menu is an explicit read, so it queues even under the default push_read=all.

This is 2 of 3, stacked on #120. Review the top commit. Do not merge this before #120.

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.
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.

@nixfred
nixfred merged commit 39a7ace into nixfred:main Oct 2, 2026
1 check passed
@nixfred

nixfred commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Merged, thank you Damon. Mark as Unread was one of the things people kept asking for, and the shape is right: it rides #120's verified queue, refuses to open a chat that is already unread (since opening would read it), records the front app first and hands focus back, and only reports success when chat.db agrees. Groups staying local, with the reason in the commit, is the honest call. Merged with main without conflicts: 672 tests, all bridge tests and the read-UI test green. It is live on both my machines with the new bridge on the Mac. #122 is next, once the focus-order fix in click_conversation_item lands; the per-poll items are in #125.

nixfred added a commit that referenced this pull request Oct 2, 2026
LR-T: blip, weekly-notes, change-log, read-sync, #120, #121
LR-D: blip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nixfred added a commit that referenced this pull request Oct 2, 2026
…icts

WHAT: #117 (with #116) predates #120/#121/#123. Conflicts, all resolved by
keeping both: ci.yml py_compile list gets imsg-react AND read_state.py;
install.sh installs imsg-react AND read_state.py; thread.ts decorate()
emits guid (tapbacks) AND seen_ts (read sync); thread.test.ts keeps the
'tapback targets' describe block AND the reaction-activity test. ui.test.ts
auto-resolved to #117's guid-following version (agreed on #117).

LR-T: blip, tapbacks, merge, conflicts, #116, #117
LR-D: blip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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