An open channel marks arriving messages read with no focus or visibility gate
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
# An open channel marks arriving messages read with no focus or visibility gate
## Problem
While a channel is the active channel, read state advances for **every new top-level message as it arrives**, not only when the channel is opened. Nothing in that path checks whether the window is focused or even visible.
Desktop:
- `ChannelScreen.tsx:213-229` recomputes `activeReadAt` from the live message list on every render, then passes it to `useChannelOpenReadState`.
- `useChannelOpenReadState.ts:27-42` runs a `useEffect` keyed on `[activeChannelId, activeReadAt, ...]` that calls `markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true })`. Because `activeReadAt` advances whenever a new top-level message streams in, the marker advances too.
- There is no `document.hasFocus()`, `visibilityState`, `blur`, or Tauri window-focus check anywhere in that path. The only `visibilitychange` listener in `readStateManager.ts:304` (handler at `967-971`) flushes a pending localStorage write when the document becomes hidden — it does not gate the marker advance or the publish.
Mobile behaves the same way:
- `channel_detail_page.dart:207-230` derives `readTimestamp` from the newest top-level message; `channel_detail_page.dart:557-569` marks read whenever it advances.
- `deferred_read_state_update.dart` only defers to the next frame and checks `context.mounted`.
- The only lifecycle handling (`read_state_provider.dart:122-125`) flushes on `paused`/`detached`/`hidden`. It is a persistence flush, not a gate.
## Why this matters
A Buzz window left on a channel — backgrounded, minimised, on another desktop, or with the machine locked — marks messages read that nobody has seen. Because NIP-RS markers are monotonic and sync across devices, one parked desktop also clears the unread state on the user's phone.
There is no way to recover from it:
- **No setting.** Neither settings UI has any read/unread preference. `NotificationSettingsCard.tsx` covers desktop alerts, "notify while viewing", sound and the home badge — nothing controls read marking.
- **"Mark unread" does not help.** It is a local, per-device flag (`forcedUnreadStore.ts` on desktop via localStorage, an in-memory map in `read_state_provider.dart:68` on mobile) that never reaches the relay. `forcedUnreadStore.ts` says so directly: *"NOT synced to the relay — NIP-RS markers are monotonic and cannot represent a retrograde 'unread' state."*
The impact is largest for agent-centric setups, where a channel is the primary surface for talking to an agent. An agent replies while the user is away with the app open, the reply is marked read, and every downstream unread signal — including anything built on NIP-RS — goes silent. The user has no indication anything arrived.
Interestingly, `docs/formal/nip-rs-unread/` models a manual-unread override layer (`ov_s:` / `ov_c:` / `ov_b:` keys), but no client implements it; the clients only carry the local flag above. So the protocol anticipates retrograde unread while the implementation cannot express it.
## Steps to reproduce
1. On desktop, open a channel and leave it as the active channel.
2. Switch to another application, or lock the screen. Leave Buzz running.
3. Have another member or an agent post a top-level message in that channel.
4. Observe a new `kind:30078` read-state event whose context timestamp has advanced to the new message's `created_at`, within a few seconds of arrival.
5. The message is now read on every device. A phone signed into the same account shows no unread badge.
## Expected behavior
Any of these would resolve it:
1. Gate the passive advance on the window actually being focused or visible on desktop, and on the app being foregrounded on mobile. This is what Slack, Discord and iMessage do.
2. Offer a setting to disable passive mark-as-read.
3. Apply the model already proposed in #3683 to the channel view: viewing previews, and read state changes on explicit action.
Option 1 alone would cover the reported failure while leaving current behavior intact for a user who is genuinely at the screen.
## Related
- #3683 — *Only mark Inbox items read on explicit action*. Establishes the same principle for the Inbox ("Slack-style inbox triage model"), but does not cover the channel view and does not discuss focus.
- #6303 — read state does not sync between iOS and desktop.
## Logs / additional context
Observed on a self-hosted relay with the desktop app open on the channel and the
window not in the foreground:
```
message created_at = 2026-09-07 22:58:33Z (kind:9, from another member)
readstate created_at = 2026-09-07 22:58:34Z (kind:30078, d=read-state:)
```
The read marker advanced one second after the message arrived, without any
interaction. Decrypting the `kind:30078` payload shows the channel's context
timestamp moved to the new message's `created_at`.
## Version and platform
- Buzz version: `desktop-v0.5.22`
- OS: macOS 15 (Darwin 25.6)
- Relay: self-hosted, `deploy/compose/`
Contributor guide
Assessment
This issue has not been assessed yet.