Skip to content

feat(desktop): Slack-parity per-channel notification settings - #3233

Open
LordMelkor wants to merge 12 commits into
block:mainfrom
LordMelkor:feat/channel-notification-settings
Open

feat(desktop): Slack-parity per-channel notification settings#3233
LordMelkor wants to merge 12 commits into
block:mainfrom
LordMelkor:feat/channel-notification-settings

Conversation

@LordMelkor

@LordMelkor LordMelkor commented Jul 27, 2026

Copy link
Copy Markdown

Closes #3160

What this adds

Slack-parity per-channel notification settings for the desktop app — replacing the single binary mute with:

  • Notification level per channel: All new posts (default — today's exact behavior), Just mentions (channel still tracks unread, but ordinary posts stop alerting/bouncing), Mute and hide (muted and removed from the sidebar; resurfaces while active or when it holds an unread direct mention, and stays listed in the channel browser).
  • Temporary mute — "For 1 hour" / "Until tomorrow (9am)", stored as an absolute epoch and evaluated lazily, so it syncs across devices, survives restarts, and expires back to the prior level with no write. Reuses the reminders time-preset machinery.
  • Per-channel advanced toggles (channel sheet → Notifications): Desktop notifications, Follow every thread, Get broadcast messages (the per-channel opt-out for feat: channel-wide mentions — @channel and @here #3146's @channel/@here), plus an "Edit default preferences" link to Settings → Notifications.

Desktop-only; zero relay changes (kind 30078 is already generic). Mobile is a follow-up (schema reserves the field).

How it works

  • Storage (channel-notify-prefs, documented in docs/nips/NIP-CN.md): a new kind-30078 d-tag blob, NIP-44 self-encrypted, sparse per-channel entries with per-channel updatedAt LWW — a separate document from channel-mutes so older strict parsers can't strip the new fields on their next round-trip (feat(desktop): add sidebar categories and manual channel ordering #2947's rationale). The legacy mute blob is dual-written for the mute dimension, so existing clients (including mobile) keep working unchanged; reads resolve newer-updatedAt-wins between the two.
  • Decision ladder (notifyDecisionForEvent): the old boolean notify gate becomes a {unread, alert, highPriority} decision object, resolved through one normative precedence chain — DM bypass → direct @name mention (pierces everything, unchanged) → @channel/@here (gated by level + broadcasts opt-out) → top-level posts under the level gate → thread follows (follow-every-thread or participation; mute beats participation). Every consumer (live updates, unread aggregation, cross-community observer, Home badge, feed-driven desktop notifications) routes through it.
  • Hide applies only to a prefs-explicit "Mute and hide" — a legacy-only mute dims but never hides.

Behavior change flagged for review

NIP-CW ["broadcast","1"] thread replies no longer pierce channel mutes. Previously they short-circuited ahead of the mute check, contradicting the workspace rail (which already excludes muted channels) and #3146's mutes-suppress-broadcasts rule. They now behave as ordinary posts under the channel's level. Intentional; called out in NIP-CN.

Interaction with #3197 (@channel/@here)

The notify-tag gating here (eventNotifyMode) is inert until #3197 lands — no notify tags exist on the wire yet. The feed predicate mirrors #3197's feedItemSurvivesChannelMute ordering (a combined "direct mention + @channel" row pierces mutes via the direct-mention rung), so the two branches converge on the same semantics regardless of merge order.

Default-preservation

A user with no prefs blob gets byte-for-byte today's behavior: level "All new posts" adds no per-post OS banners (VISION.md: "Zero is the default"), badge counts and unread semantics are unchanged, and the rollout needs no migration.

Adversarial review

The branch went through an adversarial review (Codex gpt-5.6-sol, high effort, with KISS and SoC as named defect dimensions and 8 feature-specific probes), followed by independent verification of all 13 findings. Outcome: 8 confirmed → all fixed with regression tests; 1 refuted; 3 deferred by design (below). The confirmed fixes:

  • A prefs edit whose 2s publish debounce was cut short (community switch/reload) synced locally but never reached other devices — republish is now gated on content comparison, not a surviving timer.
  • Advanced-toggle/timed-mute writes re-stamped a stale mute level over a newer legacy (mobile) unmute — the legacy interop decision is now folded into the write seed via one shared rule.
  • An explicitly hidden channel could be resurfaced by a frozen @channel high-priority bit, or un-hidden by a legacy timestamp race — hiding now derives from both the explicit entry and the durable resolved level, and the mention escape hatch discriminates direct mentions.
  • Far-future updatedAt values are clamped at parse (an unclamped value would win LWW merges forever).
  • The cross-community observer now rejects foreign-authored prefs blobs (NIP-CN MUST-ignore), and @channel thread replies can't double-banner across the live and feed paths.

Deferred with rationale: notify-tag trust before relay member-gating exists (this PR should merge after #3197, whose relay ingest gate closes it); marker-only mention counts in the inactive-community rail (needs #3197's feed — follow-up); oversized-blob (65KB NIP-44) recovery (follow-up).

Test evidence

  • just ci green end-to-end post-rebase (fmt, clippy, biome, tsc, px-text, file-sizes, web, mobile, builds).
  • Desktop unit tests: 3,804 pass / 0 fail (~200 new across 12 suites: storage parse/merge/LWW, resolver matrix, ticker, ladder, labels, hidden-filter, sync manager, unread aggregation, observer, threading, feed predicate).
  • Playwright: new channel-notify-settings.spec.ts, rewritten channel-mute.spec.ts, plus a 31-test regression sweep of channel-controls/badge/unread/broadcast/star/sort — all green.
  • No cargo/relay surface touched; no Postgres-backed tests needed.

Two smoke specs fail on the dev machine with and without this diff (verified at the merge base): relay-reconnect.spec.ts:97 and video-attachment.spec.ts:223 — pre-existing, unrelated.

Screenshots

Posted in the first comment below (via scripts/post-screenshots.sh).

Out of scope / follow-ups

🤖 Generated with Claude Code

LordMelkor and others added 12 commits July 27, 2026 18:34
Phase 1 of Slack-parity per-channel notification settings (block#3160): the
synced storage layer and the single pure resolver every consumer will read.

- channelNotifyPrefsStorage: kind 30078 / d-tag `channel-notify-prefs`
  schema, tolerant parse that preserves unknown entry fields, per-channel
  max-updatedAt LWW merge (local wins ties), sparse entry assignment, and
  relay-scoped localStorage key.
- channelNotifyPrefsSync: NIP-44 self-encrypted sync manager on the
  channelSortSync template — 2s debounce, fetch-own-blob-merge before
  publish, all-field publish dedup, monotonic created_at, live subscribe,
  cancel-not-flush teardown (no cross-relay publish on community switch).
- resolveChannelNotifyState: pure, React-free resolution of level, timed
  mute overlay, advanced toggles, hide flag, plus legacy `channel-mutes`
  interop (newer updatedAt wins the mute dimension; legacy-only mutes
  never hide).
- timedMuteTicker: one module-level UI-refresh timer for timed-mute
  expiry, reset from resetCommunityState().
- useChannelNotifyPrefs: local-first hook (cross-tab storage events,
  (createdAt,eventId) watermark, reconnect resync, pending-local-edit
  hydration guard) exposing the mutations and a per-channel memoized
  resolved lookup. Legacy dual-write stays the caller's composition.

Unit tests cover the parse/merge/sparse matrix, the resolver matrix
(levels x timed mute x legacy interop), and the sync manager's
scheduling, filters, and teardown guards.

Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Signed-off-by: LordMelkor <kray@squareup.com>
Phase 2 of Slack-parity per-channel notification settings (block#3160): the one
precedence ladder every notification consumer will call.

- threading: eventNotifyMode(tags) reads the ["notify","channel"|"here"]
  marker (block#3146), kept strictly separate from isBroadcastReply.
- shouldNotify: notifyDecisionForEvent returns {unread, alert, highPriority}
  so a channel can record unread without alerting. Direct p-tag mentions
  pierce every level; @channel/@here is gated by the level and the
  broadcasts opt-out; top-level posts and NIP-CW broadcast replies sit under
  the level gate; thread replies need a follow (explicit, participation,
  authorship, or the channel's follow-every-thread) and lose to both the
  thread mute and the channel mute.
- NotifyOptions gains an injected channelPrefs lookup returning the phase-1
  resolved state; without it a channel-blind caller falls back to the legacy
  mutedChannelIds boolean, so the existing call sites keep working until
  phase 3 threads the lookup through.
- isHighPriorityEventForUser takes the same channel options and now agrees
  with the ladder: an @channel in an opted-out or muted channel, and a
  broadcast reply below level "all", are no longer mention tier.
- shouldNotifyForEvent stays as a transitional boolean view of the decision.

Behavior change, intentional: NIP-CW broadcast replies no longer
short-circuit ahead of the mute check, matching the rail observer, which
already skips muted channels.

The two precedence suites were extended in place — every level x event class,
the broadcasts toggle, timed-mute expiry via an injected clock,
followAllThreads, legacy channel-mutes interop, and notify-tag vs
broadcast-tag disambiguation.

Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Signed-off-by: LordMelkor <kray@squareup.com>
…ecision

Phase 3 of Slack-parity per-channel notification settings (block#3160): the
consumers now read the phase-1 resolver and the phase-2 ladder instead of a
boolean mute set.

- useLiveChannelUpdates forks on the decision: `unread` drives unread
  tracking (onChannelMessage / onThreadReplyNotification) while the new
  `onChannelAlert` carries the alert tier, so a channel at level "mentions"
  still marks unread but never bounces the dock. The
  onThreadReplyCandidate flow, the active-channel exemption, and the
  dedupe-before-eligibility ordering are unchanged.
- useUnreadChannels threads the prefs lookup through the live path, the
  catch-up scan, and mention-tier classification. The aggregation moved to
  a pure `aggregateUnreadChannels` and applies the CURRENTLY resolved
  level: a muted channel contributes nothing but its mention-tier items
  (mirroring the sidebar escape hatch), which keeps level changes from
  re-tiering or re-notifying frozen ObservedUnreadEvent records.
- The home badge and the feed-driven desktop banners replace their
  unconditional `category === "mention"` mute escape with
  `allowsFeedItemForChannel`: notify-tag items (@channel/@here) obey the
  level and the broadcasts opt-out, direct mentions still pierce the mute.
  The banner site additionally honors per-channel `desktop: false`, which
  also gates all three useAppShellDesktopNotifications handlers (banner,
  sound, dock bounce) without touching unread state.
- communityUnreadObserver fetches and decrypts the prefs blob alongside
  channel-mutes and projects both through the same pure resolver. A muted
  channel no longer short-circuits the whole channel: it loses the dot but
  its mentions still count toward the rail's mentionCount.
- AppShell owns the composition through useChannelNotificationSettings,
  which folds the two blobs together, performs the legacy channel-mutes
  dual-write on level changes, and exports the effective-mute set that
  boolean consumers (sidebar glyphs, keyboard nav, thread activity) still
  read. The resolver and mutations reach the phase-4 UI via AppShellContext.

`shouldNotifyForEvent` is gone — every call site now reads the decision
object directly.

Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Signed-off-by: LordMelkor <kray@squareup.com>
Phase 4 of Slack-parity per-channel notification settings (block#3160): the
surfaces users actually touch, all reading the phase-1 resolver.

- ChannelContextMenu: the binary Mute/Unmute pair becomes a "Notifications"
  submenu for channels — a radio group over the three levels, the two
  timed-mute presets ("Mute for 1 hour" / "Mute until tomorrow", reusing
  timePresets), a "Muted until <time>" caption plus Unmute while a timed mute
  runs, and "Notification preferences..." opening the channel sheet. DMs keep
  today's reduced binary menu (no levels for DMs in v1). The mute handlers
  stay the presence gate for the whole block.
- ChannelManagementSheet: a member-scoped (NOT admin-gated) Notifications
  section — level radio rows, the timed-mute row, and the Desktop
  notifications / Follow every thread / Get broadcast messages toggles, plus
  an "Edit default preferences" link into Settings -> Notifications. The two
  new row primitives live beside the sheet's other rows.
- Channel header: getChannelDescription takes the resolved state and appends
  "Notifications: Just mentions" / "Notifications: Muted" when non-default.
- Sidebar: hidden channels ("Mute and hide", i.e. an explicit prefs level of
  "mute" — never legacy-only or timed mutes) drop out of the stream, section,
  starred, and forum lists through one pure filterHiddenChannels, with
  Slack's two escape hatches (active channel, mention-tier unread) rendering
  the row in the existing muted styling. Search, the quick switcher, and the
  channel browser keep reading the unfiltered channel set.

The sidebar now reads the notification surface from AppShellContext instead of
five props, which is also how the context menu reaches it; the shell exports
the mention-tier set for the escape hatch. The channel-list derivation moved
out of AppSidebar into useSidebarChannelGroups so the component only renders
(and stays under the file-size guard). The resolver gained `muteUntil` so the
"Muted until" captions have a single source.

Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Signed-off-by: LordMelkor <kray@squareup.com>
Document per-channel notification preferences as NIP-CN: the kind-30078
`channel-notify-prefs` schema (with the reserved `mobile` field), the
per-channel LWW merge and sparse-entry rule, the normative precedence
ladder, legacy `channel-mutes` interop, timed-mute lazy expiry, and the
Non-Goals (push-lease projection, per-post banners, synced defaults,
per-installation settings, admin policy).

Teach the E2E bridge to store and replay kind-30078 blobs for the new
d-tag so the sync manager round-trips instead of always seeing an empty
relay, add a screenshot spec for the six notification surfaces, and move
the two channel-mute specs that asserted the removed binary Mute/Unmute
items onto the Notifications submenu. Unit-test the `eventNotifyMode`
tag reader and the timed-mute refresh ticker.

Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Signed-off-by: LordMelkor <kray@squareup.com>
…n the feed predicate

`allowsFeedItemForChannel` checked `eventNotifyMode(tags)` before looking for a
direct `p`-tag mention of the reader, so a feed item carrying both
`["notify","channel"]` and a `p`-tag of self was suppressed from the Home badge
and feed banners whenever the channel resolved to level "mute" or had opted out
of broadcasts. The NIP-CN precedence ladder makes a direct mention row 2 — it
pierces every level — and `notifyDecisionForEvent` /
`isHighPriorityEventForUser` already order it first; only this FeedItem-side
predicate was wrong.

Extract a tags-only `tagsMentionPubkey` helper (one discrimination point,
reused by `hasMentionForEvent`), add a leading direct-mention escape to
`allowsFeedItemForChannel`, and thread the normalized reader pubkey through
both call sites (Home badge loop and the feed desktop-notification gate).

This mirrors the `feedItemSurvivesChannelMute` ordering in block#3197,
which fixes the same defect shape on the mute-set side of the same seam
(codex finding relayed cross-session). NIP-CN's no-event-graph note now spells
out the ordering explicitly so the two seams cannot drift apart again.

Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Signed-off-by: LordMelkor <kray@squareup.com>
… interop

Two confirmed adversarial-review findings in the `channel-mutes` interop:

F3: `updateEntry` seeded from the raw prefs entry and stamped a fresh
`updatedAt`, so any advanced-toggle or timed-mute write flipped the N2
tie-break and silently resurrected a level the legacy blob had already
overruled — e.g. a mobile unmute followed by turning "Desktop
notifications" off re-muted AND hid the channel. Extract the interop
decision as `foldLegacyMuteDecision` (one rule, used by both the resolver
and the write seed) and fold it into the entry a mutation seeds from.

F7: `hidden` was cleared unconditionally inside the legacy-wins branch,
so a newer legacy `muted:true` silently downgraded "Mute and hide" to
plain mute and the channel reappeared in the sidebar. `hidden` is now
derived once from both terms: the entry explicitly says "mute" AND the
durable (pre-timed-mute-overlay) level resolves to "mute".

F13: NIP-CN's `hidden` iff-rule contradicted its own interop rule (and
the branch's own tests). Restate it as the two-term rule above, and
record the deliberate consequence of folding on write: a newer legacy
mute materializes an explicit `level:"mute"`, so "a legacy-only mute
never hides" holds only until the user next edits that channel here.
Keeping both dimensions independent past that point needs a stored
`hidden` or a per-dimension timestamp — out of scope.

Findings: F3, F7, F13
Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: LordMelkor <kray@squareup.com>
… blob

Two confirmed adversarial-review findings in the `channel-notify-prefs` sync
path, both of which strand an edit in localStorage while every other device
keeps the stale value.

F2: `applyRemote` republished the merged store only when a debounced publish
was still outstanding on the *same* manager instance. Any edit whose 2 s
debounce window was cut short lost its publish outright — `destroy()` (run on
community switch, sign-out, unmount, reload/quit) cancels the timer and nulls
`pendingStore`, and the fresh manager built on return has nothing to rescue.
The merge still kept the edit locally, so the UI looked correct while the
remote blob and every other device stayed stale indefinitely, until some
unrelated edit to that community happened to republish the whole store. Gate
the republish on a content comparison (`storesEqual(merged, remote.store)`)
instead. That subsumes the original block#2947 hydration guard — a still-debounced
edit is by definition absent from the remote blob — and terminates once our own
republished blob comes back.

F5: `updatedAt` was accepted verbatim, and `mergeStores` is a pure max-timestamp
comparison with no clock reference, so a single device with a badly wrong clock
pinned a channel's entry permanently: every correctly-clocked edit lost the
merge, was overwritten by the remote blob, and the level silently reverted. A
legacy `channel-mutes` unmute could not win either (the N2 tie-break is also a
timestamp comparison), freezing the channel's mute state on every client. Clamp
`updatedAt` to `now + 1h` at the single parse choke point, which covers both the
localStorage mirror and the decrypted remote blob and keeps `mergeStores`
clock-free. `muteUntil` is deliberately not clamped — it is an absolute future
timestamp by design.

NIP-CN's normative text is updated for both: the republish rule is restated as
a content comparison (with the reason not to use a pending-publish check), and
the clamp is specified as a MUST on parse.

Also records the deferred F9 seam as a comment: the pre-encrypt size of this
blob is unbounded and a NIP-44 overflow only `console.warn`s. Sparse entries
keep the ceiling at several hundred customized channels, and the same gap ships
today in all four sibling kind-30078 sidebar blobs, so a shared budget is
tracked separately rather than added here for one caller.

Findings: F2, F5 (F9 noted, deferred)
Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: LordMelkor <kray@squareup.com>
…Priority

F6: `ObservedUnreadEvent` freezes one `highPriority` boolean, decided by the
NIP-CN ladder under the level in force when the event arrived. The aggregation
then reused that frozen flag as the muted channel's mention tier, so a record
earned at level "all" kept badging the channel after the user muted it — and
`highPriorityUnreadChannelIds` feeds the sidebar's hidden-channel escape hatch,
so "Mute and hide" left the channel visible with a numeric badge.

Live today via NIP-CW broadcast replies, which are `highPriority` at level "all"
(unchanged and intentional): a broadcast reply arrives in #engineering, the user
then picks "Mute and hide", and the channel stays in the sidebar with a count.
The same applies to the `broadcasts: false` opt-out, and will apply to
`["notify","channel"]` markers.

Record a state-independent `directMention` alongside `highPriority` (from the
already-exported `hasMentionForEvent`) and tier the muted branch on that: the
`continue` guard, the per-channel count, the app-badge contribution, and the
high-priority set. Direct `p`-tag mentions pierce every level, so their frozen
value stays correct across later level changes — no re-resolution machinery. The
reverse direction (an event observed while muted keeps `highPriority: false`
after an unmute) is left alone: the spec's "never re-notify on a level change"
stance covers it.

NIP-CN's level table and hiding escape hatches are tightened to say "direct
`p`-tag mention" explicitly, with the reason a client must not reuse a recorded
mention-tier classification there.

Findings: F6
Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: LordMelkor <kray@squareup.com>
…rver

F11: `fetchCommunityUnread` accepted `notifyPrefsEvents[0]` with no author
check, while `ChannelNotifyPrefsSyncManager` rejects the same document twice for
the same reason, and NIP-CN states it normatively ("Clients MUST ignore any
returned event whose `pubkey` is not the author's own").

Narrow but non-empty: the REQ carries `authors: [pubkey]` and the payload is
NIP-44 self-encrypted, so a third party cannot forge decryptable content. The
gap needs a hostile or buggy relay that ignores the filter *and* replays one of
the user's own older ciphertexts inside an event signed by another key — the
ciphertext decrypts, parses, and rolls the prefs back, e.g. a stale level "mute"
silencing the community rail dot and mention count. Defense-in-depth plus a
documented-MUST conformance gap.

One-line guard mirroring the sync manager. The sibling legacy `channel-mutes`
block above has the same shape; it is pre-existing on main and left untouched
rather than widening this branch's scope.

Findings: F11
Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: LordMelkor <kray@squareup.com>
F4: `handleThreadReplyDesktopNotification` suppresses replies that directly
`p`-mention the user, because the Home-feed mention path delivers those. It does
not recognize the other mention-tier source — a NIP-CM `["notify","channel"]` /
`["notify","here"]` marker, which carries no `p` tag. Once the relay persists
`@channel` events as feed mentions, a marker-carrying thread reply banners twice
for one event id: live via the ladder's marker row, then again within a poll
interval via the mentions feed, from two dedupe sets that cannot see each other.

Add `feedOwnsThreadReplyNotification` beside `shouldBounceForChannelNotification`
(existing module, existing test file) and skip on it. It composes
`eventNotifyMode` with the already-exported `allowsFeedItemForChannel` rather
than re-deriving the ladder, so the suppression is conditioned on the feed
actually accepting the item: a marker in a `broadcasts: false` or muted channel
is dropped by the feed and keeps its single live banner.

Two caveats for the PR body: `@here` is not persisted to the feed, which is why
this must stay gated on the feed predicate rather than on the marker alone; and
whichever of this branch / the `@channel` branch merges second owns the merge of
this guard and the ladder ordering.

Also records the deferred F1 seam at the ladder's marker row: the `notify` tag is
relay-validated at ingest (membership plus a kind allowlist), not verified
client-side, and this client re-reads the tag at notify time — so an event stored
by a relay predating that validation stays trusted. No client membership check is
added; relay/client deploy ordering is a PR-body note.

Findings: F4 (F1 noted, deferred)
Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: LordMelkor <kray@squareup.com>
No semantic change — reflow only, keeping the new normative paragraphs on the file's 80-column prose convention.

Signed-off-by: LordMelkor <kray@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: LordMelkor <kray@squareup.com>
@LordMelkor
LordMelkor requested a review from a team as a code owner July 27, 2026 22:47
LordMelkor added a commit to LordMelkor/buzz that referenced this pull request Jul 27, 2026
@LordMelkor

LordMelkor commented Jul 27, 2026

Copy link
Copy Markdown
Author

🤖 Posted by kray's AI agent.

Screenshots

Notifications submenu

Right-click a channel → Notifications: the three levels (All new posts / Just mentions / Mute and hide) plus the temporary-mute presets.

01-notifications-submenu

Just mentions

A channel on Just mentions keeps tracking unread (name stays bold) but ordinary posts no longer alert or bounce the dock.

02-just-mentions

Mute and hide

Mute and hide removes the channel from the sidebar entirely (it stays listed in the channel browser).

03-mute-and-hide

Hidden channel resurfaces on a direct mention

An unread direct @-mention pierces hiding — the channel reappears with its mention badge until the mention is read.

04-hidden-channel-mention

Temporary mute

A timed mute ("For 1 hour" / "Until tomorrow") shows its state in the submenu; it expires back to the prior level with no write and syncs across devices.

05-timed-mute

Channel sheet — Notifications section

The channel sheet gains per-channel advanced toggles: Desktop notifications, Follow every thread, Get broadcast messages, and a link to the global defaults.

06-sheet-notifications-section

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.

feat: Slack-parity per-channel notification settings — levels, temporary mute, mute-and-hide, broadcast opt-out

1 participant