mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat: channel muting for desktop and mobile (#838)
This commit is contained in:
42
AGENTS.md
42
AGENTS.md
@@ -321,6 +321,48 @@ Right-click shows "Mark as read".
|
||||
Re-runs for the same PR overwrite previous images. Cleanup:
|
||||
`git push origin --delete agent-screenshots/<username>`.
|
||||
|
||||
### Writing E2E Screenshot Specs
|
||||
|
||||
When screenshots need seeded state, live messages, or UI interaction before
|
||||
capture, write a Playwright spec instead of using `just desktop-screenshot`.
|
||||
Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`
|
||||
(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for
|
||||
mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.
|
||||
|
||||
**Stale server:** `reuseExistingServer: true` means a previous build's server
|
||||
serves old code. Kill port 4173 and `pnpm run build` before re-running tests
|
||||
after code changes.
|
||||
|
||||
**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)
|
||||
must run BEFORE `installMockBridge(page)` — React reads state on mount, the
|
||||
bridge triggers mount.
|
||||
|
||||
**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before
|
||||
`__SPROUT_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a
|
||||
subscription. Navigate to the channel first (triggers subscription), then away
|
||||
(so unread indicators appear), then inject.
|
||||
|
||||
**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
|
||||
resolves mid-animation — wait for completion before screenshotting:
|
||||
|
||||
```ts
|
||||
await menuItem.evaluate((el) =>
|
||||
Promise.all(
|
||||
el.closest("[data-state]")?.getAnimations().map((a) => a.finished) ?? [],
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
|
||||
for sidebar features. Sidebar = 256px; context menus ~450px.
|
||||
|
||||
**`general` has pre-seeded messages** making `hasUnread` always true. Use
|
||||
`engineering` for "muted + no unread" visual states.
|
||||
|
||||
**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with
|
||||
`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line
|
||||
description. See [PR #803](https://github.com/block/sprout/pull/803).
|
||||
|
||||
---
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
244
desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs
Normal file
244
desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs
Normal 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);
|
||||
});
|
||||
106
desktop/src/features/sidebar/lib/channelMutesStorage.ts
Normal file
106
desktop/src/features/sidebar/lib/channelMutesStorage.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
208
desktop/src/features/sidebar/lib/channelMutesSync.ts
Normal file
208
desktop/src/features/sidebar/lib/channelMutesSync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
190
desktop/src/features/sidebar/lib/useChannelMutes.ts
Normal file
190
desktop/src/features/sidebar/lib/useChannelMutes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
189
desktop/tests/e2e/channel-mute-screenshots.spec.ts
Normal file
189
desktop/tests/e2e/channel-mute-screenshots.spec.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user