diff --git a/AGENTS.md b/AGENTS.md
index 385ce0933e..e67c85d8b6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -321,6 +321,48 @@ Right-click shows "Mark as read".
 Re-runs for the same PR overwrite previous images. Cleanup:
 `git push origin --delete agent-screenshots/<username>`.
 
+### Writing E2E Screenshot Specs
+
+When screenshots need seeded state, live messages, or UI interaction before
+capture, write a Playwright spec instead of using `just desktop-screenshot`.
+Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`
+(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for
+mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.
+
+**Stale server:** `reuseExistingServer: true` means a previous build's server
+serves old code. Kill port 4173 and `pnpm run build` before re-running tests
+after code changes.
+
+**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)
+must run BEFORE `installMockBridge(page)` — React reads state on mount, the
+bridge triggers mount.
+
+**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before
+`__SPROUT_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a
+subscription. Navigate to the channel first (triggers subscription), then away
+(so unread indicators appear), then inject.
+
+**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
+resolves mid-animation — wait for completion before screenshotting:
+
+```ts
+await menuItem.evaluate((el) =>
+  Promise.all(
+    el.closest("[data-state]")?.getAnimations().map((a) => a.finished) ?? [],
+  ),
+);
+```
+
+**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
+for sidebar features. Sidebar = 256px; context menus ~450px.
+
+**`general` has pre-seeded messages** making `hasUnread` always true. Use
+`engineering` for "muted + no unread" visual states.
+
+**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with
+`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line
+description. See [PR #803](https://github.com/block/sprout/pull/803).
+
 ---
 
 ## Common Gotchas
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 41084cab31..ce8b908769 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -26,6 +26,7 @@ export default defineConfig({
         "**/messaging.spec.ts",
         "**/custom-emoji.spec.ts",
         "**/custom-emoji-screenshots.spec.ts",
+        "**/channel-mute-screenshots.spec.ts",
         "**/file-attachment.spec.ts",
         "**/mentions.spec.ts",
         "**/relay-reconnect.spec.ts",
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 07211b698d..291977bca1 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -56,6 +56,7 @@ import {
 import { HuddleBar, HuddleProvider } from "@/features/huddle";
 import { useMeshRelayOrchestrator } from "@/features/mesh-compute/hooks/useMeshRelayOrchestrator";
 import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
+import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
 import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
 import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
 import { relayClient } from "@/shared/api/relayClient";
@@ -204,6 +205,9 @@ export function AppShell() {
 
   const identityQuery = useIdentityQuery();
   useMeshRelayOrchestrator(identityQuery.data?.pubkey);
+  const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes(
+    identityQuery.data?.pubkey,
+  );
   const profileQuery = useProfileQuery();
   const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
   usePresenceSubscription();
@@ -219,10 +223,12 @@ export function AppShell() {
   const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => {
     void homeFeedQuery.refetch();
   });
-  const handleChannelNotification = React.useEffectEvent(() => {
-    if (!notificationSettings.settings.desktopEnabled) return;
-    void requestDockBounce();
-  });
+  const handleChannelNotification = React.useEffectEvent(
+    (_channelId: string, _event: RelayEvent) => {
+      if (!notificationSettings.settings.desktopEnabled) return;
+      void requestDockBounce();
+    },
+  );
 
   const handleDmNotification = React.useEffectEvent(
     (event: RelayEvent, channel: Channel) => {
@@ -312,6 +318,7 @@ export function AppShell() {
       pubkey: identityQuery.data?.pubkey,
       relayClient,
       currentPubkey: identityQuery.data?.pubkey,
+      mutedChannelIds,
       onChannelMessage: handleChannelNotification,
       onDmMessage: handleDmNotification,
       onLiveMention: refetchHomeFeedOnLiveMention,
@@ -335,6 +342,7 @@ export function AppShell() {
       readStateVersion,
       highPriorityUnreadChannelIds,
       feedProfilesQuery.data?.profiles,
+      mutedChannelIds,
     );
 
   const isNotifiedForThread = React.useCallback(
@@ -807,6 +815,9 @@ export function AppShell() {
                   selectedChannelId={selectedChannelId}
                   selectedView={selectedView}
                   unreadChannelIds={unreadChannelIds}
+                  mutedChannelIds={mutedChannelIds}
+                  onMuteChannel={muteChannel}
+                  onUnmuteChannel={unmuteChannel}
                 />
 
                 <SidebarInset className="min-h-0 min-w-0 overflow-hidden">
diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts
index aef2ab713e..7d1cb6ce30 100644
--- a/desktop/src/features/channels/useLiveChannelUpdates.ts
+++ b/desktop/src/features/channels/useLiveChannelUpdates.ts
@@ -34,6 +34,7 @@ export type UseLiveChannelUpdatesOptions = {
   followedRootIds?: ReadonlySet<string>;
   authoredRootIds?: ReadonlySet<string>;
   mutedRootIds?: ReadonlySet<string>;
+  mutedChannelIds?: ReadonlySet<string>;
 };
 
 const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000;
@@ -175,14 +176,14 @@ export function useLiveChannelUpdates(
       UNREAD_TRIGGER_KINDS.has(event.kind) &&
       (normalizedCurrentPubkey.length === 0 ||
         event.pubkey.toLowerCase() !== normalizedCurrentPubkey) &&
-      shouldNotifyForEvent(
-        event,
-        normalizedCurrentPubkey,
-        options.participatedRootIds ?? EMPTY_SET,
-        options.followedRootIds ?? EMPTY_SET,
-        options.authoredRootIds ?? EMPTY_SET,
-        options.mutedRootIds ?? EMPTY_SET,
-      )
+      shouldNotifyForEvent(event, normalizedCurrentPubkey, {
+        participatedRootIds: options.participatedRootIds ?? EMPTY_SET,
+        followedRootIds: options.followedRootIds ?? EMPTY_SET,
+        authoredRootIds: options.authoredRootIds ?? EMPTY_SET,
+        mutedRootIds: options.mutedRootIds ?? EMPTY_SET,
+        mutedChannelIds: options.mutedChannelIds ?? EMPTY_SET,
+        channelId,
+      })
     ) {
       options.onChannelMessage?.(channelId, event);
       if (channelId !== activeChannelId) {
diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts
index 1ad372f5cc..faa34080b8 100644
--- a/desktop/src/features/channels/useUnreadChannels.ts
+++ b/desktop/src/features/channels/useUnreadChannels.ts
@@ -10,8 +10,8 @@ import {
   isBroadcastReply,
 } from "@/features/messages/lib/threading";
 import {
-  shouldNotifyForEvent,
   isHighPriorityEventForUser,
+  shouldNotifyForEvent,
 } from "@/features/notifications/lib/shouldNotify";
 import type { RelayClient } from "@/shared/api/relayClientSession";
 import type { Channel, RelayEvent } from "@/shared/api/types";
@@ -20,6 +20,7 @@ import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
 type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
   pubkey?: string;
   relayClient?: RelayClient;
+  mutedChannelIds?: ReadonlySet<string>;
 };
 
 // Per-channel cap on the catch-up REQ. We only consume the *max matching*
@@ -226,7 +227,12 @@ export function useUnreadChannels(
   activeReadAt?: string | null,
   options: UseUnreadChannelsOptions = {},
 ) {
-  const { pubkey, relayClient, ...liveUpdateOptions } = options;
+  const {
+    pubkey,
+    relayClient,
+    mutedChannelIds: mutedChannelIdsOption,
+    ...liveUpdateOptions
+  } = options;
   const activeChannelId = activeChannel?.id ?? null;
   const activeChannelLastMessageAt = activeChannel?.lastMessageAt ?? null;
   const normalizedPubkey = pubkey?.toLowerCase() ?? null;
@@ -278,6 +284,11 @@ export function useUnreadChannels(
   // over participation, follow, and authorship for notification suppression.
   const mutedRootIdsRef = React.useRef(new Set<string>());
 
+  // Stable ref for the caller-supplied muted channel IDs. Updated every render
+  // so the catch-up loop always reads the latest set without being a dep.
+  const mutedChannelIdsRef = React.useRef<ReadonlySet<string>>(new Set());
+  mutedChannelIdsRef.current = mutedChannelIdsOption ?? new Set();
+
   // Thread reply events that triggered notifications — surfaced in the Home
   // activity feed as synthetic FeedItems.
   const threadActivityRef = React.useRef<ThreadActivityItem[]>([]);
@@ -496,6 +507,7 @@ export function useUnreadChannels(
     followedRootIds: liveUpdateOptions.followedRootIds,
     authoredRootIds: authoredRootIdsRef.current,
     mutedRootIds: mutedRootIdsRef.current,
+    mutedChannelIds: mutedChannelIdsRef.current,
   });
 
   // Effect-key the catch-up on the *set* of channel IDs, not the array
@@ -602,15 +614,17 @@ export function useUnreadChannels(
               continue;
             }
             if (readAt !== null && event.created_at <= readAt) continue;
+            const eventChannelId =
+              event.tags.find((t) => t[0] === "h")?.[1] ?? null;
             if (
-              !shouldNotifyForEvent(
-                event,
-                normalizedPubkey ?? "",
-                participatedRootIdsRef.current,
-                options.followedRootIds ?? EMPTY_SET,
-                authoredRootIdsRef.current,
-                mutedRootIdsRef.current,
-              )
+              !shouldNotifyForEvent(event, normalizedPubkey ?? "", {
+                participatedRootIds: participatedRootIdsRef.current,
+                followedRootIds: options.followedRootIds ?? EMPTY_SET,
+                authoredRootIds: authoredRootIdsRef.current,
+                mutedRootIds: mutedRootIdsRef.current,
+                mutedChannelIds: mutedChannelIdsRef.current,
+                channelId: eventChannelId,
+              })
             ) {
               continue;
             }
diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts
index ecbf5aaf10..d0ab21ee80 100644
--- a/desktop/src/features/notifications/hooks.ts
+++ b/desktop/src/features/notifications/hooks.ts
@@ -270,6 +270,7 @@ export function useHomeFeedNotificationState(
   readStateVersion: number,
   highPriorityChannelIds: ReadonlySet<string>,
   profiles?: UserProfileLookup,
+  mutedChannelIds?: ReadonlySet<string>,
 ) {
   useFeedDesktopNotifications(
     feed,
@@ -277,6 +278,7 @@ export function useHomeFeedNotificationState(
     settings,
     setDesktopEnabled,
     profiles,
+    mutedChannelIds,
   );
   const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
   const [seenFeedIds, setSeenFeedIds] = React.useState<string[]>(() =>
@@ -327,6 +329,13 @@ export function useHomeFeedNotificationState(
     let total = 0;
     let excludingHighPriority = 0;
     for (const item of currentFeedItems) {
+      if (
+        item.channelId &&
+        mutedChannelIds?.has(item.channelId) &&
+        item.category !== "mention"
+      ) {
+        continue;
+      }
       let isUnread: boolean;
       if (item.channelId) {
         const readAt = getChannelReadAt(item.channelId);
@@ -352,6 +361,7 @@ export function useHomeFeedNotificationState(
     getChannelReadAt,
     highPriorityChannelIds,
     isHomeActive,
+    mutedChannelIds,
     readStateVersion,
     seenFeedIds,
     settings.homeBadgeEnabled,
diff --git a/desktop/src/features/notifications/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs
index 82f4bf054f..57e8e378db 100644
--- a/desktop/src/features/notifications/lib/shouldNotify.test.mjs
+++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs
@@ -34,24 +34,22 @@ const replyTag = (id) => ["e", id, "", "reply"];
 const pTag = (pubkey) => ["p", pubkey];
 const broadcastTag = () => ["broadcast", "1"];
 
+const opts = (overrides = {}) => ({
+  participatedRootIds: EMPTY,
+  followedRootIds: EMPTY,
+  authoredRootIds: EMPTY,
+  ...overrides,
+});
+
 // ── 1. Top-level message → always true ──────────────────────────────────────
 
 test("top-level message (no e-tags) notifies", () => {
-  assert.equal(
-    shouldNotifyForEvent(makeEvent([]), PUBKEY, EMPTY, EMPTY, EMPTY),
-    true,
-  );
+  assert.equal(shouldNotifyForEvent(makeEvent([]), PUBKEY, opts()), true);
 });
 
 test("top-level message with unrelated p-tag notifies", () => {
   assert.equal(
-    shouldNotifyForEvent(
-      makeEvent([pTag(OTHER_PUBKEY)]),
-      PUBKEY,
-      EMPTY,
-      EMPTY,
-      EMPTY,
-    ),
+    shouldNotifyForEvent(makeEvent([pTag(OTHER_PUBKEY)]), PUBKEY, opts()),
     true,
   );
 });
@@ -60,7 +58,7 @@ test("top-level message with unrelated p-tag notifies", () => {
 
 test("broadcast reply to unrelated thread notifies", () => {
   const event = makeEvent([replyTag(ROOT_ID), broadcastTag()]);
-  assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
+  assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), true);
 });
 
 test("broadcast reply with root+reply tags notifies", () => {
@@ -69,7 +67,7 @@ test("broadcast reply with root+reply tags notifies", () => {
     replyTag(PARENT_ID),
     broadcastTag(),
   ]);
-  assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
+  assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), true);
 });
 
 // ── 3. Thread reply with p-tag mention → always true ─────────────────────────
@@ -80,12 +78,12 @@ test("thread reply with p-tag mention of currentPubkey notifies", () => {
     replyTag(PARENT_ID),
     pTag(PUBKEY),
   ]);
-  assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
+  assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), true);
 });
 
 test("p-tag mention matching is case-insensitive", () => {
   const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY.toUpperCase())]);
-  assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), true);
+  assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), true);
 });
 
 test("p-tag mention of a different pubkey does not trigger mention path", () => {
@@ -94,7 +92,7 @@ test("p-tag mention of a different pubkey does not trigger mention path", () =>
     replyTag(PARENT_ID),
     pTag(OTHER_PUBKEY),
   ]);
-  assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false);
+  assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), false);
 });
 
 // ── 4. Thread reply to participated thread → true ────────────────────────────
@@ -102,16 +100,23 @@ test("p-tag mention of a different pubkey does not trigger mention path", () =>
 test("thread reply to participated thread notifies", () => {
   const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
   assert.equal(
-    shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY),
+    shouldNotifyForEvent(
+      event,
+      PUBKEY,
+      opts({ participatedRootIds: new Set([ROOT_ID]) }),
+    ),
     true,
   );
 });
 
 test("shallow thread reply (root===parent) to participated thread notifies", () => {
-  // Single reply tag: rootId falls back to parentId == ROOT_ID
   const event = makeEvent([replyTag(ROOT_ID)]);
   assert.equal(
-    shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY),
+    shouldNotifyForEvent(
+      event,
+      PUBKEY,
+      opts({ participatedRootIds: new Set([ROOT_ID]) }),
+    ),
     true,
   );
 });
@@ -121,7 +126,11 @@ test("shallow thread reply (root===parent) to participated thread notifies", ()
 test("thread reply to followed thread notifies", () => {
   const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
   assert.equal(
-    shouldNotifyForEvent(event, PUBKEY, EMPTY, new Set([ROOT_ID]), EMPTY),
+    shouldNotifyForEvent(
+      event,
+      PUBKEY,
+      opts({ followedRootIds: new Set([ROOT_ID]) }),
+    ),
     true,
   );
 });
@@ -131,7 +140,11 @@ test("thread reply to followed thread notifies", () => {
 test("thread reply to authored thread notifies", () => {
   const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
   assert.equal(
-    shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, new Set([ROOT_ID])),
+    shouldNotifyForEvent(
+      event,
+      PUBKEY,
+      opts({ authoredRootIds: new Set([ROOT_ID]) }),
+    ),
     true,
   );
 });
@@ -140,7 +153,7 @@ test("thread reply to authored thread notifies", () => {
 
 test("thread reply to unrelated thread does not notify", () => {
   const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
-  assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false);
+  assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), false);
 });
 
 // ── 8. Muted thread suppresses participated ───────────────────────────────────
@@ -151,10 +164,10 @@ test("muted thread reply suppresses participated", () => {
     shouldNotifyForEvent(
       event,
       PUBKEY,
-      new Set([ROOT_ID]),
-      EMPTY,
-      EMPTY,
-      new Set([ROOT_ID]),
+      opts({
+        participatedRootIds: new Set([ROOT_ID]),
+        mutedRootIds: new Set([ROOT_ID]),
+      }),
     ),
     false,
   );
@@ -168,10 +181,10 @@ test("muted thread reply suppresses followed", () => {
     shouldNotifyForEvent(
       event,
       PUBKEY,
-      EMPTY,
-      new Set([ROOT_ID]),
-      EMPTY,
-      new Set([ROOT_ID]),
+      opts({
+        followedRootIds: new Set([ROOT_ID]),
+        mutedRootIds: new Set([ROOT_ID]),
+      }),
     ),
     false,
   );
@@ -185,10 +198,10 @@ test("muted thread reply suppresses authored", () => {
     shouldNotifyForEvent(
       event,
       PUBKEY,
-      EMPTY,
-      EMPTY,
-      new Set([ROOT_ID]),
-      new Set([ROOT_ID]),
+      opts({
+        authoredRootIds: new Set([ROOT_ID]),
+        mutedRootIds: new Set([ROOT_ID]),
+      }),
     ),
     false,
   );
@@ -197,7 +210,6 @@ test("muted thread reply suppresses authored", () => {
 // ── 11. Muted thread with p-tag mention still notifies ───────────────────────
 
 test("muted thread reply still notifies when currentPubkey is mentioned via p-tag", () => {
-  // p-tag check fires BEFORE the mute gate → mention always wins
   const event = makeEvent([
     rootTag(ROOT_ID),
     replyTag(PARENT_ID),
@@ -207,10 +219,7 @@ test("muted thread reply still notifies when currentPubkey is mentioned via p-ta
     shouldNotifyForEvent(
       event,
       PUBKEY,
-      EMPTY,
-      EMPTY,
-      EMPTY,
-      new Set([ROOT_ID]),
+      opts({ mutedRootIds: new Set([ROOT_ID]) }),
     ),
     true,
   );
@@ -219,16 +228,12 @@ test("muted thread reply still notifies when currentPubkey is mentioned via p-ta
 // ── 12. Muted top-level message still notifies ───────────────────────────────
 
 test("muted rootId does not suppress a top-level (non-reply) message", () => {
-  // parentId is null for top-level → function returns true before reaching mute check
   const event = makeEvent([]);
   assert.equal(
     shouldNotifyForEvent(
       event,
       PUBKEY,
-      EMPTY,
-      EMPTY,
-      EMPTY,
-      new Set([ROOT_ID]),
+      opts({ mutedRootIds: new Set([ROOT_ID]) }),
     ),
     true,
   );
@@ -238,16 +243,19 @@ test("muted rootId does not suppress a top-level (non-reply) message", () => {
 
 test("omitting mutedRootIds parameter defaults to empty set and still notifies participated", () => {
   const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
-  // No sixth argument — should not throw and should notify for participated thread
   assert.equal(
-    shouldNotifyForEvent(event, PUBKEY, new Set([ROOT_ID]), EMPTY, EMPTY),
+    shouldNotifyForEvent(
+      event,
+      PUBKEY,
+      opts({ participatedRootIds: new Set([ROOT_ID]) }),
+    ),
     true,
   );
 });
 
 test("omitting mutedRootIds for unrelated thread returns false without throwing", () => {
   const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]);
-  assert.equal(shouldNotifyForEvent(event, PUBKEY, EMPTY, EMPTY, EMPTY), false);
+  assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), false);
 });
 
 // ── 14. Muted shallow thread reply (single replyTag, no rootTag) ────────────
@@ -258,10 +266,10 @@ test("muted shallow thread reply (rootId falls back to parentId) is suppressed",
     shouldNotifyForEvent(
       event,
       PUBKEY,
-      new Set([ROOT_ID]),
-      EMPTY,
-      EMPTY,
-      new Set([ROOT_ID]),
+      opts({
+        participatedRootIds: new Set([ROOT_ID]),
+        mutedRootIds: new Set([ROOT_ID]),
+      }),
     ),
     false,
   );
@@ -279,10 +287,7 @@ test("broadcast reply on a muted thread still notifies (broadcast overrides mute
     shouldNotifyForEvent(
       event,
       PUBKEY,
-      EMPTY,
-      EMPTY,
-      EMPTY,
-      new Set([ROOT_ID]),
+      opts({ mutedRootIds: new Set([ROOT_ID]) }),
     ),
     true,
   );
@@ -300,10 +305,10 @@ test("empty currentPubkey skips p-tag check — muted thread is suppressed", ()
     shouldNotifyForEvent(
       event,
       "",
-      new Set([ROOT_ID]),
-      EMPTY,
-      EMPTY,
-      new Set([ROOT_ID]),
+      opts({
+        participatedRootIds: new Set([ROOT_ID]),
+        mutedRootIds: new Set([ROOT_ID]),
+      }),
     ),
     false,
   );
@@ -316,7 +321,11 @@ test("empty currentPubkey with participated thread still notifies (no mute)", ()
     pTag(PUBKEY),
   ]);
   assert.equal(
-    shouldNotifyForEvent(event, "", new Set([ROOT_ID]), EMPTY, EMPTY),
+    shouldNotifyForEvent(
+      event,
+      "",
+      opts({ participatedRootIds: new Set([ROOT_ID]) }),
+    ),
     true,
   );
 });
diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts
index 84355c1cd6..9ceb9c2df8 100644
--- a/desktop/src/features/notifications/lib/shouldNotify.ts
+++ b/desktop/src/features/notifications/lib/shouldNotify.ts
@@ -4,30 +4,55 @@ import {
   isBroadcastReply,
 } from "@/features/messages/lib/threading";
 
+export function hasMentionForEvent(
+  event: RelayEvent,
+  currentPubkey: string,
+): boolean {
+  return (
+    currentPubkey.length > 0 &&
+    event.tags.some(
+      (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey,
+    )
+  );
+}
+
+export type NotifyOptions = {
+  participatedRootIds: ReadonlySet<string>;
+  followedRootIds: ReadonlySet<string>;
+  authoredRootIds: ReadonlySet<string>;
+  mutedRootIds?: ReadonlySet<string>;
+  mutedChannelIds?: ReadonlySet<string>;
+  channelId?: string | null;
+};
+
 export function shouldNotifyForEvent(
   event: RelayEvent,
   currentPubkey: string,
-  participatedRootIds: ReadonlySet<string>,
-  followedRootIds: ReadonlySet<string>,
-  authoredRootIds: ReadonlySet<string>,
-  mutedRootIds: ReadonlySet<string> = new Set(),
+  options: NotifyOptions,
 ): boolean {
+  const {
+    participatedRootIds,
+    followedRootIds,
+    authoredRootIds,
+    mutedRootIds = new Set(),
+    mutedChannelIds = new Set(),
+    channelId = null,
+  } = options;
   const { parentId, rootId } = getThreadReference(event.tags);
 
-  if (parentId === null) {
+  if (isBroadcastReply(event.tags)) {
     return true;
   }
 
-  if (isBroadcastReply(event.tags)) {
+  if (hasMentionForEvent(event, currentPubkey)) {
     return true;
   }
 
-  if (
-    currentPubkey.length > 0 &&
-    event.tags.some(
-      (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey,
-    )
-  ) {
+  if (channelId !== null && mutedChannelIds.has(channelId)) {
+    return false;
+  }
+
+  if (parentId === null) {
     return true;
   }
 
diff --git a/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs b/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs
new file mode 100644
index 0000000000..191444eb77
--- /dev/null
+++ b/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs
@@ -0,0 +1,174 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { hasMentionForEvent, shouldNotifyForEvent } from "./shouldNotify.ts";
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const PUBKEY = "a".repeat(64);
+const OTHER_PUBKEY = "b".repeat(64);
+const CHANNEL_ID =
+  "channel-0000000000000000000000000000000000000000000000000000";
+const ROOT_ID = `root-${"0".repeat(59)}`;
+const PARENT_ID = `parent-${"0".repeat(57)}`;
+
+const EMPTY = new Set();
+
+function makeEvent(tags = [], overrides = {}) {
+  return {
+    id: `event-${"0".repeat(59)}`,
+    pubkey: OTHER_PUBKEY,
+    created_at: 1700000000,
+    kind: 9,
+    tags,
+    content: "hello",
+    sig: "s".repeat(128),
+    ...overrides,
+  };
+}
+
+const rootTag = (id) => ["e", id, "", "root"];
+const replyTag = (id) => ["e", id, "", "reply"];
+const pTag = (pubkey) => ["p", pubkey];
+const broadcastTag = () => ["broadcast", "1"];
+const hTag = (channelId) => ["h", channelId];
+
+// ── hasMentionForEvent ────────────────────────────────────────────────────────
+
+test("hasMentionForEvent: p-tag matching currentPubkey returns true", () => {
+  const event = makeEvent([pTag(PUBKEY)]);
+  assert.equal(hasMentionForEvent(event, PUBKEY), true);
+});
+
+test("hasMentionForEvent: p-tag case-insensitive match returns true", () => {
+  const event = makeEvent([pTag(PUBKEY.toUpperCase())]);
+  assert.equal(hasMentionForEvent(event, PUBKEY), true);
+});
+
+test("hasMentionForEvent: p-tag not matching currentPubkey returns false", () => {
+  const event = makeEvent([pTag(OTHER_PUBKEY)]);
+  assert.equal(hasMentionForEvent(event, PUBKEY), false);
+});
+
+test("hasMentionForEvent: no p-tags returns false", () => {
+  const event = makeEvent([hTag(CHANNEL_ID)]);
+  assert.equal(hasMentionForEvent(event, PUBKEY), false);
+});
+
+test("hasMentionForEvent: empty currentPubkey returns false", () => {
+  const event = makeEvent([pTag(PUBKEY)]);
+  assert.equal(hasMentionForEvent(event, ""), false);
+});
+
+// ── shouldNotifyForEvent: channel muting ─────────────────────────────────────
+
+test("top-level message in muted channel is suppressed", () => {
+  const event = makeEvent([hTag(CHANNEL_ID)]);
+  assert.equal(
+    shouldNotifyForEvent(event, PUBKEY, {
+      participatedRootIds: EMPTY,
+      followedRootIds: EMPTY,
+      authoredRootIds: EMPTY,
+      mutedChannelIds: new Set([CHANNEL_ID]),
+      channelId: CHANNEL_ID,
+    }),
+    false,
+  );
+});
+
+test("mention in muted channel still notifies (mention fires before mute check)", () => {
+  const event = makeEvent([hTag(CHANNEL_ID), pTag(PUBKEY)]);
+  assert.equal(
+    shouldNotifyForEvent(event, PUBKEY, {
+      participatedRootIds: EMPTY,
+      followedRootIds: EMPTY,
+      authoredRootIds: EMPTY,
+      mutedChannelIds: new Set([CHANNEL_ID]),
+      channelId: CHANNEL_ID,
+    }),
+    true,
+  );
+});
+
+test("thread reply in muted channel is suppressed", () => {
+  const event = makeEvent([
+    hTag(CHANNEL_ID),
+    rootTag(ROOT_ID),
+    replyTag(PARENT_ID),
+  ]);
+  assert.equal(
+    shouldNotifyForEvent(event, PUBKEY, {
+      participatedRootIds: new Set([ROOT_ID]),
+      followedRootIds: EMPTY,
+      authoredRootIds: EMPTY,
+      mutedChannelIds: new Set([CHANNEL_ID]),
+      channelId: CHANNEL_ID,
+    }),
+    false,
+  );
+});
+
+test("broadcast reply in muted channel still notifies (broadcast fires before mute check)", () => {
+  const event = makeEvent([
+    hTag(CHANNEL_ID),
+    replyTag(ROOT_ID),
+    broadcastTag(),
+  ]);
+  assert.equal(
+    shouldNotifyForEvent(event, PUBKEY, {
+      participatedRootIds: EMPTY,
+      followedRootIds: EMPTY,
+      authoredRootIds: EMPTY,
+      mutedChannelIds: new Set([CHANNEL_ID]),
+      channelId: CHANNEL_ID,
+    }),
+    true,
+  );
+});
+
+test("top-level message in unmuted channel notifies", () => {
+  const event = makeEvent([hTag(CHANNEL_ID)]);
+  assert.equal(
+    shouldNotifyForEvent(event, PUBKEY, {
+      participatedRootIds: EMPTY,
+      followedRootIds: EMPTY,
+      authoredRootIds: EMPTY,
+      channelId: CHANNEL_ID,
+    }),
+    true,
+  );
+});
+
+test("no channelId passed behaves as if unmuted (top-level notifies)", () => {
+  const event = makeEvent([hTag(CHANNEL_ID)]);
+  // mutedChannelIds has the channel but channelId is null (default)
+  assert.equal(
+    shouldNotifyForEvent(event, PUBKEY, {
+      participatedRootIds: EMPTY,
+      followedRootIds: EMPTY,
+      authoredRootIds: EMPTY,
+      mutedChannelIds: new Set([CHANNEL_ID]),
+    }),
+    true,
+  );
+});
+
+test("thread in mutedRootIds AND in muted channel is suppressed", () => {
+  const event = makeEvent([
+    hTag(CHANNEL_ID),
+    rootTag(ROOT_ID),
+    replyTag(PARENT_ID),
+  ]);
+  // Both the root thread and the channel are muted; mute channel check fires first
+  assert.equal(
+    shouldNotifyForEvent(event, PUBKEY, {
+      participatedRootIds: new Set([ROOT_ID]),
+      followedRootIds: EMPTY,
+      authoredRootIds: EMPTY,
+      mutedRootIds: new Set([ROOT_ID]),
+      mutedChannelIds: new Set([CHANNEL_ID]),
+      channelId: CHANNEL_ID,
+    }),
+    false,
+  );
+});
diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts
index 273d2f1302..d76dbbf839 100644
--- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts
+++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts
@@ -69,6 +69,7 @@ export function useFeedDesktopNotifications(
   settings: NotificationSettings,
   setDesktopEnabled: (enabled: boolean) => Promise<boolean>,
   profiles?: UserProfileLookup,
+  mutedChannelIds?: ReadonlySet<string>,
 ) {
   const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
   const seenItemIdsRef = React.useRef<Set<string>>(
@@ -150,7 +151,14 @@ export function useFeedDesktopNotifications(
       ? eligibleFeedNotificationItems(feed, {
           mentions: settings.mentions,
           needsAction: settings.needsAction,
-        }).filter((item) => !nextSeenItemIds.has(item.id))
+        })
+          .filter((item) => !nextSeenItemIds.has(item.id))
+          .filter(
+            (item) =>
+              !item.channelId ||
+              !mutedChannelIds?.has(item.channelId) ||
+              item.category === "mention",
+          )
       : [];
 
     for (const item of currentFeedItems) {
@@ -192,6 +200,7 @@ export function useFeedDesktopNotifications(
     }
   }, [
     feed,
+    mutedChannelIds,
     normalizedPubkey,
     profiles,
     settings.desktopEnabled,
diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs
new file mode 100644
index 0000000000..df5ea855f9
--- /dev/null
+++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs
@@ -0,0 +1,244 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  parseMutePayload,
+  mergeStores,
+  mutedChannelIdsFromStore,
+} from "./channelMutesStorage.ts";
+
+// ── parseMutePayload ──────────────────────────────────────────────────────────
+
+test("parseMutePayload: valid payload with channels returns store", () => {
+  const payload = {
+    version: 1,
+    channels: {
+      "chan-1": { muted: true, updatedAt: 1000 },
+      "chan-2": { muted: false, updatedAt: 2000 },
+    },
+  };
+  const result = parseMutePayload(payload);
+  assert.deepEqual(result, {
+    version: 1,
+    channels: {
+      "chan-1": { muted: true, updatedAt: 1000 },
+      "chan-2": { muted: false, updatedAt: 2000 },
+    },
+  });
+});
+
+test("parseMutePayload: missing version returns null", () => {
+  assert.equal(
+    parseMutePayload({ channels: { "chan-1": { muted: true, updatedAt: 1 } } }),
+    null,
+  );
+});
+
+test("parseMutePayload: wrong version returns null", () => {
+  assert.equal(
+    parseMutePayload({
+      version: 2,
+      channels: { "chan-1": { muted: true, updatedAt: 1 } },
+    }),
+    null,
+  );
+});
+
+test("parseMutePayload: null input returns null", () => {
+  assert.equal(parseMutePayload(null), null);
+});
+
+test("parseMutePayload: non-object input returns null", () => {
+  assert.equal(parseMutePayload("string"), null);
+  assert.equal(parseMutePayload(42), null);
+  assert.equal(parseMutePayload(true), null);
+});
+
+test("parseMutePayload: malformed channel entries missing muted/updatedAt are filtered out", () => {
+  const payload = {
+    version: 1,
+    channels: {
+      "no-muted": { updatedAt: 1000 },
+      "no-updated-at": { muted: true },
+      valid: { muted: false, updatedAt: 500 },
+      "muted-wrong-type": { muted: "yes", updatedAt: 1000 },
+      "updated-at-wrong-type": { muted: true, updatedAt: "now" },
+      null: null,
+    },
+  };
+  const result = parseMutePayload(payload);
+  assert.deepEqual(result, {
+    version: 1,
+    channels: {
+      valid: { muted: false, updatedAt: 500 },
+    },
+  });
+});
+
+test("parseMutePayload: NaN/Infinity/negative updatedAt entries are filtered out", () => {
+  const payload = {
+    version: 1,
+    channels: {
+      nan: { muted: true, updatedAt: NaN },
+      inf: { muted: true, updatedAt: Infinity },
+      "neg-inf": { muted: true, updatedAt: -Infinity },
+      neg: { muted: true, updatedAt: -1 },
+      valid: { muted: true, updatedAt: 100 },
+    },
+  };
+  const result = parseMutePayload(payload);
+  assert.deepEqual(result, {
+    version: 1,
+    channels: { valid: { muted: true, updatedAt: 100 } },
+  });
+});
+
+test("parseMutePayload: empty channels returns store with empty channels", () => {
+  const result = parseMutePayload({ version: 1, channels: {} });
+  assert.deepEqual(result, { version: 1, channels: {} });
+});
+
+test("parseMutePayload: version 1 with no channels key returns store with empty channels", () => {
+  const result = parseMutePayload({ version: 1 });
+  assert.deepEqual(result, { version: 1, channels: {} });
+});
+
+// ── mergeStores ───────────────────────────────────────────────────────────────
+
+test("mergeStores: non-overlapping channels returns union of both", () => {
+  const local = {
+    version: 1,
+    channels: { "chan-a": { muted: true, updatedAt: 100 } },
+  };
+  const remote = {
+    version: 1,
+    channels: { "chan-b": { muted: false, updatedAt: 200 } },
+  };
+  const result = mergeStores(local, remote);
+  assert.deepEqual(result, {
+    version: 1,
+    channels: {
+      "chan-a": { muted: true, updatedAt: 100 },
+      "chan-b": { muted: false, updatedAt: 200 },
+    },
+  });
+});
+
+test("mergeStores: overlapping channel with remote newer takes remote", () => {
+  const local = {
+    version: 1,
+    channels: { "chan-a": { muted: false, updatedAt: 100 } },
+  };
+  const remote = {
+    version: 1,
+    channels: { "chan-a": { muted: true, updatedAt: 200 } },
+  };
+  const result = mergeStores(local, remote);
+  assert.deepEqual(result.channels["chan-a"], { muted: true, updatedAt: 200 });
+});
+
+test("mergeStores: overlapping channel with local newer takes local", () => {
+  const local = {
+    version: 1,
+    channels: { "chan-a": { muted: true, updatedAt: 300 } },
+  };
+  const remote = {
+    version: 1,
+    channels: { "chan-a": { muted: false, updatedAt: 100 } },
+  };
+  const result = mergeStores(local, remote);
+  assert.deepEqual(result.channels["chan-a"], { muted: true, updatedAt: 300 });
+});
+
+test("mergeStores: overlapping channel with same updatedAt local wins", () => {
+  const local = {
+    version: 1,
+    channels: { "chan-a": { muted: true, updatedAt: 500 } },
+  };
+  const remote = {
+    version: 1,
+    channels: { "chan-a": { muted: false, updatedAt: 500 } },
+  };
+  const result = mergeStores(local, remote);
+  assert.deepEqual(result.channels["chan-a"], { muted: true, updatedAt: 500 });
+});
+
+test("mergeStores: unmute with higher updatedAt overrides mute", () => {
+  const local = {
+    version: 1,
+    channels: { "chan-a": { muted: true, updatedAt: 100 } },
+  };
+  const remote = {
+    version: 1,
+    channels: { "chan-a": { muted: false, updatedAt: 999 } },
+  };
+  const result = mergeStores(local, remote);
+  assert.deepEqual(result.channels["chan-a"], { muted: false, updatedAt: 999 });
+});
+
+test("mergeStores: empty local returns remote entries", () => {
+  const local = { version: 1, channels: {} };
+  const remote = {
+    version: 1,
+    channels: { "chan-b": { muted: true, updatedAt: 42 } },
+  };
+  const result = mergeStores(local, remote);
+  assert.deepEqual(result.channels, {
+    "chan-b": { muted: true, updatedAt: 42 },
+  });
+});
+
+test("mergeStores: empty remote returns local entries", () => {
+  const local = {
+    version: 1,
+    channels: { "chan-a": { muted: false, updatedAt: 10 } },
+  };
+  const remote = { version: 1, channels: {} };
+  const result = mergeStores(local, remote);
+  assert.deepEqual(result.channels, {
+    "chan-a": { muted: false, updatedAt: 10 },
+  });
+});
+
+test("mergeStores: both empty returns empty", () => {
+  const result = mergeStores(
+    { version: 1, channels: {} },
+    { version: 1, channels: {} },
+  );
+  assert.deepEqual(result, { version: 1, channels: {} });
+});
+
+// ── mutedChannelIdsFromStore ──────────────────────────────────────────────────
+
+test("mutedChannelIdsFromStore: returns set of IDs where muted=true", () => {
+  const store = {
+    version: 1,
+    channels: {
+      "chan-a": { muted: true, updatedAt: 100 },
+      "chan-b": { muted: true, updatedAt: 200 },
+      "chan-c": { muted: false, updatedAt: 300 },
+    },
+  };
+  const result = mutedChannelIdsFromStore(store);
+  assert.equal(result.has("chan-a"), true);
+  assert.equal(result.has("chan-b"), true);
+  assert.equal(result.has("chan-c"), false);
+  assert.equal(result.size, 2);
+});
+
+test("mutedChannelIdsFromStore: excludes IDs where muted=false", () => {
+  const store = {
+    version: 1,
+    channels: {
+      "chan-x": { muted: false, updatedAt: 1 },
+      "chan-y": { muted: false, updatedAt: 2 },
+    },
+  };
+  const result = mutedChannelIdsFromStore(store);
+  assert.equal(result.size, 0);
+});
+
+test("mutedChannelIdsFromStore: empty channels returns empty set", () => {
+  const result = mutedChannelIdsFromStore({ version: 1, channels: {} });
+  assert.equal(result.size, 0);
+});
diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.ts b/desktop/src/features/sidebar/lib/channelMutesStorage.ts
new file mode 100644
index 0000000000..504086a371
--- /dev/null
+++ b/desktop/src/features/sidebar/lib/channelMutesStorage.ts
@@ -0,0 +1,106 @@
+const STORAGE_KEY_PREFIX = "sprout-channel-mutes.v1";
+
+export type ChannelMuteEntry = {
+  muted: boolean;
+  updatedAt: number;
+};
+
+export type ChannelMuteStore = {
+  version: 1;
+  channels: Record<string, ChannelMuteEntry>;
+};
+
+export const DEFAULT_STORE: ChannelMuteStore = Object.freeze({
+  version: 1,
+  channels: {},
+});
+
+export function storageKey(pubkey: string): string {
+  return `${STORAGE_KEY_PREFIX}:${pubkey}`;
+}
+
+export function parseMutePayload(json: unknown): ChannelMuteStore | null {
+  if (typeof json !== "object" || json === null) return null;
+  const obj = json as Record<string, unknown>;
+  if (obj.version !== 1) return null;
+  const channels: Record<string, ChannelMuteEntry> =
+    typeof obj.channels === "object" &&
+    obj.channels !== null &&
+    !Array.isArray(obj.channels)
+      ? Object.fromEntries(
+          Object.entries(obj.channels as Record<string, unknown>).filter(
+            (entry): entry is [string, ChannelMuteEntry] => {
+              const v = entry[1];
+              return (
+                typeof v === "object" &&
+                v !== null &&
+                typeof (v as Record<string, unknown>).muted === "boolean" &&
+                typeof (v as Record<string, unknown>).updatedAt === "number" &&
+                Number.isFinite(
+                  (v as Record<string, unknown>).updatedAt as number,
+                ) &&
+                ((v as Record<string, unknown>).updatedAt as number) >= 0
+              );
+            },
+          ),
+        )
+      : {};
+  return { version: 1, channels };
+}
+
+export function readChannelMutesStore(pubkey: string): ChannelMuteStore {
+  try {
+    const raw = window.localStorage.getItem(storageKey(pubkey));
+    if (!raw) {
+      return DEFAULT_STORE;
+    }
+    const parsed = JSON.parse(raw);
+    if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
+      return DEFAULT_STORE;
+    }
+    return parseMutePayload(parsed) ?? DEFAULT_STORE;
+  } catch {
+    return DEFAULT_STORE;
+  }
+}
+
+export function writeChannelMutesStore(
+  pubkey: string,
+  store: ChannelMuteStore,
+): boolean {
+  try {
+    window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store));
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export function mergeStores(
+  local: ChannelMuteStore,
+  remote: ChannelMuteStore,
+): ChannelMuteStore {
+  const allIds = new Set([
+    ...Object.keys(local.channels),
+    ...Object.keys(remote.channels),
+  ]);
+  const merged: Record<string, ChannelMuteEntry> = {};
+  for (const id of allIds) {
+    const l = local.channels[id];
+    const r = remote.channels[id];
+    if (l && r) {
+      merged[id] = l.updatedAt >= r.updatedAt ? l : r;
+    } else {
+      merged[id] = (l ?? r) as ChannelMuteEntry;
+    }
+  }
+  return { version: 1, channels: merged };
+}
+
+export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set<string> {
+  return new Set(
+    Object.entries(store.channels)
+      .filter(([, entry]) => entry.muted)
+      .map(([id]) => id),
+  );
+}
diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts
new file mode 100644
index 0000000000..0a0d2bb9f6
--- /dev/null
+++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts
@@ -0,0 +1,208 @@
+import { relayClient } from "@/shared/api/relayClient";
+import {
+  nip44DecryptFromSelf,
+  nip44EncryptToSelf,
+  signRelayEvent,
+} from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+import { KIND_CHANNEL_MUTES } from "@/shared/constants/kinds";
+import {
+  mergeStores,
+  parseMutePayload,
+  type ChannelMuteStore,
+} from "./channelMutesStorage";
+
+const D_TAG = "channel-mutes";
+const DEBOUNCE_MS = 2_000;
+
+export type RemoteMutes = {
+  store: ChannelMuteStore;
+  createdAt: number;
+  eventId: string;
+};
+
+async function decryptAndParse(event: RelayEvent): Promise<RemoteMutes | null> {
+  try {
+    const plaintext = await nip44DecryptFromSelf(event.content);
+    const store = parseMutePayload(JSON.parse(plaintext));
+    if (!store) return null;
+    return { store, createdAt: event.created_at, eventId: event.id };
+  } catch {
+    return null;
+  }
+}
+
+export class ChannelMuteSyncManager {
+  private pubkey: string;
+  private debounceTimer: number | null = null;
+  private lastRemoteCreatedAt = 0;
+  private pendingStore: ChannelMuteStore | null = null;
+  private lastPublishedStore: ChannelMuteStore | null = null;
+
+  constructor(pubkey: string) {
+    this.pubkey = pubkey;
+  }
+
+  async fetchRemoteMutes(): Promise<RemoteMutes | null> {
+    try {
+      const events = await relayClient.fetchEvents({
+        kinds: [KIND_CHANNEL_MUTES],
+        authors: [this.pubkey],
+        "#d": [D_TAG],
+        limit: 1,
+      });
+      if (events.length === 0) return null;
+      if (events[0].pubkey !== this.pubkey) return null;
+      const result = await decryptAndParse(events[0]);
+      if (result) {
+        this.lastRemoteCreatedAt = Math.max(
+          this.lastRemoteCreatedAt,
+          result.createdAt,
+        );
+      }
+      return result;
+    } catch {
+      return null;
+    }
+  }
+
+  cancelPendingMutePublish(): void {
+    if (this.debounceTimer !== null) {
+      window.clearTimeout(this.debounceTimer);
+      this.debounceTimer = null;
+    }
+  }
+
+  getPendingMuteStore(): ChannelMuteStore | null {
+    return this.pendingStore;
+  }
+
+  publishMutes(store: ChannelMuteStore): void {
+    this.pendingStore = store;
+    if (this.debounceTimer !== null) {
+      window.clearTimeout(this.debounceTimer);
+    }
+    this.debounceTimer = window.setTimeout(() => {
+      this.debounceTimer = null;
+      void this.doPublish(store);
+    }, DEBOUNCE_MS);
+  }
+
+  private async fetchOwnBlobBeforePublish(
+    store: ChannelMuteStore,
+  ): Promise<ChannelMuteStore> {
+    try {
+      const events = await relayClient.fetchEvents({
+        kinds: [KIND_CHANNEL_MUTES],
+        authors: [this.pubkey],
+        "#d": [D_TAG],
+        limit: 1,
+      });
+      if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
+      const remote = await decryptAndParse(events[0]);
+      if (!remote) return store;
+      this.lastRemoteCreatedAt = Math.max(
+        this.lastRemoteCreatedAt,
+        remote.createdAt,
+      );
+      return mergeStores(store, remote.store);
+    } catch {
+      return store;
+    }
+  }
+
+  private isIdenticalToLastPublished(store: ChannelMuteStore): boolean {
+    if (!this.lastPublishedStore) return false;
+    const lastKeys = Object.keys(this.lastPublishedStore.channels);
+    const currentKeys = Object.keys(store.channels);
+    if (lastKeys.length !== currentKeys.length) return false;
+    for (const key of currentKeys) {
+      const last = this.lastPublishedStore.channels[key];
+      const current = store.channels[key];
+      if (
+        !last ||
+        last.muted !== current.muted ||
+        last.updatedAt !== current.updatedAt
+      )
+        return false;
+    }
+    return true;
+  }
+
+  private async doPublish(store: ChannelMuteStore): Promise<void> {
+    try {
+      const merged = await this.fetchOwnBlobBeforePublish(store);
+      if (this.isIdenticalToLastPublished(merged)) {
+        this.pendingStore = null;
+        return;
+      }
+      const payload = {
+        version: 1,
+        channels: merged.channels,
+      };
+      const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
+      const createdAt = Math.max(
+        Math.floor(Date.now() / 1_000),
+        this.lastRemoteCreatedAt + 1,
+      );
+      const event = await signRelayEvent({
+        kind: KIND_CHANNEL_MUTES,
+        content: ciphertext,
+        createdAt,
+        tags: [
+          ["d", D_TAG],
+          ["t", D_TAG], // relay discoverability; not used in our filters
+        ],
+      });
+      await relayClient.publishEvent(
+        event,
+        "Timed out publishing channel mutes.",
+        "Failed to publish channel mutes.",
+      );
+      this.lastRemoteCreatedAt = Math.max(
+        this.lastRemoteCreatedAt,
+        event.created_at,
+      );
+      this.lastPublishedStore = merged;
+      this.pendingStore = null;
+    } catch (error) {
+      console.warn("[channelMutesSync] publish failed:", error);
+    }
+  }
+
+  async subscribeToMutes(
+    onUpdate: (remote: RemoteMutes) => void,
+  ): Promise<() => Promise<void>> {
+    return relayClient.subscribeLive(
+      {
+        kinds: [KIND_CHANNEL_MUTES],
+        authors: [this.pubkey],
+        "#d": [D_TAG],
+        limit: 0,
+      },
+      (event: RelayEvent) => {
+        if (event.pubkey !== this.pubkey) return;
+        void decryptAndParse(event).then((result) => {
+          if (result) {
+            this.lastRemoteCreatedAt = Math.max(
+              this.lastRemoteCreatedAt,
+              result.createdAt,
+            );
+            onUpdate(result);
+          }
+        });
+      },
+    );
+  }
+
+  destroy(): void {
+    if (this.debounceTimer !== null && this.pendingStore !== null) {
+      window.clearTimeout(this.debounceTimer);
+      this.debounceTimer = null;
+      void this.doPublish(this.pendingStore);
+    } else if (this.debounceTimer !== null) {
+      window.clearTimeout(this.debounceTimer);
+      this.debounceTimer = null;
+    }
+  }
+}
diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts
index 6ff37b19b6..9a5f8a4e48 100644
--- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts
+++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts
@@ -8,6 +8,7 @@ import type { RelayEvent } from "@/shared/api/types";
 import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds";
 import {
   parseChannelSectionPayload,
+  type ChannelSection,
   type ChannelSectionStore,
 } from "./channelSectionsStorage";
 
@@ -20,10 +21,6 @@ export type RemoteSections = {
   eventId: string;
 };
 
-let debounceTimer: number | null = null;
-let lastRemoteCreatedAt = 0;
-let pendingStore: ChannelSectionStore | null = null;
-
 async function decryptAndParse(
   event: RelayEvent,
 ): Promise<RemoteSections | null> {
@@ -37,111 +34,187 @@ async function decryptAndParse(
   }
 }
 
-export async function fetchRemoteSections(
-  pubkey: string,
-): Promise<RemoteSections | null> {
-  try {
-    const events = await relayClient.fetchEvents({
-      kinds: [KIND_CHANNEL_SECTIONS],
-      authors: [pubkey],
-      "#d": [D_TAG],
-      limit: 1,
-    });
-    if (events.length === 0) return null;
-    if (events[0].pubkey !== pubkey) return null;
-    const result = await decryptAndParse(events[0]);
-    if (result) {
-      lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt);
+export class ChannelSectionSyncManager {
+  private pubkey: string;
+  private debounceTimer: number | null = null;
+  private lastRemoteCreatedAt = 0;
+  private pendingStore: ChannelSectionStore | null = null;
+  private lastPublishedStore: ChannelSectionStore | null = null;
+
+  constructor(pubkey: string) {
+    this.pubkey = pubkey;
+  }
+
+  async fetchRemoteSections(): Promise<RemoteSections | null> {
+    try {
+      const events = await relayClient.fetchEvents({
+        kinds: [KIND_CHANNEL_SECTIONS],
+        authors: [this.pubkey],
+        "#d": [D_TAG],
+        limit: 1,
+      });
+      if (events.length === 0) return null;
+      if (events[0].pubkey !== this.pubkey) return null;
+      const result = await decryptAndParse(events[0]);
+      if (result) {
+        this.lastRemoteCreatedAt = Math.max(
+          this.lastRemoteCreatedAt,
+          result.createdAt,
+        );
+      }
+      return result;
+    } catch {
+      return null;
     }
-    return result;
-  } catch {
-    return null;
   }
-}
 
-export function cancelPendingPublish(): void {
-  if (debounceTimer !== null) {
-    window.clearTimeout(debounceTimer);
-    debounceTimer = null;
+  cancelPendingPublish(): void {
+    if (this.debounceTimer !== null) {
+      window.clearTimeout(this.debounceTimer);
+      this.debounceTimer = null;
+    }
   }
-}
 
-export function getPendingStore(): ChannelSectionStore | null {
-  return pendingStore;
-}
+  getPendingStore(): ChannelSectionStore | null {
+    return this.pendingStore;
+  }
 
-export function publishSections(store: ChannelSectionStore): void {
-  pendingStore = store;
-  if (debounceTimer !== null) {
-    window.clearTimeout(debounceTimer);
+  publishSections(store: ChannelSectionStore): void {
+    this.pendingStore = store;
+    if (this.debounceTimer !== null) {
+      window.clearTimeout(this.debounceTimer);
+    }
+    this.debounceTimer = window.setTimeout(() => {
+      this.debounceTimer = null;
+      void this.doPublish(store);
+    }, DEBOUNCE_MS);
   }
-  debounceTimer = window.setTimeout(() => {
-    debounceTimer = null;
-    void doPublish(store);
-  }, DEBOUNCE_MS);
-}
 
-async function doPublish(store: ChannelSectionStore): Promise<void> {
-  try {
-    const payload = {
-      version: 1,
-      sections: store.sections,
-      assignments: store.assignments,
-    };
-    const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
-    const createdAt = Math.max(
-      Math.floor(Date.now() / 1_000),
-      lastRemoteCreatedAt + 1,
-    );
-    const event = await signRelayEvent({
-      kind: KIND_CHANNEL_SECTIONS,
-      content: ciphertext,
-      createdAt,
-      tags: [
-        ["d", D_TAG],
-        ["t", D_TAG], // relay discoverability; not used in our filters
-      ],
-    });
-    await relayClient.publishEvent(
-      event,
-      "Timed out publishing channel sections.",
-      "Failed to publish channel sections.",
-    );
-    lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, event.created_at);
-    pendingStore = null;
-  } catch (error) {
-    console.warn("[channelSectionsSync] publish failed:", error);
+  private async fetchOwnBlobBeforePublish(
+    store: ChannelSectionStore,
+  ): Promise<ChannelSectionStore> {
+    try {
+      const events = await relayClient.fetchEvents({
+        kinds: [KIND_CHANNEL_SECTIONS],
+        authors: [this.pubkey],
+        "#d": [D_TAG],
+        limit: 1,
+      });
+      if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
+      const remote = await decryptAndParse(events[0]);
+      if (!remote) return store;
+      // Sections use whole-blob LWW: take whichever is newer
+      if (remote.createdAt > this.lastRemoteCreatedAt) {
+        this.lastRemoteCreatedAt = remote.createdAt;
+        return remote.store;
+      }
+      return store;
+    } catch {
+      return store;
+    }
+  }
+
+  private isIdenticalToLastPublished(store: ChannelSectionStore): boolean {
+    if (!this.lastPublishedStore) return false;
+    const lastSections = this.lastPublishedStore.sections;
+    const currentSections = store.sections;
+    if (lastSections.length !== currentSections.length) return false;
+    for (let i = 0; i < currentSections.length; i++) {
+      const last = lastSections[i] as ChannelSection | undefined;
+      const current = currentSections[i] as ChannelSection;
+      if (
+        !last ||
+        last.id !== current.id ||
+        last.name !== current.name ||
+        last.order !== current.order
+      )
+        return false;
+    }
+    const lastAssignKeys = Object.keys(this.lastPublishedStore.assignments);
+    const currentAssignKeys = Object.keys(store.assignments);
+    if (lastAssignKeys.length !== currentAssignKeys.length) return false;
+    for (const key of currentAssignKeys) {
+      if (this.lastPublishedStore.assignments[key] !== store.assignments[key])
+        return false;
+    }
+    return true;
   }
-}
 
-export async function subscribeToSections(
-  pubkey: string,
-  onUpdate: (remote: RemoteSections) => void,
-): Promise<() => Promise<void>> {
-  return relayClient.subscribeLive(
-    {
-      kinds: [KIND_CHANNEL_SECTIONS],
-      authors: [pubkey],
-      "#d": [D_TAG],
-      limit: 0,
-    },
-    (event: RelayEvent) => {
-      if (event.pubkey !== pubkey) return;
-      void decryptAndParse(event).then((result) => {
-        if (result) {
-          lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt);
-          onUpdate(result);
-        }
+  private async doPublish(store: ChannelSectionStore): Promise<void> {
+    try {
+      const merged = await this.fetchOwnBlobBeforePublish(store);
+      if (this.isIdenticalToLastPublished(merged)) {
+        this.pendingStore = null;
+        return;
+      }
+      const payload = {
+        version: 1,
+        sections: merged.sections,
+        assignments: merged.assignments,
+      };
+      const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
+      const createdAt = Math.max(
+        Math.floor(Date.now() / 1_000),
+        this.lastRemoteCreatedAt + 1,
+      );
+      const event = await signRelayEvent({
+        kind: KIND_CHANNEL_SECTIONS,
+        content: ciphertext,
+        createdAt,
+        tags: [
+          ["d", D_TAG],
+          ["t", D_TAG], // relay discoverability; not used in our filters
+        ],
       });
-    },
-  );
-}
+      await relayClient.publishEvent(
+        event,
+        "Timed out publishing channel sections.",
+        "Failed to publish channel sections.",
+      );
+      this.lastRemoteCreatedAt = Math.max(
+        this.lastRemoteCreatedAt,
+        event.created_at,
+      );
+      this.lastPublishedStore = merged;
+      this.pendingStore = null;
+    } catch (error) {
+      console.warn("[channelSectionsSync] publish failed:", error);
+    }
+  }
 
-export function resetSyncState(): void {
-  if (debounceTimer !== null) {
-    window.clearTimeout(debounceTimer);
-    debounceTimer = null;
+  async subscribeToSections(
+    onUpdate: (remote: RemoteSections) => void,
+  ): Promise<() => Promise<void>> {
+    return relayClient.subscribeLive(
+      {
+        kinds: [KIND_CHANNEL_SECTIONS],
+        authors: [this.pubkey],
+        "#d": [D_TAG],
+        limit: 0,
+      },
+      (event: RelayEvent) => {
+        if (event.pubkey !== this.pubkey) return;
+        void decryptAndParse(event).then((result) => {
+          if (result) {
+            this.lastRemoteCreatedAt = Math.max(
+              this.lastRemoteCreatedAt,
+              result.createdAt,
+            );
+            onUpdate(result);
+          }
+        });
+      },
+    );
+  }
+
+  destroy(): void {
+    if (this.debounceTimer !== null && this.pendingStore !== null) {
+      window.clearTimeout(this.debounceTimer);
+      this.debounceTimer = null;
+      void this.doPublish(this.pendingStore);
+    } else if (this.debounceTimer !== null) {
+      window.clearTimeout(this.debounceTimer);
+      this.debounceTimer = null;
+    }
   }
-  lastRemoteCreatedAt = 0;
-  pendingStore = null;
 }
diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts
new file mode 100644
index 0000000000..1fe92b60a3
--- /dev/null
+++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts
@@ -0,0 +1,190 @@
+import * as React from "react";
+
+import { relayClient } from "@/shared/api/relayClient";
+import {
+  DEFAULT_STORE,
+  mergeStores,
+  mutedChannelIdsFromStore,
+  readChannelMutesStore,
+  storageKey,
+  writeChannelMutesStore,
+  type ChannelMuteEntry,
+  type ChannelMuteStore,
+} from "./channelMutesStorage";
+import { ChannelMuteSyncManager } from "./channelMutesSync";
+import type { RemoteMutes } from "./channelMutesSync";
+
+export function useChannelMutes(pubkey: string | undefined): {
+  mutedChannelIds: Set<string>;
+  muteChannel: (channelId: string) => void;
+  unmuteChannel: (channelId: string) => void;
+} {
+  const [store, setStore] = React.useState<ChannelMuteStore>(() => {
+    if (!pubkey) {
+      return DEFAULT_STORE;
+    }
+    return readChannelMutesStore(pubkey);
+  });
+
+  const managerRef = React.useRef<ChannelMuteSyncManager | null>(null);
+  const lastAppliedRemoteTs = React.useRef(0);
+  const lastAppliedEventId = React.useRef("");
+
+  React.useEffect(() => {
+    if (!pubkey) {
+      setStore(DEFAULT_STORE);
+      lastAppliedRemoteTs.current = 0;
+      lastAppliedEventId.current = "";
+      return;
+    }
+    setStore(readChannelMutesStore(pubkey));
+    lastAppliedRemoteTs.current = 0;
+    lastAppliedEventId.current = "";
+    managerRef.current = new ChannelMuteSyncManager(pubkey);
+    return () => {
+      managerRef.current?.destroy();
+      managerRef.current = null;
+    };
+  }, [pubkey]);
+
+  React.useEffect(() => {
+    if (!pubkey) {
+      return;
+    }
+    const key = storageKey(pubkey);
+    const handler = (e: StorageEvent) => {
+      if (e.key !== key) {
+        return;
+      }
+      setStore(readChannelMutesStore(pubkey));
+    };
+    window.addEventListener("storage", handler);
+    return () => {
+      window.removeEventListener("storage", handler);
+    };
+  }, [pubkey]);
+
+  const applyRemote = React.useCallback(
+    (remote: RemoteMutes): ((prev: ChannelMuteStore) => ChannelMuteStore) => {
+      return (prev) => {
+        if (!pubkey) return prev;
+        if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
+        if (
+          remote.createdAt === lastAppliedRemoteTs.current &&
+          remote.eventId <= lastAppliedEventId.current
+        )
+          return prev;
+        lastAppliedRemoteTs.current = remote.createdAt;
+        lastAppliedEventId.current = remote.eventId;
+        managerRef.current?.cancelPendingMutePublish();
+        const merged = mergeStores(prev, remote.store);
+        if (!writeChannelMutesStore(pubkey, merged)) return prev;
+        return merged;
+      };
+    },
+    [pubkey],
+  );
+
+  React.useEffect(() => {
+    if (!pubkey) return;
+    let cancelled = false;
+    void managerRef.current?.fetchRemoteMutes().then((remote) => {
+      if (cancelled) return;
+      if (remote) {
+        setStore(applyRemote(remote));
+      } else {
+        const local = readChannelMutesStore(pubkey);
+        if (Object.keys(local.channels).length > 0) {
+          managerRef.current?.publishMutes(local);
+        }
+      }
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, [pubkey, applyRemote]);
+
+  React.useEffect(() => {
+    if (!pubkey) return;
+    let unsub: (() => Promise<void>) | null = null;
+    let cancelled = false;
+    void managerRef.current
+      ?.subscribeToMutes((remote) => {
+        if (cancelled) return;
+        setStore(applyRemote(remote));
+      })
+      .then((dispose) => {
+        if (cancelled) {
+          void dispose();
+        } else {
+          unsub = dispose;
+        }
+      });
+    return () => {
+      cancelled = true;
+      if (unsub) void unsub();
+    };
+  }, [pubkey, applyRemote]);
+
+  React.useEffect(() => {
+    if (!pubkey) return;
+    let cancelled = false;
+    const unsub = relayClient.subscribeToReconnects(() => {
+      void managerRef.current?.fetchRemoteMutes().then((remote) => {
+        if (cancelled) return;
+        if (remote) {
+          setStore(applyRemote(remote));
+        }
+        const pending = managerRef.current?.getPendingMuteStore();
+        if (pending) {
+          managerRef.current?.publishMutes(pending);
+        }
+      });
+    });
+    return () => {
+      cancelled = true;
+      unsub();
+    };
+  }, [pubkey, applyRemote]);
+
+  // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes)
+  const mutedChannelIds = React.useMemo(
+    () => mutedChannelIdsFromStore(store),
+    [store.channels],
+  );
+
+  const setMuteState = React.useCallback(
+    (channelId: string, muted: boolean) => {
+      if (!pubkey) return;
+      const entry: ChannelMuteEntry = {
+        muted,
+        updatedAt: Math.floor(Date.now() / 1000),
+      };
+      setStore((prev) => {
+        const next: ChannelMuteStore = {
+          version: 1,
+          channels: { ...prev.channels, [channelId]: entry },
+        };
+        if (!writeChannelMutesStore(pubkey, next)) return prev;
+        managerRef.current?.publishMutes(next);
+        return next;
+      });
+    },
+    [pubkey],
+  );
+
+  const muteChannel = React.useCallback(
+    (channelId: string) => setMuteState(channelId, true),
+    [setMuteState],
+  );
+  const unmuteChannel = React.useCallback(
+    (channelId: string) => setMuteState(channelId, false),
+    [setMuteState],
+  );
+
+  return {
+    mutedChannelIds,
+    muteChannel,
+    unmuteChannel,
+  };
+}
diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts
index f4bd12ec1f..c86fc30826 100644
--- a/desktop/src/features/sidebar/lib/useChannelSections.ts
+++ b/desktop/src/features/sidebar/lib/useChannelSections.ts
@@ -7,14 +7,7 @@ import {
   storageKey,
   writeChannelSectionsStore,
 } from "./channelSectionsStorage";
-import {
-  cancelPendingPublish,
-  fetchRemoteSections,
-  getPendingStore,
-  publishSections,
-  resetSyncState,
-  subscribeToSections,
-} from "./channelSectionsSync";
+import { ChannelSectionSyncManager } from "./channelSectionsSync";
 import type { RemoteSections } from "./channelSectionsSync";
 import { swapSectionOrder } from "./channelSectionsHelpers";
 
@@ -44,6 +37,7 @@ export function useChannelSections(pubkey: string | undefined): {
     return readChannelSectionsStore(pubkey);
   });
 
+  const managerRef = React.useRef<ChannelSectionSyncManager | null>(null);
   const lastAppliedRemoteTs = React.useRef(0);
   const lastAppliedEventId = React.useRef("");
 
@@ -57,8 +51,10 @@ export function useChannelSections(pubkey: string | undefined): {
     setStore(readChannelSectionsStore(pubkey));
     lastAppliedRemoteTs.current = 0;
     lastAppliedEventId.current = "";
+    managerRef.current = new ChannelSectionSyncManager(pubkey);
     return () => {
-      resetSyncState();
+      managerRef.current?.destroy();
+      managerRef.current = null;
     };
   }, [pubkey]);
 
@@ -93,7 +89,7 @@ export function useChannelSections(pubkey: string | undefined): {
           return prev;
         lastAppliedRemoteTs.current = remote.createdAt;
         lastAppliedEventId.current = remote.eventId;
-        cancelPendingPublish();
+        managerRef.current?.cancelPendingPublish();
         if (!writeChannelSectionsStore(pubkey, remote.store)) return prev;
         return remote.store;
       };
@@ -104,14 +100,14 @@ export function useChannelSections(pubkey: string | undefined): {
   React.useEffect(() => {
     if (!pubkey) return;
     let cancelled = false;
-    void fetchRemoteSections(pubkey).then((remote) => {
+    void managerRef.current?.fetchRemoteSections().then((remote) => {
       if (cancelled) return;
       if (remote) {
         setStore(applyRemote(remote));
       } else {
         const local = readChannelSectionsStore(pubkey);
         if (local.sections.length > 0) {
-          publishSections(local);
+          managerRef.current?.publishSections(local);
         }
       }
     });
@@ -124,16 +120,18 @@ export function useChannelSections(pubkey: string | undefined): {
     if (!pubkey) return;
     let unsub: (() => Promise<void>) | null = null;
     let cancelled = false;
-    void subscribeToSections(pubkey, (remote) => {
-      if (cancelled) return;
-      setStore(applyRemote(remote));
-    }).then((dispose) => {
-      if (cancelled) {
-        void dispose();
-      } else {
-        unsub = dispose;
-      }
-    });
+    void managerRef.current
+      ?.subscribeToSections((remote) => {
+        if (cancelled) return;
+        setStore(applyRemote(remote));
+      })
+      .then((dispose) => {
+        if (cancelled) {
+          void dispose();
+        } else {
+          unsub = dispose;
+        }
+      });
     return () => {
       cancelled = true;
       if (unsub) void unsub();
@@ -144,14 +142,14 @@ export function useChannelSections(pubkey: string | undefined): {
     if (!pubkey) return;
     let cancelled = false;
     const unsub = relayClient.subscribeToReconnects(() => {
-      void fetchRemoteSections(pubkey).then((remote) => {
+      void managerRef.current?.fetchRemoteSections().then((remote) => {
         if (cancelled) return;
         if (remote) {
           setStore(applyRemote(remote));
         }
-        const pending = getPendingStore();
+        const pending = managerRef.current?.getPendingStore();
         if (pending) {
-          publishSections(pending);
+          managerRef.current?.publishSections(pending);
         }
       });
     });
@@ -185,7 +183,7 @@ export function useChannelSections(pubkey: string | undefined): {
           sections: [...current.sections, section],
         };
         if (!writeChannelSectionsStore(pubkey, next)) return current;
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
       return section;
@@ -208,7 +206,7 @@ export function useChannelSections(pubkey: string | undefined): {
         if (!writeChannelSectionsStore(pubkey, next)) {
           return prev;
         }
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
     },
@@ -235,7 +233,7 @@ export function useChannelSections(pubkey: string | undefined): {
         if (!writeChannelSectionsStore(pubkey, next)) {
           return prev;
         }
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
     },
@@ -248,7 +246,7 @@ export function useChannelSections(pubkey: string | undefined): {
       setStore((prev) => {
         const next = swapSectionOrder(prev, sectionId, "up");
         if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
     },
@@ -261,7 +259,7 @@ export function useChannelSections(pubkey: string | undefined): {
       setStore((prev) => {
         const next = swapSectionOrder(prev, sectionId, "down");
         if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
     },
@@ -278,7 +276,7 @@ export function useChannelSections(pubkey: string | undefined): {
         });
         const next: ChannelSectionStore = { ...prev, sections };
         if (!writeChannelSectionsStore(pubkey, next)) return prev;
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
     },
@@ -298,7 +296,7 @@ export function useChannelSections(pubkey: string | undefined): {
         if (!writeChannelSectionsStore(pubkey, next)) {
           return prev;
         }
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
     },
@@ -317,7 +315,7 @@ export function useChannelSections(pubkey: string | undefined): {
         if (!writeChannelSectionsStore(pubkey, next)) {
           return prev;
         }
-        publishSections(next);
+        managerRef.current?.publishSections(next);
         return next;
       });
     },
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 9e84d19e45..690f714900 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -146,6 +146,9 @@ type AppSidebarProps = {
   onNewDmOpenChange?: (open: boolean) => void;
   isCreateChannelOpen?: boolean;
   onCreateChannelOpenChange?: (open: boolean) => void;
+  mutedChannelIds?: ReadonlySet<string>;
+  onMuteChannel?: (channelId: string) => void;
+  onUnmuteChannel?: (channelId: string) => void;
 };
 
 // ---------------------------------------------------------------------------
@@ -201,6 +204,9 @@ export function AppSidebar({
   onNewDmOpenChange,
   isCreateChannelOpen: isCreateChannelOpenProp,
   onCreateChannelOpenChange,
+  mutedChannelIds,
+  onMuteChannel,
+  onUnmuteChannel,
 }: AppSidebarProps) {
   const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
   const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
@@ -549,6 +555,9 @@ export function AppSidebar({
                     onDeleteSection={() => setDeleteSectionTarget(section)}
                     onMoveSectionUp={() => moveSectionUp(section.id)}
                     onMoveSectionDown={() => moveSectionDown(section.id)}
+                    mutedChannelIds={mutedChannelIds}
+                    onMuteChannel={onMuteChannel}
+                    onUnmuteChannel={onUnmuteChannel}
                   />
                 ))}
                 <ChannelGroupSection
@@ -579,6 +588,9 @@ export function AppSidebar({
                   onAssignChannel={assignChannel}
                   onUnassignChannel={unassignChannel}
                   onCreateSectionForChannel={handleCreateSectionForChannel}
+                  mutedChannelIds={mutedChannelIds}
+                  onMuteChannel={onMuteChannel}
+                  onUnmuteChannel={onUnmuteChannel}
                 />
               </SidebarDndContext>
               <ChannelGroupSection
@@ -600,6 +612,9 @@ export function AppSidebar({
                 selectedChannelId={selectedChannelId}
                 title="Forums"
                 unreadChannelIds={unreadChannelIds}
+                mutedChannelIds={mutedChannelIds}
+                onMuteChannel={onMuteChannel}
+                onUnmuteChannel={onUnmuteChannel}
               />
               <SidebarSection
                 action={
@@ -634,6 +649,9 @@ export function AppSidebar({
                 testId="dm-list"
                 title="Direct Messages"
                 unreadChannelIds={unreadChannelIds}
+                mutedChannelIds={mutedChannelIds}
+                onMuteChannel={onMuteChannel}
+                onUnmuteChannel={onUnmuteChannel}
               />
             </>
           ) : null}
diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
index 4feae8a4b9..1f3254a958 100644
--- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
+++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
@@ -1,6 +1,8 @@
 import {
   ArrowDown,
   ArrowUp,
+  Bell,
+  BellOff,
   Check,
   CheckCheck,
   CheckCircle2,
@@ -114,47 +116,69 @@ function MoveToSectionSubmenu({
 export function ChannelContextMenuItems({
   channel,
   hasUnread,
+  isMuted,
   sections,
   assignments,
   onMarkChannelRead,
   onMarkChannelUnread,
+  onMuteChannel,
+  onUnmuteChannel,
   onAssignChannel,
   onUnassignChannel,
   onCreateSectionForChannel,
 }: {
   channel: Channel;
   hasUnread: boolean;
+  isMuted?: boolean;
   sections?: ChannelSection[];
   assignments?: Record<string, string>;
-  onMarkChannelRead: (
+  onMarkChannelRead?: (
     channelId: string,
     lastMessageAt: string | null | undefined,
   ) => void;
-  onMarkChannelUnread: (
+  onMarkChannelUnread?: (
     channelId: string,
     lastMessageAt: string | null | undefined,
   ) => void;
+  onMuteChannel?: (channelId: string) => void;
+  onUnmuteChannel?: (channelId: string) => void;
   onAssignChannel?: (channelId: string, sectionId: string) => void;
   onUnassignChannel?: (channelId: string) => void;
   onCreateSectionForChannel?: (channelId: string) => void;
 }) {
   return (
     <>
-      {hasUnread ? (
+      {hasUnread && onMarkChannelRead ? (
         <ContextMenuItem
           onClick={() => onMarkChannelRead(channel.id, channel.lastMessageAt)}
         >
           <CheckCircle2 className="h-4 w-4" />
           Mark as read
         </ContextMenuItem>
-      ) : (
+      ) : !hasUnread && onMarkChannelUnread ? (
         <ContextMenuItem
           onClick={() => onMarkChannelUnread(channel.id, channel.lastMessageAt)}
         >
           <CircleDot className="h-4 w-4" />
           Mark unread
         </ContextMenuItem>
-      )}
+      ) : null}
+      {onMuteChannel && onUnmuteChannel ? (
+        <>
+          <ContextMenuSeparator />
+          {isMuted ? (
+            <ContextMenuItem onClick={() => onUnmuteChannel(channel.id)}>
+              <Bell className="h-4 w-4" />
+              Unmute channel
+            </ContextMenuItem>
+          ) : (
+            <ContextMenuItem onClick={() => onMuteChannel(channel.id)}>
+              <BellOff className="h-4 w-4" />
+              Mute channel
+            </ContextMenuItem>
+          )}
+        </>
+      ) : null}
       {sections &&
       assignments &&
       onAssignChannel &&
@@ -268,6 +292,9 @@ export function ChannelGroupSection({
   onAssignChannel,
   onUnassignChannel,
   onCreateSectionForChannel,
+  mutedChannelIds,
+  onMuteChannel,
+  onUnmuteChannel,
 }: {
   browseAriaLabel: string;
   browseTestId?: string;
@@ -300,6 +327,9 @@ export function ChannelGroupSection({
   onAssignChannel?: (channelId: string, sectionId: string) => void;
   onUnassignChannel?: (channelId: string) => void;
   onCreateSectionForChannel?: (channelId: string) => void;
+  mutedChannelIds?: ReadonlySet<string>;
+  onMuteChannel?: (channelId: string) => void;
+  onUnmuteChannel?: (channelId: string) => void;
 }) {
   const contentId = `sidebar-${listTestId}`;
 
@@ -315,6 +345,7 @@ export function ChannelGroupSection({
                     <ChannelMenuButton
                       channel={channel}
                       hasUnread={unreadChannelIds.has(channel.id)}
+                      isMuted={mutedChannelIds?.has(channel.id)}
                       isActive={
                         isActiveChannel && selectedChannelId === channel.id
                       }
@@ -325,6 +356,7 @@ export function ChannelGroupSection({
                   <ChannelMenuButton
                     channel={channel}
                     hasUnread={unreadChannelIds.has(channel.id)}
+                    isMuted={mutedChannelIds?.has(channel.id)}
                     isActive={
                       isActiveChannel && selectedChannelId === channel.id
                     }
@@ -337,10 +369,13 @@ export function ChannelGroupSection({
               <ChannelContextMenuItems
                 channel={channel}
                 hasUnread={unreadChannelIds.has(channel.id)}
+                isMuted={mutedChannelIds?.has(channel.id)}
                 sections={sections}
                 assignments={assignments}
                 onMarkChannelRead={onMarkChannelRead}
                 onMarkChannelUnread={onMarkChannelUnread}
+                onMuteChannel={onMuteChannel}
+                onUnmuteChannel={onUnmuteChannel}
                 onAssignChannel={onAssignChannel}
                 onUnassignChannel={onUnassignChannel}
                 onCreateSectionForChannel={onCreateSectionForChannel}
@@ -424,6 +459,9 @@ export function CustomChannelSection({
   onDeleteSection,
   onMoveSectionUp,
   onMoveSectionDown,
+  mutedChannelIds,
+  onMuteChannel,
+  onUnmuteChannel,
 }: {
   section: ChannelSection;
   channels: Channel[];
@@ -454,6 +492,9 @@ export function CustomChannelSection({
   onDeleteSection: () => void;
   onMoveSectionUp: () => void;
   onMoveSectionDown: () => void;
+  mutedChannelIds?: ReadonlySet<string>;
+  onMuteChannel?: (channelId: string) => void;
+  onUnmuteChannel?: (channelId: string) => void;
 }) {
   const contentId = `sidebar-section-${section.id}`;
 
@@ -573,6 +614,7 @@ export function CustomChannelSection({
                               <ChannelMenuButton
                                 channel={channel}
                                 hasUnread={unreadChannelIds.has(channel.id)}
+                                isMuted={mutedChannelIds?.has(channel.id)}
                                 isActive={
                                   isActiveChannel &&
                                   selectedChannelId === channel.id
@@ -586,10 +628,13 @@ export function CustomChannelSection({
                           <ChannelContextMenuItems
                             channel={channel}
                             hasUnread={unreadChannelIds.has(channel.id)}
+                            isMuted={mutedChannelIds?.has(channel.id)}
                             sections={sections}
                             assignments={assignments}
                             onMarkChannelRead={onMarkChannelRead}
                             onMarkChannelUnread={onMarkChannelUnread}
+                            onMuteChannel={onMuteChannel}
+                            onUnmuteChannel={onUnmuteChannel}
                             onAssignChannel={onAssignChannel}
                             onUnassignChannel={onUnassignChannel}
                             onCreateSectionForChannel={
diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx
index c91f398279..dc994f8227 100644
--- a/desktop/src/features/sidebar/ui/SidebarSection.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx
@@ -1,6 +1,6 @@
 import type * as React from "react";
 import {
-  CheckCircle2,
+  BellOff,
   ChevronDown,
   CircleDot,
   FileText,
@@ -12,10 +12,10 @@ import {
 import {
   ContextMenu,
   ContextMenuContent,
-  ContextMenuItem,
   ContextMenuTrigger,
 } from "@/shared/ui/context-menu";
 
+import { ChannelContextMenuItems } from "@/features/sidebar/ui/CustomChannelSection";
 import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
 import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
 import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
@@ -148,6 +148,7 @@ export function ChannelMenuButton({
   label,
   isActive,
   hasUnread,
+  isMuted,
   dmParticipants,
   presenceStatus,
   onSelectChannel,
@@ -156,6 +157,7 @@ export function ChannelMenuButton({
   label?: string;
   isActive: boolean;
   hasUnread: boolean;
+  isMuted?: boolean;
   dmParticipants?: SidebarDmParticipant[];
   presenceStatus?: PresenceStatus;
   onSelectChannel: (channelId: string) => void;
@@ -169,6 +171,7 @@ export function ChannelMenuButton({
         !isActive &&
           hasUnread &&
           "font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
+        !isActive && isMuted && !hasUnread && "opacity-50",
       )}
       data-channel-id={channel.id}
       data-testid={`channel-${channel.name}`}
@@ -190,6 +193,16 @@ export function ChannelMenuButton({
           variant="sidebar"
         />
       ) : null}
+      {isMuted ? (
+        <BellOff
+          className={cn(
+            "ml-auto h-3 w-3 shrink-0",
+            isActive
+              ? "text-sidebar-primary-foreground/60"
+              : "text-sidebar-foreground/40",
+          )}
+        />
+      ) : null}
       {hasUnread && !isActive && channel.channelType !== "dm" ? (
         <span
           aria-hidden="true"
@@ -219,6 +232,9 @@ export function SidebarSection({
   onMarkChannelUnread,
   onSelectChannel,
   onToggleCollapsed,
+  mutedChannelIds,
+  onMuteChannel,
+  onUnmuteChannel,
 }: {
   action?: React.ReactNode;
   dmParticipantsByChannelId?: Record<string, SidebarDmParticipant[]>;
@@ -243,6 +259,9 @@ export function SidebarSection({
   ) => void;
   onSelectChannel: (channelId: string) => void;
   onToggleCollapsed?: () => void;
+  mutedChannelIds?: ReadonlySet<string>;
+  onMuteChannel?: (channelId: string) => void;
+  onUnmuteChannel?: (channelId: string) => void;
 }) {
   if (items.length === 0 && !action && !emptyState) {
     return null;
@@ -292,6 +311,7 @@ export function SidebarSection({
                       channel={channel}
                       dmParticipants={dmParticipantsByChannelId?.[channel.id]}
                       hasUnread={unreadChannelIds.has(channel.id)}
+                      isMuted={mutedChannelIds?.has(channel.id)}
                       isActive={
                         isActiveChannel && selectedChannelId === channel.id
                       }
@@ -323,34 +343,22 @@ export function SidebarSection({
 
                 const hasContextAction =
                   (unreadChannelIds.has(channel.id) && onMarkChannelRead) ||
-                  (!unreadChannelIds.has(channel.id) && onMarkChannelUnread);
+                  (!unreadChannelIds.has(channel.id) && onMarkChannelUnread) ||
+                  (onMuteChannel && onUnmuteChannel);
 
                 return hasContextAction ? (
                   <ContextMenu key={channel.id}>
                     <ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
                     <ContextMenuContent>
-                      {unreadChannelIds.has(channel.id) && onMarkChannelRead ? (
-                        <ContextMenuItem
-                          onClick={() =>
-                            onMarkChannelRead(channel.id, channel.lastMessageAt)
-                          }
-                        >
-                          <CheckCircle2 className="h-4 w-4" />
-                          Mark as read
-                        </ContextMenuItem>
-                      ) : onMarkChannelUnread ? (
-                        <ContextMenuItem
-                          onClick={() =>
-                            onMarkChannelUnread(
-                              channel.id,
-                              channel.lastMessageAt,
-                            )
-                          }
-                        >
-                          <CircleDot className="h-4 w-4" />
-                          Mark unread
-                        </ContextMenuItem>
-                      ) : null}
+                      <ChannelContextMenuItems
+                        channel={channel}
+                        hasUnread={unreadChannelIds.has(channel.id)}
+                        isMuted={mutedChannelIds?.has(channel.id)}
+                        onMarkChannelRead={onMarkChannelRead}
+                        onMarkChannelUnread={onMarkChannelUnread}
+                        onMuteChannel={onMuteChannel}
+                        onUnmuteChannel={onUnmuteChannel}
+                      />
                     </ContextMenuContent>
                   </ContextMenu>
                 ) : (
diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts
index 335ed81476..efc3b8af5d 100644
--- a/desktop/src/features/workspaces/useWorkspaceInit.ts
+++ b/desktop/src/features/workspaces/useWorkspaceInit.ts
@@ -10,15 +10,15 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
 import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
 import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
 import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
-import { resetSyncState } from "@/features/sidebar/lib/channelSectionsSync";
 
 import { initFirstWorkspace } from "./workspaceStorage";
 import type { Workspace } from "./types";
 
 /**
  * Tear down all workspace-scoped module singletons so the new
- * workspace starts with a clean slate. If you add a new module-level
- * cache or singleton that holds workspace data, add its reset here.
+ * workspace starts with a clean slate. Hook-managed singletons
+ * (e.g. ChannelMuteSyncManager, ChannelSectionSyncManager) are
+ * destroyed via effect cleanup and do not need entries here.
  * See AGENTS.md "Workspace Switching" for the full contract.
  */
 function resetWorkspaceState(): void {
@@ -27,7 +27,6 @@ function resetWorkspaceState(): void {
   resetMediaCaches();
   clearSearchHitEventCache();
   clearAllDrafts();
-  resetSyncState();
 }
 
 type WorkspaceInitResult =
diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts
index 21d8e21e9d..93b3b887cf 100644
--- a/desktop/src/shared/constants/kinds.ts
+++ b/desktop/src/shared/constants/kinds.ts
@@ -16,10 +16,11 @@ export const KIND_FORUM_POST = 45001;
 export const KIND_FORUM_COMMENT = 45003;
 export const KIND_APPROVAL_REQUEST = 46010;
 export const KIND_TYPING_INDICATOR = 20002;
-// NIP-78 application-specific data. Both use kind 30078; the relay
-// differentiates them by d-tag ("read-state:<slotId>" vs "channel-sections").
+// NIP-78 application-specific data. All use kind 30078; the relay
+// differentiates them by d-tag ("read-state:<slotId>", "channel-sections", "channel-mutes").
 export const KIND_READ_STATE = 30078;
 export const KIND_CHANNEL_SECTIONS = 30078;
+export const KIND_CHANNEL_MUTES = 30078;
 export const KIND_USER_STATUS = 30315;
 export const KIND_AGENT_OBSERVER_FRAME = 24200;
 export const KIND_MESH_STATUS_REPORT = 24620;
diff --git a/desktop/tests/e2e/channel-mute-screenshots.spec.ts b/desktop/tests/e2e/channel-mute-screenshots.spec.ts
new file mode 100644
index 0000000000..e8c1a4017f
--- /dev/null
+++ b/desktop/tests/e2e/channel-mute-screenshots.spec.ts
@@ -0,0 +1,189 @@
+import { expect, test } from "@playwright/test";
+
+import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
+
+const MOCK_PUBKEY = "deadbeef".repeat(8);
+const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
+const MUTE_STORAGE_KEY = `sprout-channel-mutes.v1:${MOCK_PUBKEY}`;
+const SHOTS = "test-results/channel-mute";
+
+function seedMuteState(
+  page: import("@playwright/test").Page,
+  channelId: string,
+) {
+  return page.addInitScript(
+    ({ key, id }) => {
+      localStorage.setItem(
+        key,
+        JSON.stringify({
+          version: 1,
+          channels: {
+            [id]: { muted: true, updatedAt: 1700000000 },
+          },
+        }),
+      );
+    },
+    { key: MUTE_STORAGE_KEY, id: channelId },
+  );
+}
+
+async function waitForMockLiveSubscription(
+  page: import("@playwright/test").Page,
+  channelName: string,
+) {
+  await expect
+    .poll(async () => {
+      return page.evaluate(
+        ({ ch }) =>
+          (
+            window as Window & {
+              __SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
+                channelName: string;
+              }) => boolean;
+            }
+          ).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
+          false,
+        { ch: channelName },
+      );
+    })
+    .toBe(true);
+}
+
+test.describe("channel muting screenshots", () => {
+  test("01 — context menu shows Mute channel", async ({ page }) => {
+    await installMockBridge(page);
+    await page.goto("/");
+    await page.getByTestId("channel-general").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    await page.getByTestId("channel-general").click({ button: "right" });
+    const muteItem = page.getByRole("menuitem", { name: "Mute channel" });
+    await expect(muteItem).toBeVisible();
+    await muteItem.evaluate((el) =>
+      Promise.all(
+        el
+          .closest("[data-state]")!
+          .getAnimations()
+          .map((a) => a.finished),
+      ),
+    );
+
+    await page.screenshot({
+      path: `${SHOTS}/01-context-menu-mute.png`,
+      clip: { x: 0, y: 0, width: 450, height: 720 },
+    });
+  });
+
+  test("02 — muted channel is dimmed with BellOff icon", async ({ page }) => {
+    await seedMuteState(page, ENGINEERING_CHANNEL_ID);
+    await installMockBridge(page);
+
+    await page.goto("/");
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    const engRow = page.getByTestId("channel-engineering");
+    await expect(engRow).toBeVisible();
+    await expect(engRow).toHaveCSS("opacity", "0.5");
+    await expect(engRow.locator("svg.lucide-bell-off")).toHaveCount(1);
+
+    await page.screenshot({
+      path: `${SHOTS}/02-muted-channel-dimmed.png`,
+      clip: { x: 0, y: 0, width: 256, height: 720 },
+    });
+  });
+
+  test("03 — muted channel with @mention shows unread dot", async ({
+    page,
+  }) => {
+    await seedMuteState(page, ENGINEERING_CHANNEL_ID);
+    await installMockBridge(page);
+
+    await page.goto("/");
+    await page.getByTestId("channel-engineering").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("engineering");
+    await waitForMockLiveSubscription(page, "engineering");
+
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    await page.evaluate(
+      ({ pubkey, mockPubkey }) => {
+        (
+          window as Window & {
+            __SPROUT_E2E_EMIT_MOCK_MESSAGE__?: (input: {
+              channelName: string;
+              content: string;
+              pubkey: string;
+              mentionPubkeys: string[];
+            }) => unknown;
+          }
+        ).__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
+          channelName: "engineering",
+          content: "Hey check this out",
+          pubkey,
+          mentionPubkeys: [mockPubkey],
+        });
+      },
+      {
+        pubkey: TEST_IDENTITIES.alice.pubkey,
+        mockPubkey: MOCK_PUBKEY,
+      },
+    );
+
+    await expect(page.getByTestId("channel-unread-engineering")).toBeVisible();
+
+    await page.screenshot({
+      path: `${SHOTS}/03-muted-with-mention.png`,
+      clip: { x: 0, y: 0, width: 256, height: 720 },
+    });
+  });
+
+  test("04 — context menu shows Unmute channel when muted", async ({
+    page,
+  }) => {
+    await seedMuteState(page, ENGINEERING_CHANNEL_ID);
+    await installMockBridge(page);
+
+    await page.goto("/");
+    await page.getByTestId("channel-random").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+    await page.getByTestId("channel-engineering").click({ button: "right" });
+    const unmuteItem = page.getByRole("menuitem", { name: "Unmute channel" });
+    await expect(unmuteItem).toBeVisible();
+    await unmuteItem.evaluate((el) =>
+      Promise.all(
+        el
+          .closest("[data-state]")!
+          .getAnimations()
+          .map((a) => a.finished),
+      ),
+    );
+
+    await page.screenshot({
+      path: `${SHOTS}/04-context-menu-unmute.png`,
+      clip: { x: 0, y: 0, width: 450, height: 720 },
+    });
+  });
+
+  test("05 — muted icon visible on selected channel", async ({ page }) => {
+    await seedMuteState(page, ENGINEERING_CHANNEL_ID);
+    await installMockBridge(page);
+
+    await page.goto("/");
+    await page.getByTestId("channel-engineering").click();
+    await expect(page.getByTestId("chat-title")).toHaveText("engineering");
+
+    const engRow = page.getByTestId("channel-engineering");
+    await expect(engRow.locator("svg.lucide-bell-off")).toHaveCount(1);
+
+    await page.screenshot({
+      path: `${SHOTS}/05-muted-icon-on-selected.png`,
+      clip: { x: 0, y: 0, width: 256, height: 720 },
+    });
+  });
+});
diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart
new file mode 100644
index 0000000000..d64ba09cf0
--- /dev/null
+++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart
@@ -0,0 +1,271 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:nostr/nostr.dart' as nostr;
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../../../shared/crypto/nip44.dart';
+import '../../../shared/relay/relay.dart';
+import '../read_state/read_state_time.dart';
+import 'channel_mutes_storage.dart';
+
+class ChannelMutesCrypto {
+  final Uint8List _conversationKey;
+
+  ChannelMutesCrypto(String nsec, String pubkey)
+    : _conversationKey = _deriveKey(nsec, pubkey);
+
+  static Uint8List _deriveKey(String nsec, String pubkey) {
+    final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
+    return getConversationKey(privkeyHex, pubkey);
+  }
+
+  String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
+
+  String decrypt(String ciphertext) =>
+      nip44Decrypt(_conversationKey, ciphertext);
+}
+
+class ChannelMutesManager {
+  final String pubkey;
+  final ChannelMutesStorage _storage;
+  final ChannelMutesCrypto _crypto;
+  final RelaySessionNotifier? _relaySession;
+  final SignedEventRelay? _signedEventRelay;
+  final bool _remoteEnabled;
+  final VoidCallback _onChanged;
+
+  ChannelMuteStore _store;
+  ChannelMuteStore? _lastPublishedStore;
+  Timer? _publishDebounce;
+  int _lastRemoteCreatedAt = 0;
+  String? _lastRemoteEventId;
+  void Function()? _unsubscribe;
+  bool _disposed = false;
+
+  ChannelMutesManager({
+    required this.pubkey,
+    required SharedPreferences prefs,
+    required ChannelMutesCrypto crypto,
+    required RelaySessionNotifier? relaySession,
+    required SignedEventRelay? signedEventRelay,
+    required bool remoteEnabled,
+    required VoidCallback onChanged,
+  }) : _storage = ChannelMutesStorage(prefs),
+       _crypto = crypto,
+       _relaySession = relaySession,
+       _signedEventRelay = signedEventRelay,
+       _remoteEnabled = remoteEnabled,
+       _onChanged = onChanged,
+       _store = ChannelMutesStorage(prefs).read(pubkey);
+
+  ChannelMuteStore get store => _store;
+
+  Future<void> initialize() async {
+    if (_disposed) return;
+
+    if (!_remoteEnabled || _relaySession == null) {
+      _onChanged();
+      return;
+    }
+
+    await _fetchAndMerge();
+    await _startLiveSubscription();
+    _onChanged();
+  }
+
+  void dispose({bool flushPending = true}) {
+    if (_disposed) return;
+    _disposed = true;
+
+    final hadPending = _publishDebounce != null;
+    _publishDebounce?.cancel();
+    _publishDebounce = null;
+
+    if (flushPending && hadPending && _remoteEnabled) {
+      unawaited(_publish(allowDisposed: true));
+    }
+
+    _unsubscribe?.call();
+    _unsubscribe = null;
+  }
+
+  // -------------------------------------------------------------------------
+  // CRUD
+  // -------------------------------------------------------------------------
+
+  void muteChannel(String channelId) {
+    if (_disposed) return;
+    final entry = ChannelMuteEntry(
+      muted: true,
+      updatedAt: currentUnixSeconds(),
+    );
+    _store = ChannelMuteStore(channels: {..._store.channels, channelId: entry});
+    _persist();
+    markDirty();
+  }
+
+  void unmuteChannel(String channelId) {
+    if (_disposed) return;
+    final entry = ChannelMuteEntry(
+      muted: false,
+      updatedAt: currentUnixSeconds(),
+    );
+    _store = ChannelMuteStore(channels: {..._store.channels, channelId: entry});
+    _persist();
+    markDirty();
+  }
+
+  void markDirty() {
+    if (!_remoteEnabled || _disposed) return;
+    _publishDebounce?.cancel();
+    _publishDebounce = Timer(const Duration(seconds: 5), () {
+      _publishDebounce = null;
+      unawaited(_publish());
+    });
+  }
+
+  // -------------------------------------------------------------------------
+  // Remote sync
+  // -------------------------------------------------------------------------
+
+  Future<void> _fetchAndMerge() async {
+    if (_relaySession == null) return;
+    try {
+      final events = await _relaySession.fetchHistory(
+        NostrFilter(
+          kinds: const [EventKind.readState],
+          authors: [pubkey],
+          tags: const {
+            '#d': ['channel-mutes'],
+          },
+          limit: 1,
+        ),
+      );
+      _mergeEvents(events);
+      _persist();
+      if (!_disposed) _onChanged();
+    } catch (_) {
+      // Local state remains usable when relay is unavailable.
+    }
+  }
+
+  Future<void> _startLiveSubscription() async {
+    if (_relaySession == null) return;
+    try {
+      _unsubscribe = await _relaySession.subscribe(
+        NostrFilter(
+          kinds: const [EventKind.readState],
+          authors: [pubkey],
+          tags: const {
+            '#d': ['channel-mutes'],
+          },
+          limit: 1,
+        ),
+        _handleIncomingEvent,
+      );
+    } catch (_) {
+      // Non-fatal — local state and history still work.
+    }
+  }
+
+  void _mergeEvents(List<NostrEvent> events) {
+    for (final event in events) {
+      if (event.pubkey != pubkey) continue;
+      _mergeEvent(event);
+    }
+  }
+
+  void _mergeEvent(NostrEvent event) {
+    // Only process channel-mutes d-tag events.
+    final dTag = event.getTagValue('d');
+    if (dTag != 'channel-mutes') return;
+
+    try {
+      final plaintext = _crypto.decrypt(event.content);
+      final parsed = jsonDecode(plaintext);
+      if (parsed is! Map<String, dynamic>) return;
+
+      final incoming = ChannelMuteStore.fromJson(parsed);
+
+      // Gate on createdAt: ignore events older than what we've already seen.
+      final isNewer =
+          event.createdAt > _lastRemoteCreatedAt ||
+          (event.createdAt == _lastRemoteCreatedAt &&
+              event.id.compareTo(_lastRemoteEventId ?? '') > 0);
+
+      if (isNewer) {
+        _lastRemoteCreatedAt = event.createdAt;
+        _lastRemoteEventId = event.id;
+        // Per-channel merge: keep the entry with the highest updatedAt for each channel.
+        _store = mergeStores(_store, incoming);
+        _persist();
+      }
+    } catch (_) {
+      // Decryption failure or parse error — keep existing state.
+    }
+  }
+
+  void _handleIncomingEvent(NostrEvent event) {
+    if (_disposed) return;
+    _mergeEvent(event);
+    if (!_disposed) _onChanged();
+  }
+
+  bool _isIdenticalToLastPublished() {
+    final last = _lastPublishedStore;
+    if (last == null) return false;
+    if (last.channels.length != _store.channels.length) return false;
+    for (final key in _store.channels.keys) {
+      final lastEntry = last.channels[key];
+      final currentEntry = _store.channels[key];
+      if (lastEntry == null ||
+          lastEntry.muted != currentEntry!.muted ||
+          lastEntry.updatedAt != currentEntry.updatedAt) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  Future<void> _publish({bool allowDisposed = false}) async {
+    if ((!allowDisposed && _disposed) ||
+        !_remoteEnabled ||
+        _signedEventRelay == null) {
+      return;
+    }
+
+    // Read-before-write: merge remote state before publishing
+    await _fetchAndMerge();
+
+    // No-op suppression: skip if nothing changed
+    if (_isIdenticalToLastPublished()) return;
+
+    try {
+      final payload = jsonEncode(_store.toJson());
+      final ciphertext = _crypto.encrypt(payload);
+      final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1);
+
+      await _signedEventRelay.submit(
+        kind: EventKind.readState,
+        content: ciphertext,
+        tags: [
+          ['d', 'channel-mutes'],
+          ['t', 'channel-mutes'],
+        ],
+        createdAt: createdAt,
+      );
+
+      _lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
+      _lastPublishedStore = ChannelMuteStore(channels: Map.of(_store.channels));
+    } catch (error) {
+      debugPrint('[ChannelMutesManager] publish failed: $error');
+    }
+  }
+
+  void _persist() {
+    _storage.write(pubkey, _store);
+  }
+}
diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart
new file mode 100644
index 0000000000..8c69abcc42
--- /dev/null
+++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart
@@ -0,0 +1,123 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:nostr/nostr.dart' as nostr;
+
+import '../../../shared/relay/relay.dart';
+import '../../../shared/theme/theme_provider.dart';
+import '../../../shared/workspace/workspace_provider.dart';
+import 'channel_mutes_manager.dart';
+import 'channel_mutes_storage.dart';
+
+class ChannelMutesState {
+  final bool isReady;
+  final ChannelMuteStore store;
+
+  /// Bumped on every change to force downstream rebuilds.
+  final int version;
+
+  const ChannelMutesState({
+    this.isReady = false,
+    this.store = const ChannelMuteStore(),
+    this.version = 0,
+  });
+}
+
+class ChannelMutesNotifier extends Notifier<ChannelMutesState> {
+  ChannelMutesManager? _manager;
+
+  @override
+  ChannelMutesState build() {
+    _manager?.dispose(flushPending: false);
+    _manager = null;
+
+    final relayConfig = ref.watch(relayConfigProvider);
+    final sessionState = ref.watch(relaySessionProvider);
+    // Rebuild when the active workspace changes (pubkey may differ).
+    ref.watch(activeWorkspaceProvider);
+
+    final nsec = relayConfig.nsec?.trim();
+    if (nsec == null || nsec.isEmpty) {
+      return const ChannelMutesState();
+    }
+
+    final pubkey = _safePubkeyFromNsec(nsec);
+    if (pubkey == null || pubkey.isEmpty) {
+      return const ChannelMutesState();
+    }
+
+    final ChannelMutesCrypto crypto;
+    try {
+      crypto = ChannelMutesCrypto(nsec, pubkey);
+    } catch (_) {
+      return const ChannelMutesState();
+    }
+
+    final prefs = ref.read(savedPrefsProvider);
+    final signedRelay = SignedEventRelay(
+      session: ref.read(relaySessionProvider.notifier),
+      nsec: nsec,
+    );
+
+    late final ChannelMutesManager manager;
+    manager = ChannelMutesManager(
+      pubkey: pubkey,
+      prefs: prefs,
+      crypto: crypto,
+      relaySession: ref.read(relaySessionProvider.notifier),
+      signedEventRelay: signedRelay,
+      remoteEnabled: sessionState.status == SessionStatus.connected,
+      onChanged: () => _emitManagerState(manager),
+    );
+    _manager = manager;
+
+    ref.onDispose(() {
+      manager.dispose();
+      if (_manager == manager) {
+        _manager = null;
+      }
+    });
+
+    Future.microtask(() async {
+      await manager.initialize();
+      if (_manager != manager) return;
+      _emitManagerState(manager);
+    });
+
+    return ChannelMutesState(isReady: false, store: manager.store, version: 1);
+  }
+
+  // -------------------------------------------------------------------------
+  // CRUD delegates
+  // -------------------------------------------------------------------------
+
+  void muteChannel(String channelId) => _manager?.muteChannel(channelId);
+
+  void unmuteChannel(String channelId) => _manager?.unmuteChannel(channelId);
+
+  // -------------------------------------------------------------------------
+  // Internal
+  // -------------------------------------------------------------------------
+
+  void _emitManagerState(ChannelMutesManager manager) {
+    if (_manager != manager) return;
+    state = ChannelMutesState(
+      isReady: true,
+      store: manager.store,
+      version: state.version + 1,
+    );
+  }
+}
+
+final channelMutesProvider =
+    NotifierProvider<ChannelMutesNotifier, ChannelMutesState>(
+      ChannelMutesNotifier.new,
+    );
+
+String? _safePubkeyFromNsec(String nsec) {
+  try {
+    final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
+    if (privkeyHex.isEmpty) return null;
+    return nostr.Keys(privkeyHex).public;
+  } catch (_) {
+    return null;
+  }
+}
diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_storage.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_storage.dart
new file mode 100644
index 0000000000..1faccb55ad
--- /dev/null
+++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_storage.dart
@@ -0,0 +1,91 @@
+import 'dart:convert';
+
+import 'package:shared_preferences/shared_preferences.dart';
+
+String channelMutesKey(String pubkey) => 'sprout.channel-mutes.v1:$pubkey';
+
+class ChannelMuteEntry {
+  final bool muted;
+  final int updatedAt;
+
+  const ChannelMuteEntry({required this.muted, required this.updatedAt});
+
+  Map<String, dynamic> toJson() => {'muted': muted, 'updatedAt': updatedAt};
+
+  factory ChannelMuteEntry.fromJson(Map<String, dynamic> json) =>
+      ChannelMuteEntry(
+        muted: json['muted'] as bool,
+        updatedAt: json['updatedAt'] as int,
+      );
+}
+
+class ChannelMuteStore {
+  final int version;
+  final Map<String, ChannelMuteEntry> channels;
+
+  const ChannelMuteStore({this.version = 1, this.channels = const {}});
+
+  Map<String, dynamic> toJson() => {
+    'version': version,
+    'channels': {for (final e in channels.entries) e.key: e.value.toJson()},
+  };
+
+  factory ChannelMuteStore.fromJson(Map<String, dynamic> json) {
+    final rawChannels = json['channels'];
+    final channels = <String, ChannelMuteEntry>{};
+    if (rawChannels is Map) {
+      for (final entry in rawChannels.entries) {
+        if (entry.key is String && entry.value is Map<String, dynamic>) {
+          final v = entry.value as Map<String, dynamic>;
+          if (v['muted'] is bool && v['updatedAt'] is int) {
+            channels[entry.key as String] = ChannelMuteEntry.fromJson(v);
+          }
+        }
+      }
+    }
+    return ChannelMuteStore(version: 1, channels: channels);
+  }
+}
+
+ChannelMuteStore mergeStores(ChannelMuteStore local, ChannelMuteStore remote) {
+  // Per-channel max-updatedAt merge:
+  // For each channel ID in the union, keep the entry with the highest updatedAt.
+  final merged = <String, ChannelMuteEntry>{...local.channels};
+  for (final entry in remote.channels.entries) {
+    final existing = merged[entry.key];
+    if (existing == null || entry.value.updatedAt > existing.updatedAt) {
+      merged[entry.key] = entry.value;
+    }
+  }
+  return ChannelMuteStore(channels: merged);
+}
+
+class ChannelMutesStorage {
+  final SharedPreferences _prefs;
+
+  ChannelMutesStorage(this._prefs);
+
+  ChannelMuteStore read(String pubkey) {
+    final raw = _prefs.getString(channelMutesKey(pubkey));
+    if (raw == null || raw.isEmpty) {
+      return const ChannelMuteStore();
+    }
+
+    try {
+      final parsed = jsonDecode(raw);
+      if (parsed is! Map<String, dynamic>) {
+        return const ChannelMuteStore();
+      }
+      if (parsed['version'] != 1) {
+        return const ChannelMuteStore();
+      }
+      return ChannelMuteStore.fromJson(parsed);
+    } catch (_) {
+      return const ChannelMuteStore();
+    }
+  }
+
+  void write(String pubkey, ChannelMuteStore store) {
+    _prefs.setString(channelMutesKey(pubkey), jsonEncode(store.toJson()));
+  }
+}
diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart
index e59b978aba..adb857ab7b 100644
--- a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart
+++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart
@@ -41,8 +41,10 @@ class ChannelSectionsManager {
   final VoidCallback _onChanged;
 
   ChannelSectionStore _store;
+  ChannelSectionStore? _lastPublishedStore;
   Timer? _publishDebounce;
   int _lastRemoteCreatedAt = 0;
+  String? _lastRemoteEventId;
   void Function()? _unsubscribe;
   bool _disposed = false;
 
@@ -278,14 +280,28 @@ class ChannelSectionsManager {
     }
   }
 
-  String? _lastRemoteEventId;
-
   void _handleIncomingEvent(NostrEvent event) {
     if (_disposed) return;
     _mergeEvent(event);
     if (!_disposed) _onChanged();
   }
 
+  bool _isIdenticalToLastPublished() {
+    final last = _lastPublishedStore;
+    if (last == null) return false;
+    if (last.sections.length != _store.sections.length) return false;
+    if (last.assignments.length != _store.assignments.length) return false;
+    for (var i = 0; i < _store.sections.length; i++) {
+      final a = last.sections[i];
+      final b = _store.sections[i];
+      if (a.id != b.id || a.name != b.name || a.order != b.order) return false;
+    }
+    for (final key in _store.assignments.keys) {
+      if (last.assignments[key] != _store.assignments[key]) return false;
+    }
+    return true;
+  }
+
   Future<void> _publish({bool allowDisposed = false}) async {
     if ((!allowDisposed && _disposed) ||
         !_remoteEnabled ||
@@ -293,6 +309,12 @@ class ChannelSectionsManager {
       return;
     }
 
+    // Read-before-write: merge remote state before publishing
+    await _fetchAndMerge();
+
+    // No-op suppression: skip if nothing changed
+    if (_isIdenticalToLastPublished()) return;
+
     try {
       final payload = jsonEncode(_store.toJson());
       final ciphertext = _crypto.encrypt(payload);
@@ -309,6 +331,10 @@ class ChannelSectionsManager {
       );
 
       _lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
+      _lastPublishedStore = ChannelSectionStore(
+        sections: List.of(_store.sections),
+        assignments: Map.of(_store.assignments),
+      );
     } catch (error) {
       debugPrint('[ChannelSectionsManager] publish failed: $error');
     }
diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart
index ad96e59cf2..12d422c78a 100644
--- a/mobile/lib/features/channels/channels_page.dart
+++ b/mobile/lib/features/channels/channels_page.dart
@@ -21,6 +21,7 @@ import '../pairing/pairing_provider.dart';
 import 'channel.dart';
 import 'channel_detail_page.dart';
 import 'channel_management_provider.dart';
+import 'channel_mutes/channel_mutes_provider.dart';
 import 'channel_sections/channel_sections_provider.dart';
 import 'channel_sections/channel_sections_storage.dart';
 import 'channels_provider.dart';
@@ -275,6 +276,11 @@ class _SliverChannelsList extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     final readState = ref.watch(readStateProvider);
     final sectionsState = ref.watch(channelSectionsProvider);
+    final mutesState = ref.watch(channelMutesProvider);
+    final mutedChannelIds = {
+      for (final entry in mutesState.store.channels.entries)
+        if (entry.value.muted) entry.key,
+    };
     final visibleChannels = channels
         .where((channel) => channel.isMember && !channel.isArchived)
         .toList();
@@ -331,7 +337,8 @@ class _SliverChannelsList extends HookConsumerWidget {
             for (final channel in visibleChannels)
               if ((seedCompleteForPubkey ||
                       readState.effectiveTimestamp(channel.id) != null) &&
-                  _isUnread(channel, readState))
+                  _isUnread(channel, readState) &&
+                  !mutedChannelIds.contains(channel.id))
                 channel.id,
           }
         : const <String>{};
@@ -378,6 +385,7 @@ class _SliverChannelsList extends HookConsumerWidget {
                     .where((c) => sectionAssignments[c.id] == section.id)
                     .toList(),
                 unreadChannelIds: unreadChannelIds,
+                mutedChannelIds: mutedChannelIds,
                 currentPubkey: currentPubkey,
                 expanded: sectionExpanded(section.id),
                 isFirst: userSections.first.id == section.id,
@@ -452,6 +460,7 @@ class _SliverChannelsList extends HookConsumerWidget {
               onToggle: () => channelsExpanded.value = !channelsExpanded.value,
               channels: ungroupedStreamChannels,
               unreadChannelIds: unreadChannelIds,
+              mutedChannelIds: mutedChannelIds,
               currentPubkey: currentPubkey,
               emptyLabel: 'No stream channels yet',
               onSelectChannel: onSelectChannel,
@@ -463,6 +472,7 @@ class _SliverChannelsList extends HookConsumerWidget {
               onToggle: () => forumsExpanded.value = !forumsExpanded.value,
               channels: forumChannels,
               unreadChannelIds: unreadChannelIds,
+              mutedChannelIds: mutedChannelIds,
               currentPubkey: currentPubkey,
               emptyLabel: 'No forums yet',
               onSelectChannel: onSelectChannel,
@@ -474,6 +484,7 @@ class _SliverChannelsList extends HookConsumerWidget {
               onToggle: () => dmsExpanded.value = !dmsExpanded.value,
               channels: dmChannels,
               unreadChannelIds: unreadChannelIds,
+              mutedChannelIds: mutedChannelIds,
               currentPubkey: currentPubkey,
               emptyLabel: 'No direct messages yet',
               onSelectChannel: onSelectChannel,
@@ -493,6 +504,7 @@ class _CustomChannelSection extends StatelessWidget {
   final ChannelSection section;
   final List<Channel> channels;
   final Set<String> unreadChannelIds;
+  final Set<String> mutedChannelIds;
   final String? currentPubkey;
   final bool expanded;
   final bool isFirst;
@@ -509,6 +521,7 @@ class _CustomChannelSection extends StatelessWidget {
     required this.section,
     required this.channels,
     required this.unreadChannelIds,
+    required this.mutedChannelIds,
     required this.currentPubkey,
     required this.expanded,
     required this.isFirst,
@@ -543,6 +556,7 @@ class _CustomChannelSection extends StatelessWidget {
             _ChannelTile(
               channel: channel,
               isUnread: unreadChannelIds.contains(channel.id),
+              isMuted: mutedChannelIds.contains(channel.id),
               currentPubkey: currentPubkey,
               onTap: () => onSelectChannel(channel),
               onMarkRead: () => onMarkChannelRead(channel),
@@ -716,6 +730,7 @@ class _ChannelSection extends StatelessWidget {
   final VoidCallback onToggle;
   final List<Channel> channels;
   final Set<String> unreadChannelIds;
+  final Set<String> mutedChannelIds;
   final String? currentPubkey;
   final String emptyLabel;
   final Future<void> Function(Channel channel) onSelectChannel;
@@ -727,6 +742,7 @@ class _ChannelSection extends StatelessWidget {
     required this.onToggle,
     required this.channels,
     required this.unreadChannelIds,
+    required this.mutedChannelIds,
     required this.currentPubkey,
     required this.emptyLabel,
     required this.onSelectChannel,
@@ -764,6 +780,7 @@ class _ChannelSection extends StatelessWidget {
               _ChannelTile(
                 channel: channel,
                 isUnread: unreadChannelIds.contains(channel.id),
+                isMuted: mutedChannelIds.contains(channel.id),
                 currentPubkey: currentPubkey,
                 onTap: () => onSelectChannel(channel),
                 onMarkRead: null,
@@ -858,6 +875,7 @@ class _SectionHeader extends StatelessWidget {
 class _ChannelTile extends ConsumerWidget {
   final Channel channel;
   final bool isUnread;
+  final bool isMuted;
   final String? currentPubkey;
   final VoidCallback onTap;
 
@@ -873,6 +891,7 @@ class _ChannelTile extends ConsumerWidget {
     required this.isUnread,
     required this.currentPubkey,
     required this.onTap,
+    this.isMuted = false,
     this.onMarkRead,
     this.sectionId,
   });
@@ -885,84 +904,96 @@ class _ChannelTile extends ConsumerWidget {
       borderRadius: BorderRadius.circular(Radii.md),
       onTap: onTap,
       onLongPress: () => _showChannelActions(context, ref),
-      child: Padding(
-        padding: const EdgeInsets.only(
-          left: Grid.xs + Grid.xxs,
-          right: Grid.xs,
-          top: Grid.xxs + Grid.quarter,
-          bottom: Grid.xxs + Grid.quarter,
-        ),
-        child: Row(
-          children: [
-            if (channel.isDm)
-              _DmAvatar(channel: channel, currentPubkey: currentPubkey)
-            else
-              Icon(
-                channelIcon(channel),
-                size: 18,
-                color: hasActivity
-                    ? context.colors.onSurface
-                    : context.colors.onSurfaceVariant,
-              ),
-            const SizedBox(width: Grid.xxs),
-            Expanded(
-              child: Column(
-                crossAxisAlignment: CrossAxisAlignment.start,
-                children: [
-                  Text(
-                    channel.displayLabel(currentPubkey: currentPubkey),
-                    maxLines: 1,
-                    overflow: TextOverflow.ellipsis,
-                    style: context.textTheme.bodyMedium?.copyWith(
-                      color: isUnread
-                          ? context.colors.onSurface
-                          : hasActivity
-                          ? context.colors.onSurface
-                          : context.colors.onSurfaceVariant,
-                      fontWeight: isUnread ? FontWeight.w700 : null,
-                    ),
-                  ),
-                ],
-              ),
-            ),
-            if (isUnread) ...[
+      child: Opacity(
+        opacity: isMuted ? 0.5 : 1.0,
+        child: Padding(
+          padding: const EdgeInsets.only(
+            left: Grid.xs + Grid.xxs,
+            right: Grid.xs,
+            top: Grid.xxs + Grid.quarter,
+            bottom: Grid.xxs + Grid.quarter,
+          ),
+          child: Row(
+            children: [
+              if (channel.isDm)
+                _DmAvatar(channel: channel, currentPubkey: currentPubkey)
+              else
+                Icon(
+                  channelIcon(channel),
+                  size: 18,
+                  color: hasActivity
+                      ? context.colors.onSurface
+                      : context.colors.onSurfaceVariant,
+                ),
               const SizedBox(width: Grid.xxs),
-              Container(
-                key: Key('channel-unread-${channel.id}'),
-                width: 8,
-                height: 8,
-                decoration: BoxDecoration(
-                  color: context.colors.primary,
-                  shape: BoxShape.circle,
+              Expanded(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      channel.displayLabel(currentPubkey: currentPubkey),
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      style: context.textTheme.bodyMedium?.copyWith(
+                        color: isUnread
+                            ? context.colors.onSurface
+                            : hasActivity
+                            ? context.colors.onSurface
+                            : context.colors.onSurfaceVariant,
+                        fontWeight: isUnread && !isMuted
+                            ? FontWeight.w700
+                            : null,
+                      ),
+                    ),
+                  ],
                 ),
               ),
-            ],
-            if (!channel.isMember && !channel.isDm)
-              Padding(
-                padding: const EdgeInsets.only(right: Grid.xxs),
-                child: Container(
-                  padding: const EdgeInsets.symmetric(
-                    horizontal: Grid.half + 2,
-                    vertical: 3,
-                  ),
+              if (isMuted) ...[
+                const SizedBox(width: Grid.xxs),
+                Icon(
+                  LucideIcons.bellOff,
+                  size: 12,
+                  color: context.colors.onSurfaceVariant,
+                ),
+              ] else if (isUnread) ...[
+                const SizedBox(width: Grid.xxs),
+                Container(
+                  key: Key('channel-unread-${channel.id}'),
+                  width: 8,
+                  height: 8,
                   decoration: BoxDecoration(
-                    color: context.colors.primary.withValues(alpha: 0.1),
-                    borderRadius: BorderRadius.circular(Radii.sm),
+                    color: context.colors.primary,
+                    shape: BoxShape.circle,
                   ),
-                  child: Text(
-                    'Open',
-                    style: context.textTheme.labelSmall?.copyWith(
-                      color: context.colors.primary,
-                      fontWeight: FontWeight.w600,
+                ),
+              ],
+              if (!channel.isMember && !channel.isDm)
+                Padding(
+                  padding: const EdgeInsets.only(right: Grid.xxs),
+                  child: Container(
+                    padding: const EdgeInsets.symmetric(
+                      horizontal: Grid.half + 2,
+                      vertical: 3,
+                    ),
+                    decoration: BoxDecoration(
+                      color: context.colors.primary.withValues(alpha: 0.1),
+                      borderRadius: BorderRadius.circular(Radii.sm),
+                    ),
+                    child: Text(
+                      'Open',
+                      style: context.textTheme.labelSmall?.copyWith(
+                        color: context.colors.primary,
+                        fontWeight: FontWeight.w600,
+                      ),
                     ),
                   ),
                 ),
-              ),
-            if (channel.isEphemeral) ...[
-              const SizedBox(width: Grid.xxs),
-              _EphemeralBadge(channel: channel),
+              if (channel.isEphemeral) ...[
+                const SizedBox(width: Grid.xxs),
+                _EphemeralBadge(channel: channel),
+              ],
             ],
-          ],
+          ),
         ),
       ),
     );
@@ -990,6 +1021,24 @@ class _ChannelTile extends ConsumerWidget {
                     await _showMoveSectionSheet(context, ref, sections);
                   },
                 ),
+                ListTile(
+                  leading: Icon(
+                    isMuted ? LucideIcons.bell : LucideIcons.bellOff,
+                  ),
+                  title: Text(isMuted ? 'Unmute channel' : 'Mute channel'),
+                  onTap: () {
+                    Navigator.of(sheetContext).pop();
+                    if (isMuted) {
+                      ref
+                          .read(channelMutesProvider.notifier)
+                          .unmuteChannel(channel.id);
+                    } else {
+                      ref
+                          .read(channelMutesProvider.notifier)
+                          .muteChannel(channel.id);
+                    }
+                  },
+                ),
                 ListTile(
                   leading: Icon(
                     isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart
index 5e7c21b7a8..876ec55dd7 100644
--- a/mobile/lib/features/channels/manage_channel_sheet.dart
+++ b/mobile/lib/features/channels/manage_channel_sheet.dart
@@ -1,10 +1,12 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
 
 import '../../shared/theme/theme.dart';
 import 'channel.dart';
 import 'channel_management_provider.dart';
+import 'channel_mutes/channel_mutes_provider.dart';
 
 class ManageChannelSheet extends HookConsumerWidget {
   final Channel channel;
@@ -28,6 +30,9 @@ class ManageChannelSheet extends HookConsumerWidget {
       return null;
     }, [canvasAsync.asData?.value.content, isEditingCanvas.value]);
 
+    final mutesState = ref.watch(channelMutesProvider);
+    final isMuted = mutesState.store.channels[channel.id]?.muted == true;
+
     final canJoin =
         channel.visibility == 'open' &&
         !channel.isArchived &&
@@ -120,6 +125,25 @@ class ManageChannelSheet extends HookConsumerWidget {
                 ),
               ),
             ],
+            const SizedBox(height: Grid.xs),
+            SwitchListTile(
+              title: const Text('Mute channel'),
+              subtitle: const Text('Suppress notifications and unread badges'),
+              secondary: const Icon(LucideIcons.bellOff),
+              contentPadding: EdgeInsets.zero,
+              value: isMuted,
+              onChanged: (value) {
+                if (value) {
+                  ref
+                      .read(channelMutesProvider.notifier)
+                      .muteChannel(channel.id);
+                } else {
+                  ref
+                      .read(channelMutesProvider.notifier)
+                      .unmuteChannel(channel.id);
+                }
+              },
+            ),
             if (canJoin || canLeave) ...[
               const SizedBox(height: Grid.xs),
               Wrap(
