Skip to content

fix(workspaces): exclude muted channels and unfollowed threads from rail unread badge#1738

Merged
wpfleger96 merged 2 commits into
mainfrom
duncan/rail-badge-skip-muted
Jul 10, 2026
Merged

fix(workspaces): exclude muted channels and unfollowed threads from rail unread badge#1738
wpfleger96 merged 2 commits into
mainfrom
duncan/rail-badge-skip-muted

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

Two classes of events were incorrectly lighting the workspace rail's unread dot:

  1. Muted channels — channels the user has muted still counted toward hasUnread because fetchObservedChannels filtered archived channels and hidden DMs but not muted channels.
  2. Unfollowed thread replies — threaded replies in threads the user was not following, participating in, authored, or mentioned in lit the rail dot, even though the in-workspace sidebar dot correctly ignores them.

When the user switched into the relay, both badges disappeared — the two code paths disagreed on what counts as unread.

Root cause

fetchWorkspaceUnread in workspaceUnreadObserver.ts:

  • Never checked channel mutes (kind 30078, d=channel-mutes)
  • Set hasUnread on any external unread event via isUnreadExternalEvent, with no thread-relevance gate

The in-workspace sidebar uses shouldNotifyForEvent (a pure function in shouldNotify.ts) to gate events through the same thread-relationship sets. The rail observer did not.

Fix

Commit 1 — muted channels:
Fetch the channel-mutes blob (kind 30078, d=channel-mutes, nip44-self-encrypted) alongside read-state in a single Promise.all, decrypt via nip44DecryptFromSelf, derive the muted id set with mutedChannelIdsFromStore, and continue past muted channels in the loop.

Commit 2 — thread-relevance gate:
Before the per-channel loop, read the four by-pubkey localStorage sets — participated (buzz-thread-participation.v1), followed (buzz-thread-follows.v1), authored (buzz-thread-authored.v1), muted-roots (buzz-thread-muted.v1) — and gate each candidate unread event through shouldNotifyForEvent in addition to the existing isUnreadExternalEvent check. The follows set uses ThreadFollowEntry[] format so it gets a thin dedicated reader.

Both injectables follow the existing decryptReadState/decryptMutes pattern for unit-testability.

Tests

14 tests total (9 pre-existing + 5 new for thread-relevance gate):

  • Muted-only channel → {hasUnread: false, mentionCount: 0}
  • Mixed muted+unmuted — only unmuted channel counts
  • Decryption failure → treated as empty mutes (channel still counted)
  • Absent mutes blob → treated as empty mutes
  • Threaded reply in untracked root → hasUnread: false
  • Same reply with root in participatedRootIdshasUnread: true
  • #p-mention reply in untracked root → hasUnread: true (mention overrides gate)
  • Top-level post → hasUnread: true (no thread gate for top-level)
  • Threaded reply whose root is in mutedRootIdshasUnread: false (mute wins even if participated)

tsc --noEmit clean, pnpm build clean.

Muted channels with new external messages were lighting the workspace
rail's unread dot because fetchObservedChannels filtered archived
channels and hidden DMs but not muted channels.

Fetch the channel-mutes blob (kind 30078, d=channel-mutes) alongside
read-state using Promise.all, decrypt via nip44DecryptFromSelf, derive
the muted id set with mutedChannelIdsFromStore, and skip muted channels
in the unread/mention loop. Decryption failure or absent blob falls back
to empty mutes set — no regression for users with no mutes.

Adds injectable decryptMutes parameter (mirrors existing decryptReadState
pattern) so the unit test can stub the crypto call without a real key.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 requested a review from a team as a code owner July 10, 2026 21:29
wpfleger96 added a commit that referenced this pull request Jul 10, 2026
…message

The rail unread dot was firing for threaded replies in threads the user
was not following, participating in, or mentioned in — the same events
the in-workspace sidebar dot correctly ignores.

Port shouldNotifyForEvent (the exact gate the sidebar catch-up uses in
useLiveChannelUpdates) into fetchWorkspaceUnread. Before the per-channel
loop, read the four by-pubkey localStorage sets — participated, followed,
authored, muted-roots — and pass them as the gate options. The channel-
level mute set computed by #1738 is also forwarded for redundant safety.

The follows set uses ThreadFollowEntry[] format (not the plain string[]
that makeRootIdStore reads) so it gets a thin dedicated reader. The four
sets are injectable via readThreadRelationships? so unit tests can supply
sets directly without touching window.localStorage.

Effect: threaded replies in untracked threads no longer light the rail
dot. Broadcast replies, #p-mentions, top-level posts, and replies in
followed/participated/authored threads still do — identical to the
in-workspace badge.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title fix(workspaces): exclude muted channels from rail unread badge fix(workspaces): exclude muted channels and unfollowed threads from rail unread badge Jul 10, 2026
…message

The rail unread dot was firing for threaded replies in threads the user
was not following, participating in, or mentioned in — the same events
the in-workspace sidebar dot correctly ignores.

Port shouldNotifyForEvent (the exact gate the sidebar catch-up uses in
useLiveChannelUpdates) into fetchWorkspaceUnread. Before the per-channel
loop, read the four by-pubkey localStorage sets — participated, followed,
authored, muted-roots — and pass them as the gate options. The channel-
level mute set computed by #1738 is also forwarded for redundant safety.

The follows set uses ThreadFollowEntry[] format (not the plain string[]
that makeRootIdStore reads) so it gets a thin dedicated reader. The four
sets are injectable via readThreadRelationships? so unit tests can supply
sets directly without touching window.localStorage.

Effect: threaded replies in untracked threads no longer light the rail
dot. Broadcast replies, #p-mentions, top-level posts, and replies in
followed/participated/authored threads still do — identical to the
in-workspace badge.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the duncan/rail-badge-skip-muted branch from 8381699 to 9eb3034 Compare July 10, 2026 22:08
@wpfleger96 wpfleger96 merged commit ea1c75b into main Jul 10, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/rail-badge-skip-muted branch July 10, 2026 22:22
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.

1 participant