feat: channel muting for desktop and mobile (#838)

This commit is contained in:
Will Pfleger
2026-06-04 15:53:53 -04:00
committed by GitHub
parent 4ead7de463
commit 1fe7bf2872
28 changed files with 2304 additions and 345 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,
);
});

View File

@@ -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) {
return true;
}
if (isBroadcastReply(event.tags)) {
return true;
}
if (
currentPubkey.length > 0 &&
event.tags.some(
(tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey,
)
) {
if (hasMentionForEvent(event, currentPubkey)) {
return true;
}
if (channelId !== null && mutedChannelIds.has(channelId)) {
return false;
}
if (parentId === null) {
return true;
}

View File

@@ -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,
);
});

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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),
);
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
return result;
} catch {
return null;
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;
}
}
export function cancelPendingPublish(): void {
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
debounceTimer = null;
}
}
export function getPendingStore(): ChannelSectionStore | null {
return pendingStore;
}
export function publishSections(store: ChannelSectionStore): void {
pendingStore = store;
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
}
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);
}
}
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);
}
async fetchRemoteSections(): Promise<RemoteSections | null> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SECTIONS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
},
);
}
export function resetSyncState(): void {
if (debounceTimer !== null) {
window.clearTimeout(debounceTimer);
debounceTimer = null;
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;
}
}
cancelPendingPublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
}
getPendingStore(): ChannelSectionStore | null {
return this.pendingStore;
}
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);
}
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;
}
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);
}
}
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;
}

View File

@@ -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,
};
}

View File

@@ -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;
});
},

View File

@@ -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}

View File

@@ -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={

View File

@@ -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>
) : (

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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 },
});
});
});

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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');
}

View File

@@ -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,

View File

@@ -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(