Skip to content

feat(channels): mute conversations and mark them read or unread - #91

Merged
tellaho merged 3 commits into
mainfrom
tho/sidebar-mute-read
Sep 24, 2026
Merged

tellaho merged 3 commits into
mainfrom
tho/sidebar-mute-read

Conversation

@tellaho

@tellaho tellaho commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Category: new-feature
User Impact: Users can mute a conversation or deliberately mark it read or unread without opening it.

Problem: The sidebar lacks direct controls for quieting a conversation and managing its read state.

Solution: Add focused Mute/Unmute and state-appropriate Mark as Read/Unread actions using the existing preference, notification and durable unread owners. Mute dismisses immediately with rollback/retry on failure; read actions close only after local durability.

Standalone scope: Focused mute/read implementation 6c65ec30 plus test-only CI repairs 8267c4a0 and 5db272b1, directly on main at 204ea9d1. This extends the merged persistent-sidebar context menu: New session → separator → Mute/Unmute → Mark as Read/Unread. Preserve main’s placement-lifetime cleanup, per-action eligibility, composer focus handoff, pill-rounded menu rows, DM controls and unread presentation. No restored row ⋮, alternate menu, Move to…, group/star writes, sorting/lifecycle changes or shared-menu restyling. Production diff: 14 files, +715/−9 lines; tests/docs are separate.

Implemented and maintained by Carl (AI agent), using the operator’s GitHub account. Ready for review; hosted CI completion remains pending at the last check. Not a merge-readiness claim.

Behavioral boundaries

  • Mute writes use the existing authenticated live socket, not HTTP publication fallback. Fresh signed reads and confirmed readback preserve unrelated entries/tombstones. Same-host writes serialize; cross-host whole-record races remain possible. No durable mute outbox or automatic cross-device synchronization.
  • Confirmed mute suppresses DM/participating-thread alerts. Explicit mentions bypass only the mute gate. Unread truth is unchanged; unmuting does not replay cancelled alerts.
  • Mark as Read snapshots retained verified evidence, including replies, and atomically clears only owned local marks with its frontier. No history fetch or wall-clock cutoff. With no evidence, only the local channel mark clears. Mark as Unread is device-local.
  • Browser/development-host integration only; no speculative packaged-native writer. Real deployed-relay and OS-banner behavior remain untested.

Changes

File changes

dev/relay-broker.mjs
Add the narrow, per-relay serialized mute command with bounded signed reads, same-community live-socket publication and confirmed readback.

dev/sidebar-mutes-broker.test.mjs
Exercise real transport → HTTP broker → modeled socket integration, NIP-98 authentication, owner scoping, invalid input, rejection/disconnect and failed confirmation.

dev/sidebar-mutes.mjs
Validate and encrypt one mute intent while preserving unrelated fields and explicit unmute tombstones; fail closed on invalid heads and conflicts.

dev/sidebar-mutes.test.mjs
Cover malformed/foreign heads, budgets, monotonic updates, no-ops and publication/readback failures.

dev/sidebar-preferences.mjs
Decode the third own-author preference coordinate within existing signature and size bounds.

docs/channels.md
Document the shared menu and persistent sidebar’s pending/error lifetime, optimistic recovery, socket ownership and host/convergence limits.

docs/notifications.md
Document muted-alert suppression, mention exceptions and pending-candidate cancellation.

docs/unread.md
Define evidence-based channel read, atomic local clearing and the explicit ownership matrix.

src/bundled/channels/ChannelReadMenuItem.test.tsx
Cover state-appropriate commands and decorative icons, pending state, live updates, retargeting and subscription cleanup.

src/bundled/channels/ChannelReadMenuItem.tsx
Offer one read action from the domain snapshot while the menu is mounted, with a decorative 14px envelope icon.

src/features/channel-navigation/ChannelSidebar.tsx
Extend the existing New session menu with icon-bearing mute/read actions and a conditional separator. Keep retry/dismiss notices in the persistent owner, preserve placement cleanup and composer handoff, and restore focus by row identity without selecting the conversation.

src/bundled/channels/sidebar-sections.test.ts
Extend the preference fixture with muted state without changing placement expectations.

src/bundled/channels/useChannelRowMenu.test.tsx
Complete the preference fixture with muted state while retaining main’s menu-placement assertions.

src/bundled/channels/useOptimisticMute.test.tsx
Cover immediate presentation, rollback/retry, superseding clicks and retired-view completions.

src/bundled/channels/useOptimisticMute.ts
Keep optimistic menu state local while confirmed session state remains notification authority.

src/features/notifications/messages.test.ts
Cover muted DMs/thread replies, mention exceptions, startup readiness and cancellation during permission checks.

src/features/notifications/messages.ts
Apply confirmed mute policy in the app-global notification owner, including when Messages is unmounted.

src/features/relay/live-restriction.test.ts
Preserve restriction coverage with the complete preference fixture.

src/features/relay/read-state.ts
Atomically clear the supplied owned local marks with the existing durable frontier transaction.

src/features/relay/session.ts
Wire the optional mute writer into the existing session lifetime and deadline (14 added lines).

src/features/relay/sidebar-preferences-store.test.ts
Cover confirmed writes, serialization, stale refreshes, capability checks and cancellation/generation fences.

src/features/relay/sidebar-preferences-store.ts
Retain immutable muted state and serialize confirmed writes without stale-read rollback.

src/features/relay/sidebar-preferences.test.ts
Cover muted projection and malformed entries; preserve existing group/star projection.

src/features/relay/sidebar-preferences.ts
Add the bounded muted projection and narrow optional writer contract.

src/features/relay/transport.ts
Expose the mute command only when advertised and pass the existing live-owner identity header.

src/features/relay/unread.test.ts
Cover evidence cuts, replies, owned clearing, unrelated state, access/lifetime changes and durable-save failure.

src/features/relay/unread.ts
Snapshot retained verified evidence at invocation for explicit channel read; invent no wall-clock frontier or history fetch.

src/features/relay/warm-lifecycle.test.ts
Preserve warmer lifecycle expectations with complete preference data.

src/features/relay/warm.test.ts
Preserve warming expectations with complete preference data.

src/shared/design-system/icons/index.ts
Export Phosphor Bell/BellSlash and Envelope/EnvelopeOpen icons through the existing shared boundary.

tests/browser/fixture.mjs
Model encrypted mute storage and record precisely injected HTTP failures without a broad console-error allowlist.

tests/browser/navigation-mute-read.spec.mjs
Add one representative built-app journey per engine for shared action order/icons, real focus/dismissal, gated failure/retry across page switches, IndexedDB reload, row relocation, Sessions disable/enable without a dangling separator, and production-broker New session composition.

tests/browser/navigation-session-menu.spec.mjs
Complete the preference fixture with muted state; retain main’s stale-placement cleanup and session-focus assertions.

tests/browser/sidebar-unread.spec.mjs
Locate DM controls through the existing sidebar-row marker rather than a fixed wrapper depth. Preserve hide/restore, badge geometry, hover styling and keyboard tab-order assertions when DMs expose the shared context menu.

tests/browser/settings.spec.mjs
Wait for each real wheel gesture’s scrollend before measuring the next row position. Retain main’s distance-based scrolling, visibility margins, navigation assertions and full-visibility/focus/keyboard checks. No Settings production changes.

tests/browser/policy-relay.mjs
Validate the exact three-coordinate preference query; no new grouping writes.

Reproduction Steps

  1. In Messages with the development broker connected, right-click a non-selected channel or focus it and press Shift+F10 / the Context Menu key. The existing New session action comes first, followed by a separator and icon-bearing mute and state-appropriate read actions. No separate row ⋮ returns.
  2. Choose Mute. The menu closes and focus returns to that row without selecting it or clearing unread. Reopen to see Unmute. Reload to check confirmed persistence.
  3. Inject a failed /sidebar-mute response. The action rolls back and a Retry/Dismiss notice appears. Retry sends the same intent, not a toggle against stale state.
  4. Choose Mark as Read on an unread row. It closes after the local transaction, clears owned read intent, and leaves unrelated channels unread. Reopen to see Mark as Unread, which marks only this device.
  5. Disable Sessions in Settings → Plugins. Mute/read remain usable with no dangling separator; re-enable Sessions and choose New session from the same menu. The new-session composer receives focus.
  6. Confirm a mention still passes channel mute while ordinary eligible muted alerts do not. Global notification preferences/access/read gates still apply.

Validation

Local macOS arm64, pinned Node 24.18.0 / pnpm 11.8.0 / Playwright 1.63.0. Evidence below applies to clean head c6179d8, based on 119195e, unless explicitly stated otherwise.

  • Normal pre-commit hooks passed. Normal pre-push hooks passed: TypeScript and 183 related unit/service test files / 2,177 tests, plus design-system types and type/color/contrast/app-foundations/icon/adoption guards. No bypasses.
  • bin/pnpm exec playwright test --config tests/browser/playwright.config.mjs --project chromium --project webkit --no-deps tests/browser/navigation-mute-read.spec.mjs tests/browser/navigation-session-menu.spec.mjs: 6/6 passed, 26.6 seconds, two workers, no retries. Fixture evidence records this exact clean head and builds the production frontend; the mute/read journey uses the real development broker against modeled upstream WS/HTTP policy.
  • A prior five-file integration run (navigation-mute-read, navigation-session-menu, navigation-sidebar, new-message, thread-unread) passed 44/46 before fixture fixes. Both failures were the placement fixture omitting the newly required muted array. A subsequent focused run exposed a focus assertion placed after Settings navigation; it was moved back immediately after menu dismissal. The final 6/6 above reran both affected files, preserving the original placement and focus assertions. The five-file set was not rerun in full after those corrections.
  • git diff --check origin/main...HEAD passed. No current-head full-suite, native, deployed-relay, OS-banner or local channel-opening-measurement claim.

Browser coverage accounting: +1 journey per engine relative to main; none removed. Browser-only justification: real shared-menu accessibility/focus, production frontend/broker/socket composition, IndexedDB reload, persistent sidebar behavior across page switches, and Sessions plugin transitions. Policy/failure matrices remain in unit/service tests. No retries, relaxed budgets or error allowlists added.

CI repair (6fea974e)

Run 36065578455 at c6179d8 passed JavaScript, Rust/tool integration, browser measurements, both first journey shards, DCO and security. The second shards failed only in sidebar-unread.spec.mjs: DM hide/restore in both engines and DM keyboard tab order in Chromium. Enabling the existing context-menu wrapper for DMs invalidated the test’s two-parent traversal; CI’s captured page still contained the remove control. This repair uses the existing data-channel-sidebar-row marker, matching the navigation test convention. No production changes, assertion removals, retries or relaxed thresholds.

  • Full affected browser file: bin/pnpm exec playwright test --config tests/browser/playwright.config.mjs --project chromium --project webkit --no-deps tests/browser/sidebar-unread.spec.mjs — 12/12 passed, 27.3 seconds, two workers, no retries, macOS arm64. Ran at c6179d8 with exactly this test-only working-tree edit; committed unchanged as 6fea974e.
  • Normal commit/push hooks passed at 6fea974e: TypeScript, 183 related unit/service files / 2,177 tests, design types and guards. git diff --check passed; worktree clean.
  • Replacement hosted run at 6fea974e: JavaScript, Rust/tool integration, measurements, three journey shards, DCO and security passed; WebKit 2/2 failed solely in the narrow Settings case (145 passed, one failed). The mute/read journey and six sidebar-unread cases passed in that shard. Windows native validation was skipped. The operator moved this PR to ready for review; no approval or merge performed.

Settings wheel synchronization and main integration (5db272b1)

The failed hosted trace proves the test measured transient visibility while queued wheel input was still moving: Settings scrollTop advanced from 1286 when the row assertion passed to 1506 before the click, then stayed at 1506 through click, Space and the failed final assertion. This clipped the switch to intersection ratio 0.992968738079071. The test waited for scrolling to start, not finish.

The test now arms a one-shot scrollend observer before each gesture and awaits completion before measuring again. No fixed sleeps, animation overrides, retries or relaxed assertions; zero cases added/removed. The unchanged macOS WebKit run passed, so hosted failure and local pass are different-environment evidence, not a deterministic local fail-then-pass claim.

Rebased onto 204ea9d1 and retained main’s Settings layout, distance-based wheel geometry, visibility margins and Hosted communities keyboard assertion. The only overlap was restoring the local test edit from autostash; the resolution combines main’s wheel distance with completion synchronization. git range-diff shows the two previous PR commits retained semantically (only surrounding broker context changed).

  • Full four-file browser integration: bin/pnpm exec playwright test --config tests/browser/playwright.config.mjs '/settings.spec.mjs$' '/sidebar-unread.spec.mjs$' '/navigation-session-menu.spec.mjs$' '/navigation-mute-read.spec.mjs$' --project chromium --project webkit --no-deps --trace on — 26/26 passed, 57.4 seconds, two workers, zero retries, macOS arm64. Ran at 8267c4a0 with exactly the staged test edit committed unchanged as 5db272b1.
  • Settings-only timing evidence with tracing: on the old base, unchanged narrow WebKit case 1.5s; with completion synchronization, 3.2s (Chromium 2.6s). After integrating main’s distance-based scrolling, narrow cases were 1.6s WebKit / 1.3s Chromium. These are individual local observations with changed layout, not a hosted performance comparison; builds/setup are outside individual case times.
  • Normal commit/push hooks passed at 5db272b1: TypeScript, 186 related unit/service files / 2,219 tests, design types/guards. git diff --check passed; worktree clean. All three outgoing commits retain actual Carl authorship and DCO.
  • New hosted run 36070345580 at 5db272b1: DCO passed; JavaScript, Rust, measurements and four journey shards running at the last snapshot. Windows validation skipped. Hosted success is still pending; no CI-green or merge-readiness claim. No polling loop is running.

Screenshots / Demos

Actual production-built app at clean c6179d8, dark theme with fixture data, cropped to the shared menu. These show both current action states—not synthetic UI. Main’s New session action, menu styling and separator are retained; this PR adds the mute/read actions and their icons.

Unread, unmuted conversation

New session followed by Mute and Mark as Read with icons

Read, muted conversation

New session followed by Unmute and Mark as Unread with icons

@tellaho tellaho changed the title feat(channels): mute and mark channels read from the sidebar feat(channels): mute conversations and mark them read or unread Sep 22, 2026
@tellaho
tellaho force-pushed the tho/sidebar-grouping-star branch from 0cbb69a to 67146d8 Compare September 22, 2026 22:02
@tellaho
tellaho force-pushed the tho/sidebar-mute-read branch from 726ad9e to c86b33c Compare September 22, 2026 22:15
@tellaho
tellaho changed the base branch from tho/sidebar-grouping-star to main September 24, 2026 06:54
@tellaho
tellaho force-pushed the tho/sidebar-mute-read branch 2 times, most recently from 222cb7e to c6179d8 Compare September 24, 2026 22:06
@tellaho
tellaho marked this pull request as ready for review September 24, 2026 22:39
@tellaho
tellaho requested review from a team, comp615 and wesbillman as code owners September 24, 2026 22:39
Carl added 3 commits September 24, 2026 15:56
Extend the existing persistent sidebar context menu with icon-bearing mute/read actions. Preserve New session, placement cleanup, and focus handoff while confirming encrypted mute writes through the live socket and durable unread owner.

Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Find the existing row container rather than assuming two wrapper levels. Preserve hide/restore, badge geometry and tab-order assertions when mute/read enables the shared context menu for DMs.

Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Observe scrollend before measuring the next plugin row position. A changed scrollTop only proves wheel input started; queued WebKit scrolling could clip the switch after the full-visibility assertion passed. Preserve main's geometry, navigation, focus and keyboard assertions.

Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
@tellaho
tellaho force-pushed the tho/sidebar-mute-read branch from 6fea974 to 5db272b Compare September 24, 2026 22:58

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Source review: no blocking defects found

Reviewed 5db272b12afa2352981d07fd81121e42f3219e4e against base 204ea9d1dea6b7c524d6e09f032395fbde723830, covering sidebar mute/read actions, confirmed preference publication/readback, notification policy, durable unread ownership, and session/cache-clear transitions. The two preliminary concerns did not survive verification against the documented contract: failed preference refreshes deliberately hold ordinary alerts, and cache-clear cancellation does not promise silent dismissal or rollback of an accepted mute publication.

  • Existing CI evidence: run 36070345580, snapshot around 23:03Z, passed JavaScript (315 files / 3,415 tests), browser measurements, DCO and security checks. Rust/tool integration and all four browser-journey shards were still running; Windows native validation was skipped. This is an incomplete snapshot, not a full CI pass.
  • Validation limits: source-only review; no PR code or tests executed by the reviewers. The PR’s updated local 26/26 browser results and 2,219 unit/service results are author-reported evidence, not independent execution. Native mute writers, deployed-relay behavior and real OS banners remain outside this browser/development-host slice.

This is a review comment, not approval or a merge-readiness certification. Required hosted checks and human/code-owner review remain separate gates.

@tellaho
tellaho merged commit 6ae0fe9 into main Sep 24, 2026
12 checks passed
@tellaho
tellaho deleted the tho/sidebar-mute-read branch September 24, 2026 23:26
loganj pushed a commit that referenced this pull request Sep 25, 2026
Since #91, every sidebar row has actions, so every row was wrapped in its
own ContextMenuRoot and MenuPopup outside the memoized ChannelSidebarItem.
Those wrappers got fresh action lists and callbacks on every sidebar
render, so a channel switch rerendered one Base UI menu root per row.

Move the row's menu root into ChannelSidebarItem and give popup content
only to the open row. Closed rows now keep equal props and skip the
render. With 4x CPU throttling, the median warm switch in
channel-opening.spec falls from 257ms to 164ms (24 samples each).

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
loganj pushed a commit that referenced this pull request Sep 25, 2026
Since #91, every sidebar row has actions, so every row was wrapped in its
own ContextMenuRoot and MenuPopup outside the memoized ChannelSidebarItem.
Those wrappers got fresh action lists and callbacks on every sidebar
render, so a channel switch rerendered one Base UI menu root per row.

Move the row's menu root into ChannelSidebarItem and give popup content
only to the open row. Closed rows now keep equal props and skip the
render. With 4x CPU throttling, the median warm switch in
channel-opening.spec falls from 257ms to 164ms (24 samples each).

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
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