diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40dd5dd1b1..91543fe47e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ "**/profile-custom-emoji-status.spec.ts", "**/custom-emoji-ui.spec.ts", "**/channel-mute.spec.ts", + "**/channel-notify-settings.spec.ts", "**/channel-star.spec.ts", "**/channel-controls.spec.ts", "**/active-turn-resilience.spec.ts", diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index 73505d1798..6d744a64b0 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts"; +import { DEFAULT_CHANNEL_NOTIFY_STATE } from "../features/notifications/lib/resolveChannelNotifyState.ts"; +import { + feedOwnsThreadReplyNotification, + shouldBounceForChannelNotification, +} from "./AppShell.helpers.ts"; test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); @@ -27,3 +31,83 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => { true, ); }); + +// ── feedOwnsThreadReplyNotification (NIP-CN / NIP-CM live-vs-feed ownership) ── + +const PUBKEY = "ab".padEnd(64, "0"); + +function notifyState(overrides = {}) { + return { ...DEFAULT_CHANNEL_NOTIFY_STATE, ...overrides }; +} + +const NOTIFY_CHANNEL_REPLY = [ + ["h", "channel"], + ["e", "root", "", "reply"], + ["notify", "channel"], +]; + +test("feedOwnsThreadReplyNotification_suppressesMarkerReplyInADefaultChannel", () => { + // The feed will carry this as a mention, so the live banner would be the + // second one for the same event id. + assert.equal( + feedOwnsThreadReplyNotification( + notifyState(), + NOTIFY_CHANNEL_REPLY, + PUBKEY, + ), + true, + ); +}); + +test("feedOwnsThreadReplyNotification_keepsMarkerReplyWhenBroadcastsAreOff", () => { + // The feed suppresses the item, so the single live banner must survive. + assert.equal( + feedOwnsThreadReplyNotification( + notifyState({ broadcasts: false }), + NOTIFY_CHANNEL_REPLY, + PUBKEY, + ), + false, + ); +}); + +test("feedOwnsThreadReplyNotification_keepsMarkerReplyInAMutedChannel", () => { + assert.equal( + feedOwnsThreadReplyNotification( + notifyState({ level: "mute" }), + NOTIFY_CHANNEL_REPLY, + PUBKEY, + ), + false, + ); +}); + +test("feedOwnsThreadReplyNotification_ignoresRepliesWithoutAMarker", () => { + // A plain followed-thread reply is live-only; the feed never sees it. + assert.equal( + feedOwnsThreadReplyNotification( + notifyState(), + [ + ["h", "channel"], + ["e", "root", "", "reply"], + ], + PUBKEY, + ), + false, + ); +}); + +test("feedOwnsThreadReplyNotification_suppressesHereMarkerReply", () => { + assert.equal( + feedOwnsThreadReplyNotification( + notifyState(), + [ + ["h", "channel"], + ["e", "root", "", "reply"], + ["notify", "here"], + ], + PUBKEY, + ), + true, + ); +}); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index b0ce894931..4239078f5b 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -1,5 +1,10 @@ -import { isThreadReply } from "@/features/messages/lib/threading"; +import { + eventNotifyMode, + isThreadReply, +} from "@/features/messages/lib/threading"; import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop"; +import type { ResolvedChannelNotifyState } from "@/features/notifications/lib/resolveChannelNotifyState"; +import { allowsFeedItemForChannel } from "@/features/notifications/lib/shouldNotify"; import type { SearchHit } from "@/shared/api/types"; export type AppView = @@ -86,6 +91,30 @@ export function shouldBounceForChannelNotification(tags: string[][]): boolean { return !isThreadReply(tags); } +/** + * True when a thread reply's *live* desktop banner must be suppressed because + * the Home-feed mention path owns the same event. + * + * Direct `p`-tag mentions are handled by their own guard. This covers the other + * mention-tier source: a NIP-CM `@channel` / `@here` marker. The relay persists + * `@channel` events as feed mentions, so an unguarded marker-carrying reply + * banners twice — once live, once when the mentions feed next polls — with two + * independent dedupe sets that cannot see each other. + * + * The suppression is conditioned on the feed actually accepting the item + * (`allowsFeedItemForChannel`, the ladder's channel dimension): a marker in a + * `broadcasts: false` or muted channel is dropped by the feed, so its single + * live banner must survive. + */ +export function feedOwnsThreadReplyNotification( + state: ResolvedChannelNotifyState, + tags: string[][], + normalizedPubkey: string, +): boolean { + if (eventNotifyMode(tags) === null) return false; + return allowsFeedItemForChannel(state, false, tags, normalizedPubkey); +} + export function toSearchHit( target: DesktopNotificationTarget, ): SearchHit | null { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 877cd948ad..19fe2412a1 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -14,6 +14,7 @@ import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; +import { useChannelNotificationSettings } from "@/app/useChannelNotificationSettings"; import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; @@ -70,7 +71,6 @@ import { useReminderNotifications } from "@/features/reminders/useReminderNotifi import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest"; import { CommunityRail } from "@/features/sidebar/ui/CommunityRail"; -import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; import { useCommunities } from "@/features/communities/useCommunities"; import { @@ -161,8 +161,9 @@ export function AppShell() { const startupReady = useDeferredStartup(); const identityQuery = useIdentityQuery(); - const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( + const channelNotify = useChannelNotificationSettings( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, @@ -310,6 +311,7 @@ export function AppShell() { notificationSettings: notificationSettings.settings, openSearchHit, pubkey: identityQuery.data?.pubkey, + resolveChannelNotify: channelNotify.resolveChannelNotify, }); const { @@ -343,9 +345,10 @@ export function AppShell() { relayClient, relayUrl: communitiesHook.activeCommunity?.relayUrl, currentPubkey: identityQuery.data?.pubkey, - mutedChannelIds, + mutedChannelIds: channelNotify.mutedChannelIds, + channelPrefs: channelNotify.resolveChannelNotify, notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, - onChannelMessage: handleChannelNotification, + onChannelAlert: handleChannelNotification, onDmMessage: handleDmNotification, onLiveMention: refetchHomeFeedFromLiveSignal, onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, @@ -413,12 +416,12 @@ export function AppShell() { readStateVersion, highPriorityUnreadChannelIds, feedProfilesQuery.data?.profiles, - mutedChannelIds, feedItemState.unreadSet, threadActivityFeedItems, getThreadReadAt, getMessageReadAt, channels, + channelNotify.resolveChannelNotify, ); const dueReminderBadge = useDueReminderBadgeCount( @@ -735,6 +738,8 @@ export function AppShell() { threadActivityItems, threadActivityFeedItems, feedItemState, + channelNotify, + mentionUnreadChannelIds: highPriorityUnreadChannelIds, onOpenSettings: handleOpenSettings, }} > @@ -907,9 +912,6 @@ export function AppShell() { selectedView={selectedView} unreadChannelIds={unreadChannelIds} unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} starredChannelIds={starredChannelIds} onStarChannel={starChannel} onUnstarChannel={unstarChannel} diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 4a64de0cb2..058b8fa13d 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -2,6 +2,10 @@ import * as React from "react"; import type { ContextParentResolver } from "@/features/channels/readState/readStateManager"; import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; import type { FeedItemState } from "@/features/home/useFeedItemState"; +import { + DEFAULT_CHANNEL_NOTIFICATION_SETTINGS, + type ChannelNotificationSettings, +} from "@/app/useChannelNotificationSettings"; import type { FeedItem } from "@/shared/api/types"; import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; @@ -47,6 +51,13 @@ type AppShellContextValue = { threadActivityItems: ThreadActivityItem[]; threadActivityFeedItems: FeedItem[]; feedItemState: FeedItemState; + // Per-channel notification preferences (NIP-CN): the one resolver every + // surface reads, plus mutations that own the legacy `channel-mutes` + // dual-write so callers never have to. + channelNotify: ChannelNotificationSettings; + // Channels holding a mention-tier unread. The sidebar's escape hatch for + // "Mute and hide" channels: a hidden channel with a mention stays rendered. + mentionUnreadChannelIds: ReadonlySet; // Open the Settings panel at the given section. Available on all surfaces // that render under AppShell (channel, home, projects, pulse, agents). // Used by config-nudge cards to deep-link to Settings → Agents. @@ -74,6 +85,8 @@ const AppShellContext = React.createContext({ isThreadMuted: () => false, threadActivityItems: [], threadActivityFeedItems: [], + channelNotify: DEFAULT_CHANNEL_NOTIFICATION_SETTINGS, + mentionUnreadChannelIds: EMPTY_SET, feedItemState: { doneSet: EMPTY_SET, markDone: () => {}, diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 2266862cac..ab895f5007 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -1,10 +1,12 @@ import * as React from "react"; import { + feedOwnsThreadReplyNotification, shouldBounceForChannelNotification, toSearchHit, } from "@/app/AppShell.helpers"; import { getThreadReference } from "@/features/messages/lib/threading"; +import type { ResolvedChannelNotifyState } from "@/features/notifications/lib/resolveChannelNotifyState"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { @@ -30,6 +32,7 @@ export function useAppShellDesktopNotifications({ notificationSettings, openSearchHit, pubkey, + resolveChannelNotify, }: { channels: Channel[]; goChannel: (channelId: string) => Promise; @@ -39,11 +42,18 @@ export function useAppShellDesktopNotifications({ hit: import("@/shared/api/types").SearchHit, ) => Promise; pubkey?: string; + /** + * Resolved per-channel notification prefs (NIP-CN). Only `desktop` is read + * here: it silences this channel's banner, sound, and dock bounce on desktop + * clients without changing whether the event counts as unread. + */ + resolveChannelNotify: (channelId: string) => ResolvedChannelNotifyState; }) { const handleChannelNotification = React.useEffectEvent( - (_channelId: string, event: RelayEvent) => { + (channelId: string, event: RelayEvent) => { if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.desktopEnabled) return; + if (!resolveChannelNotify(channelId).desktop) return; void requestDockBounce(); }, ); @@ -52,7 +62,8 @@ export function useAppShellDesktopNotifications({ (event: RelayEvent, channel: Channel) => { if ( !notificationSettings.desktopEnabled || - !notificationSettings.slotAlertsEnabled.dm + !notificationSettings.slotAlertsEnabled.dm || + !resolveChannelNotify(channel.id).desktop ) { return; } @@ -84,9 +95,11 @@ export function useAppShellDesktopNotifications({ const handleThreadReplyDesktopNotification = React.useEffectEvent( (channelId: string, event: RelayEvent) => { + const channelNotify = resolveChannelNotify(channelId); if ( !notificationSettings.desktopEnabled || - !notificationSettings.slotAlertsEnabled.thread_reply + !notificationSettings.slotAlertsEnabled.thread_reply || + !channelNotify.desktop ) { return; } @@ -97,6 +110,16 @@ export function useAppShellDesktopNotifications({ if (hasMentionForEvent(event, normalizedPubkey)) { return; } + // Same for @channel / @here markers the feed will carry as mentions. + if ( + feedOwnsThreadReplyNotification( + channelNotify, + event.tags, + normalizedPubkey, + ) + ) { + return; + } const resolvedChannel = channels.find((c) => c.id === channelId); const channelName = resolvedChannel?.name?.trim() ?? null; diff --git a/desktop/src/app/useChannelNotificationSettings.ts b/desktop/src/app/useChannelNotificationSettings.ts new file mode 100644 index 0000000000..1b7c3e1bc2 --- /dev/null +++ b/desktop/src/app/useChannelNotificationSettings.ts @@ -0,0 +1,121 @@ +import * as React from "react"; + +import { + DEFAULT_CHANNEL_NOTIFY_STATE, + type ResolvedChannelNotifyState, +} from "@/features/notifications/lib/resolveChannelNotifyState"; +import type { ChannelNotifyLevel } from "@/features/sidebar/lib/channelNotifyPrefsStorage"; +import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; +import { + useChannelNotifyPrefs, + type ChannelNotifyAdvancedPatch, +} from "@/features/sidebar/lib/useChannelNotifyPrefs"; +import { useStableSet } from "@/shared/hooks/useStableReference"; + +export type ChannelNotificationSettings = { + /** + * Effective boolean mute: the legacy `channel-mutes` blob unioned with the + * channels the resolver puts at level "mute" (including a running timed + * mute). Consumers that still speak in booleans read this. + */ + mutedChannelIds: ReadonlySet; + /** + * Channels the resolver marks `hidden` ("Mute and hide"): an explicit prefs + * level of "mute". Legacy-only and timed mutes never hide. + */ + hiddenChannelIds: ReadonlySet; + resolveChannelNotify: (channelId: string) => ResolvedChannelNotifyState; + setChannelNotifyLevel: (channelId: string, level: ChannelNotifyLevel) => void; + muteChannelUntil: (channelId: string, untilSeconds: number) => void; + clearChannelTimedMute: (channelId: string) => void; + setChannelNotifyAdvanced: ( + channelId: string, + patch: ChannelNotifyAdvancedPatch, + ) => void; + /** Legacy blob mutations, still wired to the old binary Mute/Unmute items. */ + muteChannel: (channelId: string) => void; + unmuteChannel: (channelId: string) => void; +}; + +/** + * Composes the two synced notification blobs into the single surface AppShell + * threads through the app: the NIP-CN per-channel preferences (kind 30078, + * d-tag `channel-notify-prefs`) resolved against the legacy `channel-mutes` + * blob, plus the NIP-CN dual-write. + */ +export const DEFAULT_CHANNEL_NOTIFICATION_SETTINGS: ChannelNotificationSettings = + Object.freeze({ + mutedChannelIds: new Set(), + hiddenChannelIds: new Set(), + resolveChannelNotify: () => DEFAULT_CHANNEL_NOTIFY_STATE, + setChannelNotifyLevel: () => {}, + muteChannelUntil: () => {}, + clearChannelTimedMute: () => {}, + setChannelNotifyAdvanced: () => {}, + muteChannel: () => {}, + unmuteChannel: () => {}, + }); + +export function useChannelNotificationSettings( + pubkey: string | undefined, + relayUrl: string | undefined, +): ChannelNotificationSettings { + const { + mutedChannelIds: legacyMutedChannelIds, + muteStore: legacyMuteStore, + muteChannel, + unmuteChannel, + } = useChannelMutes(pubkey); + const { prefsStore, resolveChannel, setChannelLevel, ...prefs } = + useChannelNotifyPrefs(pubkey, relayUrl, legacyMuteStore); + + // Dual-write (NIP-CN N2): a level change also moves the legacy mute boolean + // so old clients and mobile keep honoring the mute. Timed mutes are + // deliberately excluded — old clients cannot express them. + const setChannelNotifyLevel = React.useCallback( + (channelId: string, level: ChannelNotifyLevel) => { + setChannelLevel(channelId, level); + if (level === "mute") { + muteChannel(channelId); + } else { + unmuteChannel(channelId); + } + }, + [muteChannel, setChannelLevel, unmuteChannel], + ); + + const mutedChannelIds = useStableSet( + React.useMemo(() => { + const ids = new Set(); + for (const channelId of [ + ...legacyMutedChannelIds, + ...Object.keys(prefsStore.channels), + ]) { + if (resolveChannel(channelId).level === "mute") ids.add(channelId); + } + return ids; + }, [legacyMutedChannelIds, prefsStore.channels, resolveChannel]), + ); + + const hiddenChannelIds = useStableSet( + React.useMemo(() => { + const ids = new Set(); + for (const channelId of Object.keys(prefsStore.channels)) { + if (resolveChannel(channelId).hidden) ids.add(channelId); + } + return ids; + }, [prefsStore.channels, resolveChannel]), + ); + + return { + mutedChannelIds, + hiddenChannelIds, + resolveChannelNotify: resolveChannel, + setChannelNotifyLevel, + muteChannelUntil: prefs.muteChannelUntil, + clearChannelTimedMute: prefs.clearTimedMute, + setChannelNotifyAdvanced: prefs.setChannelAdvanced, + muteChannel, + unmuteChannel, + }; +} diff --git a/desktop/src/features/channels/lib/channelDescription.ts b/desktop/src/features/channels/lib/channelDescription.ts index ee445d9a7f..7810a45135 100644 --- a/desktop/src/features/channels/lib/channelDescription.ts +++ b/desktop/src/features/channels/lib/channelDescription.ts @@ -1,10 +1,22 @@ +import { channelNotifyHeaderSuffix } from "@/features/notifications/lib/channelNotifyLabels"; +import type { ResolvedChannelNotifyState } from "@/features/notifications/lib/resolveChannelNotifyState"; import type { Channel } from "@/shared/api/types"; -export function getChannelDescription(channel: Channel | null): string { +/** + * Header description line for a channel. When `notify` is supplied and the + * channel's NIP-CN level is not the default, the level is appended so the + * header explains why the channel is quiet. + */ +export function getChannelDescription( + channel: Channel | null, + notify?: ResolvedChannelNotifyState | null, +): string { if (!channel) { return "Connect to the relay to browse channels and read messages."; } + const notifySuffix = notify ? channelNotifyHeaderSuffix(notify) : null; + const prefixes = [ channel.archivedAt ? "Archived." : null, !channel.isMember ? "Read-only until you join this open channel." : null, @@ -17,6 +29,8 @@ export function getChannelDescription(channel: Channel | null): string { ); const parts = [...prefixes, detail ?? null].filter(Boolean); + const body = + parts.length > 0 ? parts.join(" ") : "Channel details and activity."; - return parts.length > 0 ? parts.join(" ") : "Channel details and activity."; + return notifySuffix ? `${body} ${notifySuffix}` : body; } diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 3c1727e00a..ecdb386328 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -81,6 +81,7 @@ import { NarrativeField, NarrativeGroup, } from "./ChannelManagementSheetRows"; +import { ChannelNotificationsSection } from "./ChannelNotificationsSection"; import { ChannelManagementModerationActions, useChannelModerationCapabilities, @@ -810,6 +811,10 @@ function ChannelManagementPanelContent({ /> ) : null} + {resolvedChannel.channelType !== "dm" ? ( + + ) : null} + void; + selected: boolean; + testId?: string; +}) { + return ( + + ); +} + +/** A switch row inside a `FieldGroup` — one boolean setting. */ +export function ToggleFieldRow({ + checked, + description, + icon: Icon, + label, + onCheckedChange, + testId, +}: { + checked: boolean; + description?: string; + icon: LucideIcon; + label: string; + onCheckedChange: (checked: boolean) => void; + testId?: string; +}) { + return ( +
+ + + + + + {label} + + {description ? ( + + {description} + + ) : null} + + +
+ ); +} + export function IngressRow({ description, icon: Icon, diff --git a/desktop/src/features/channels/ui/ChannelNotificationsSection.tsx b/desktop/src/features/channels/ui/ChannelNotificationsSection.tsx new file mode 100644 index 0000000000..51d0dd6a89 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelNotificationsSection.tsx @@ -0,0 +1,146 @@ +import { Bell, MessagesSquare, Radio, Settings2 } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import { + CHANNEL_MUTE_PRESETS, + CHANNEL_NOTIFY_LEVEL_OPTIONS, + formatMuteUntil, +} from "@/features/notifications/lib/channelNotifyLabels"; +import { Button } from "@/shared/ui/button"; +import { + ChoiceFieldRow, + FieldGroup, + IngressRow, + ToggleFieldRow, +} from "./ChannelManagementSheetRows"; + +/** + * The channel sheet's member-scoped notification preferences (NIP-CN): level, + * timed mute, and the three per-channel toggles. Deliberately *not* + * admin-gated — every member controls their own notifications. + */ +export function ChannelNotificationsSection({ + channelId, +}: { + channelId: string; +}) { + const { channelNotify, onOpenSettings } = useAppShell(); + const state = channelNotify.resolveChannelNotify(channelId); + + return ( +
+

+ Notifications +

+ + +
+ {CHANNEL_NOTIFY_LEVEL_OPTIONS.map((option) => ( + + channelNotify.setChannelNotifyLevel(channelId, option.value) + } + // A running timed mute is an overlay, not a level: keep the + // stored level visible so expiry has an obvious destination. + selected={!state.timedMuteActive && state.level === option.value} + testId={`channel-notifications-level-${option.value}`} + /> + ))} +
+
+ + +
+ + {state.muteUntil !== null + ? `Muted until ${formatMuteUntil(state.muteUntil)}` + : "Mute temporarily"} + +
+ {state.muteUntil !== null ? ( + + ) : ( + CHANNEL_MUTE_PRESETS.map((preset) => ( + + )) + )} +
+
+
+ + + + channelNotify.setChannelNotifyAdvanced(channelId, { + desktop: checked, + }) + } + testId="channel-notifications-desktop-toggle" + /> + + channelNotify.setChannelNotifyAdvanced(channelId, { + followAllThreads: checked, + }) + } + testId="channel-notifications-threads-toggle" + /> + + channelNotify.setChannelNotifyAdvanced(channelId, { + broadcasts: checked, + }) + } + testId="channel-notifications-broadcasts-toggle" + /> + + + {onOpenSettings ? ( + onOpenSettings("notifications")} + testId="channel-notifications-edit-defaults" + /> + ) : null} +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index a3a8a20231..5888c6f9af 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -1,6 +1,7 @@ import { LogIn } from "lucide-react"; import type * as React from "react"; +import { useAppShell } from "@/app/AppShellContext"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader"; @@ -62,6 +63,12 @@ export function ChannelScreenHeader({ onManageChannel, onToggleMembers, }: ChannelScreenHeaderProps) { + const { channelNotify } = useAppShell(); + // DMs bypass NIP-CN levels entirely, so they never get the suffix. + const notifyState = + activeChannel && activeChannel.channelType !== "dm" + ? channelNotify.resolveChannelNotify(activeChannel.id) + : null; const isGroupDm = activeChannel?.channelType === "dm" && activeDmHeaderParticipants.length > 1; @@ -106,7 +113,7 @@ export function ChannelScreenHeader({ chromeWrapperRef={chromeWrapperRef} actions={actions} channelType={activeChannel?.channelType} - description={getChannelDescription(activeChannel)} + description={getChannelDescription(activeChannel, notifyState)} leadingContent={ activeChannel?.channelType === "dm" ? ( isGroupDm ? ( diff --git a/desktop/src/features/channels/unreadChannelAggregation.test.mjs b/desktop/src/features/channels/unreadChannelAggregation.test.mjs new file mode 100644 index 0000000000..78c60faa43 --- /dev/null +++ b/desktop/src/features/channels/unreadChannelAggregation.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + aggregateUnreadChannels, + makeObservedUnreadEvent, +} from "./unreadChannelCounts.ts"; + +const CHANNEL = "channel-1"; + +function observed(overrides = {}) { + const event = makeObservedUnreadEvent({ + id: overrides.id ?? "event-1", + createdAt: overrides.createdAt ?? 100, + rootId: overrides.rootId ?? null, + highPriority: overrides.highPriority ?? false, + directMention: overrides.directMention ?? false, + channelType: overrides.channelType, + isThreadedReply: overrides.isThreadedReply ?? false, + }); + return [event.id, event]; +} + +function aggregate({ + channels = [{ id: CHANNEL, channelType: "stream" }], + events = [observed()], + forced = [], + muted = [], + activeChannelId = null, +} = {}) { + const byChannel = new Map([[CHANNEL, new Map(events)]]); + return aggregateUnreadChannels({ + channels, + activeChannelId, + hasForcedUnread: (id) => forced.includes(id), + hasObservedLatest: (id) => byChannel.has(id), + getObservedEvents: (id) => byChannel.get(id), + getReadAt: () => () => null, + isMutedChannel: (id) => muted.includes(id), + }); +} + +test("an unmuted channel contributes its dot, badge, and app-badge counts", () => { + const result = aggregate({ events: [observed({ highPriority: true })] }); + assert.deepEqual([...result.unreadChannelIds], [CHANNEL]); + assert.deepEqual([...result.highPriorityUnreadChannelIds], [CHANNEL]); + assert.equal(result.unreadChannelCounts.get(CHANNEL), 1); + assert.equal(result.unreadChannelNotificationCount, 1); +}); + +test("a muted channel contributes nothing for ordinary posts", () => { + const result = aggregate({ muted: [CHANNEL] }); + assert.equal(result.unreadChannelIds.size, 0); + assert.equal(result.unreadChannelCounts.size, 0); + assert.equal(result.unreadChannelNotificationCount, 0); +}); + +test("a muted channel still contributes its mention-tier events", () => { + const result = aggregate({ + muted: [CHANNEL], + events: [ + observed({ id: "plain" }), + observed({ id: "mention", highPriority: true, directMention: true }), + ], + }); + assert.deepEqual([...result.unreadChannelIds], [CHANNEL]); + assert.deepEqual([...result.highPriorityUnreadChannelIds], [CHANNEL]); + // Only the mention counts — the ordinary post stays suppressed. + assert.equal(result.unreadChannelCounts.get(CHANNEL), 1); + assert.equal(result.unreadChannelNotificationCount, 1); +}); + +test("a muted channel drops a frozen broadcast-reply highPriority row", () => { + // Observed at level "all" (so the ladder froze highPriority:true), then the + // user mutes the channel. A broadcast reply / @channel marker is not + // mention-tier below level "all", so it must retire — only direct p-tag + // mentions pierce a mute. + const result = aggregate({ + muted: [CHANNEL], + events: [ + observed({ id: "broadcast-reply", highPriority: true, rootId: "root-1" }), + ], + }); + assert.equal(result.unreadChannelIds.size, 0); + assert.equal(result.highPriorityUnreadChannelIds.size, 0); + assert.equal(result.unreadChannelCounts.size, 0); + assert.equal(result.unreadChannelNotificationCount, 0); +}); + +test("a muted channel counts only the direct mention beside a frozen broadcast row", () => { + const result = aggregate({ + muted: [CHANNEL], + events: [ + observed({ id: "broadcast-reply", highPriority: true }), + observed({ id: "mention", highPriority: true, directMention: true }), + ], + }); + assert.deepEqual([...result.unreadChannelIds], [CHANNEL]); + assert.deepEqual([...result.highPriorityUnreadChannelIds], [CHANNEL]); + assert.equal(result.unreadChannelCounts.get(CHANNEL), 1); + assert.equal(result.unreadChannelNotificationCount, 1); +}); + +test("a muted channel drops its forced-unread dot", () => { + const result = aggregate({ muted: [CHANNEL], forced: [CHANNEL] }); + assert.equal(result.unreadChannelIds.size, 0); +}); + +test("mute never applies to a DM channel", () => { + const result = aggregate({ + channels: [{ id: CHANNEL, channelType: "dm" }], + muted: [CHANNEL], + events: [observed({ channelType: "dm" })], + }); + assert.deepEqual([...result.unreadChannelIds], [CHANNEL]); + assert.deepEqual([...result.highPriorityUnreadChannelIds], [CHANNEL]); +}); + +test("the active channel is always excluded", () => { + const result = aggregate({ activeChannelId: CHANNEL }); + assert.equal(result.unreadChannelIds.size, 0); +}); diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts index cb33c62975..8bf2a91ac0 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -5,6 +5,16 @@ export type ObservedUnreadEvent = { createdAt: number; rootId: string | null; highPriority: boolean; + /** + * True for a direct `p`-tag mention of the current user. Unlike + * `highPriority`, this is state-independent, so it stays correct after the + * channel's NIP-CN level changes — which is what makes it safe to freeze on + * the record. A muted channel's mention tier is keyed off this, not + * `highPriority`: an `@channel` marker or a NIP-CW broadcast reply earns + * `highPriority` only from the level it was observed under, and muting the + * channel afterwards must retire it. + */ + directMention: boolean; countsTowardBadge: boolean; countsTowardAppBadge: boolean; }; @@ -14,6 +24,7 @@ export function makeObservedUnreadEvent(input: { createdAt: number; rootId: string | null; highPriority: boolean; + directMention: boolean; channelType: string | undefined; isThreadedReply: boolean; }): ObservedUnreadEvent { @@ -23,6 +34,7 @@ export function makeObservedUnreadEvent(input: { createdAt: input.createdAt, rootId: input.rootId, highPriority: input.highPriority, + directMention: input.directMention, countsTowardBadge: isDm || input.isThreadedReply || input.highPriority, countsTowardAppBadge: isDm || (!input.isThreadedReply && input.highPriority), @@ -120,6 +132,116 @@ export function countUnreadHighPriorityObservedEvents( return count; } +export function countUnreadDirectMentionObservedEvents( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): number { + if (!eventsById) return 0; + let count = 0; + for (const event of eventsById.values()) { + if (!event.directMention) continue; + const readAt = getReadAt(event); + if (readAt === null || event.createdAt > readAt) count += 1; + } + return count; +} + +export type UnreadAggregation = { + unreadChannelIds: Set; + highPriorityUnreadChannelIds: Set; + unreadChannelCounts: Map; + unreadChannelNotificationCount: number; +}; + +/** + * Project the observed-unread evidence onto the sidebar's per-channel tiers. + * + * Pure: the caller supplies the read markers, the observed events, and the + * NIP-CN mute predicate, which is evaluated against the channel's *current* + * resolved level — so changing a level re-tiers immediately without rewriting + * the frozen `ObservedUnreadEvent` records (and therefore never re-notifies). + * A muted channel contributes only its **direct-mention** events, mirroring the + * sidebar escape hatch that keeps a hidden channel visible while it holds a + * mention. It deliberately does not use the frozen `highPriority` flag: that was + * decided under the level in force when the event arrived, so a broadcast reply + * or `@channel` marker observed at level "all" would otherwise keep badging (and + * un-hiding) the channel after the user mutes it. Direct mentions pierce every + * level, so their frozen flag stays correct forever. + */ +export function aggregateUnreadChannels(input: { + channels: readonly { id: string; channelType?: string }[]; + activeChannelId: string | null; + hasForcedUnread: (channelId: string) => boolean; + hasObservedLatest: (channelId: string) => boolean; + getObservedEvents: ( + channelId: string, + ) => ReadonlyMap | undefined; + getReadAt: ( + channelId: string, + ) => (event: ObservedUnreadEvent) => number | null; + isMutedChannel: (channelId: string) => boolean; +}): UnreadAggregation { + const unread = new Set(); + const highPriority = new Set(); + const counts = new Map(); + let unreadChannelNotificationCount = 0; + + for (const channel of input.channels) { + if (channel.id === input.activeChannelId) continue; + + // DMs bypass NIP-CN levels entirely. + const isMuted = + channel.channelType !== "dm" && input.isMutedChannel(channel.id); + + if (input.hasForcedUnread(channel.id)) { + if (isMuted) continue; + // Forced-unread is dot tier only — not high-priority. + unread.add(channel.id); + counts.set(channel.id, 1); + unreadChannelNotificationCount += 1; + continue; + } + + if (!input.hasObservedLatest(channel.id)) continue; + + const observedEvents = input.getObservedEvents(channel.id); + const readAtFor = input.getReadAt(channel.id); + + if (countUnreadObservedEvents(observedEvents, readAtFor) === 0) continue; + + // Muted channels are tiered on direct mentions only (see the doc comment); + // unmuted channels use the frozen decision from the ladder. + const mentionTierCount = isMuted + ? countUnreadDirectMentionObservedEvents(observedEvents, readAtFor) + : countUnreadHighPriorityObservedEvents(observedEvents, readAtFor); + if (isMuted && mentionTierCount === 0) continue; + + unread.add(channel.id); + counts.set( + channel.id, + isMuted + ? mentionTierCount + : countUnreadBadgeObservedEvents(observedEvents, readAtFor), + ); + unreadChannelNotificationCount += isMuted + ? mentionTierCount + : countUnreadAppBadgeObservedEvents(observedEvents, readAtFor); + + // DM channels: any unread DM is high-priority. Non-DM: only when at least + // one mention/broadcast remains unread in its own channel/thread context. + if (channel.channelType === "dm" || mentionTierCount > 0) { + highPriority.add(channel.id); + } + } + + return { + unreadChannelIds: unread, + highPriorityUnreadChannelIds: highPriority, + unreadChannelCounts: counts, + unreadChannelNotificationCount, + }; +} + export function observedUnreadEventReadAt( event: ObservedUnreadEvent, channelReadAt: number | null, diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index aeb3abb905..89340b6ecf 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -8,7 +8,10 @@ import { getChannelIdFromTags, isThreadReply, } from "@/features/messages/lib/threading"; -import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; +import { + notifyDecisionForEvent, + type ChannelNotifyPrefsLookup, +} from "@/features/notifications/lib/shouldNotify"; import { relayClient } from "@/shared/api/relayClient"; import { CHANNEL_EVENT_KINDS, @@ -40,6 +43,12 @@ export type UseLiveChannelUpdatesOptions = { * See `UNREAD_TRIGGER_KINDS` for the exact kind set. */ onChannelMessage?: (channelId: string, event: RelayEvent) => void; + /** + * Fired for live events that earn an alert (dock bounce tier) rather than + * just an unread mark — the NIP-CN decision's `alert` field. A channel at + * level "mentions" records unread without firing this. + */ + onChannelAlert?: (channelId: string, event: RelayEvent) => void; /** * Fired for thread replies that should be surfaced as Home inbox activity. */ @@ -66,6 +75,8 @@ export type UseLiveChannelUpdatesOptions = { authoredRootIds?: ReadonlySet; mutedRootIds?: ReadonlySet; mutedChannelIds?: ReadonlySet; + /** Resolved per-channel notification prefs (NIP-CN); see `NotifyOptions`. */ + channelPrefs?: ChannelNotifyPrefsLookup; }; const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000; @@ -266,20 +277,17 @@ export function useLiveChannelUpdates( const isThreadedReply = isThreadReply(event.tags); if (isExternalTriggerEvent) { - const shouldNotify = 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, - }, - ); + const decision = notifyDecisionForEvent(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, + channelPrefs: options.channelPrefs, + channelId, + }); - if (!shouldNotify) { + if (!decision.unread) { if (isThreadedReply) { options.onThreadReplyCandidate?.(channelId, event); } @@ -290,8 +298,10 @@ export function useLiveChannelUpdates( } } - if (shouldNotify && isThreadedReply) { + if (decision.alert) { + options.onChannelAlert?.(channelId, event); if ( + isThreadedReply && !dmChannelMap.has(channelId) && (channelId !== activeChannelId || options.notifyForActiveChannel) ) { diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a2376f9b7b..687483f454 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -5,10 +5,7 @@ import { type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; import { - countUnreadAppBadgeObservedEvents, - countUnreadBadgeObservedEvents, - countUnreadHighPriorityObservedEvents, - countUnreadObservedEvents, + aggregateUnreadChannels, makeObservedUnreadEvent, mapsEqual, observedUnreadEventReadAt, @@ -28,8 +25,13 @@ import { import { hasMentionForEvent, isHighPriorityEventForUser, - shouldNotifyForEvent, + notifyDecisionForEvent, + type ChannelNotifyPrefsLookup, } from "@/features/notifications/lib/shouldNotify"; +import { + DEFAULT_CHANNEL_NOTIFY_STATE, + type ResolvedChannelNotifyState, +} from "@/features/notifications/lib/resolveChannelNotifyState"; import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; @@ -143,6 +145,7 @@ export function useUnreadChannels( relayClient, relayUrl: relayUrlOption, mutedChannelIds: mutedChannelIdsOption, + channelPrefs: channelPrefsOption, ...liveUpdateOptions } = options; const activeChannelId = activeChannel?.id ?? null; @@ -239,6 +242,19 @@ export function useUnreadChannels( const mutedChannelIdsRef = React.useRef>(new Set()); mutedChannelIdsRef.current = mutedChannelIdsOption ?? new Set(); + // Same pattern for the NIP-CN prefs lookup: the async catch-up and the live + // callbacks read the latest resolver through the ref instead of taking it as + // a dependency, while `resolveChannelNotify` stays reference-stable. + const channelPrefsRef = React.useRef( + undefined, + ); + channelPrefsRef.current = channelPrefsOption; + const resolveChannelNotify = React.useCallback( + (channelId: string): ResolvedChannelNotifyState => + channelPrefsRef.current?.(channelId) ?? DEFAULT_CHANNEL_NOTIFY_STATE, + [], + ); + // Thread reply events that triggered notifications — surfaced in the Home // activity feed as synthetic FeedItems. const threadActivityRef = React.useRef([]); @@ -403,7 +419,10 @@ export function useUnreadChannels( const isHighPriority = channel?.channelType === "dm" || (normalizedPubkey !== null && - isHighPriorityEventForUser(event, normalizedPubkey)); + isHighPriorityEventForUser(event, normalizedPubkey, { + channelId, + channelPrefs: resolveChannelNotify, + })); const isThreadedReply = getThreadReference(event.tags).parentId !== null && !isBroadcastReply(event.tags); @@ -414,6 +433,9 @@ export function useUnreadChannels( createdAt: event.created_at, rootId: resolveObservedUnreadRootId(event.tags), highPriority: isHighPriority, + directMention: + normalizedPubkey !== null && + hasMentionForEvent(event, normalizedPubkey), channelType: channel?.channelType, isThreadedReply, }), @@ -446,6 +468,7 @@ export function useUnreadChannels( normalizedPubkey, recordMentionedRoot, recordUnreadEvent, + resolveChannelNotify, ], ); @@ -558,6 +581,7 @@ export function useUnreadChannels( authoredRootIds: authoredRootIdsRef.current, mutedRootIds: mutedRootIdsRef.current, mutedChannelIds: mutedChannelIdsRef.current, + channelPrefs: resolveChannelNotify, }); // Effect-key the catch-up on the *set* of channel IDs, not the array @@ -676,14 +700,15 @@ export function useUnreadChannels( const eventChannelId = event.tags.find((t) => t[0] === "h")?.[1] ?? null; if ( - !shouldNotifyForEvent(event, normalizedPubkey ?? "", { + !notifyDecisionForEvent(event, normalizedPubkey ?? "", { participatedRootIds: participatedRootIdsRef.current, followedRootIds: options.followedRootIds ?? EMPTY_SET, authoredRootIds: authoredRootIdsRef.current, mutedRootIds: mutedRootIdsRef.current, mutedChannelIds: mutedChannelIdsRef.current, + channelPrefs: resolveChannelNotify, channelId: eventChannelId, - }) + }).unread ) { continue; } @@ -696,13 +721,19 @@ export function useUnreadChannels( const isHighPriority = chType === "dm" || (normalizedPubkey !== null && - isHighPriorityEventForUser(event, normalizedPubkey)); + isHighPriorityEventForUser(event, normalizedPubkey, { + channelId: eventChannelId, + channelPrefs: resolveChannelNotify, + })); unreadEvents.push( makeObservedUnreadEvent({ id: event.id, createdAt: event.created_at, rootId: resolveObservedUnreadRootId(event.tags), highPriority: isHighPriority, + directMention: + normalizedPubkey !== null && + hasMentionForEvent(event, normalizedPubkey), channelType: chType, isThreadedReply, }), @@ -815,6 +846,7 @@ export function useUnreadChannels( normalizedRelayUrl, recordUnreadEvent, relayClient, + resolveChannelNotify, ]); // Unread = channels (excluding active) that have either been manually @@ -836,82 +868,38 @@ export function useUnreadChannels( }; } - const unread = new Set(); - const highPriority = new Set(); - const counts = new Map(); - let unreadChannelNotificationCount = 0; - - for (const channel of channels) { - if (channel.id === activeChannelId) continue; - - if (Object.hasOwn(forcedUnreadRef.current, channel.id)) { - // Forced-unread is dot tier only — not high-priority. - unread.add(channel.id); - counts.set(channel.id, 1); - unreadChannelNotificationCount += 1; - continue; - } - - if (latestByChannelRef.current.get(channel.id) === undefined) continue; - - const observedEvents = observedUnreadEventsByChannelRef.current.get( - channel.id, - ); - const channelReadAt = getEffectiveTimestamp(channel.id); - const readAtForObservedEvent = (event: ObservedUnreadEvent) => - observedUnreadEventReadAt( - event, - channelReadAt, - (rootId) => getOwnTimestamp(`thread:${rootId}`), - (messageId) => getOwnTimestamp(`msg:${messageId}`), - ); - - const unreadCount = countUnreadObservedEvents( - observedEvents, - readAtForObservedEvent, - ); - if (unreadCount === 0) continue; - - unread.add(channel.id); - const badgeCount = countUnreadBadgeObservedEvents( - observedEvents, - readAtForObservedEvent, - ); - counts.set(channel.id, badgeCount); - unreadChannelNotificationCount += countUnreadAppBadgeObservedEvents( - observedEvents, - readAtForObservedEvent, - ); - - // DM channels: any unread DM is high-priority. - if (channel.channelType === "dm") { - highPriority.add(channel.id); - } else if ( - countUnreadHighPriorityObservedEvents( - observedEvents, - readAtForObservedEvent, - ) > 0 - ) { - // Non-DM: high-priority only if at least one mention/broadcast - // remains unread in its own channel/thread context. - highPriority.add(channel.id); - } - } - - return { - unreadChannelIds: unread, - highPriorityUnreadChannelIds: highPriority, - unreadChannelCounts: counts, - unreadChannelNotificationCount, - }; + return aggregateUnreadChannels({ + channels, + activeChannelId, + hasForcedUnread: (channelId) => + Object.hasOwn(forcedUnreadRef.current, channelId), + hasObservedLatest: (channelId) => + latestByChannelRef.current.get(channelId) !== undefined, + getObservedEvents: (channelId) => + observedUnreadEventsByChannelRef.current.get(channelId), + getReadAt: (channelId) => { + const channelReadAt = getEffectiveTimestamp(channelId); + return (event: ObservedUnreadEvent) => + observedUnreadEventReadAt( + event, + channelReadAt, + (rootId) => getOwnTimestamp(`thread:${rootId}`), + (messageId) => getOwnTimestamp(`msg:${messageId}`), + ); + }, + isMutedChannel: (channelId) => + resolveChannelNotify(channelId).level === "mute", + }); }, [ activeChannelId, channels, + channelPrefsOption, getEffectiveTimestamp, getOwnTimestamp, isReadStateReady, latestVersion, readStateVersion, + resolveChannelNotify, ]); // Stabilize Set references: only replace when contents actually change, diff --git a/desktop/src/features/communities/communityUnreadObserver.test.mjs b/desktop/src/features/communities/communityUnreadObserver.test.mjs index 45c19813d1..d910a234f5 100644 --- a/desktop/src/features/communities/communityUnreadObserver.test.mjs +++ b/desktop/src/features/communities/communityUnreadObserver.test.mjs @@ -148,7 +148,9 @@ test("fetchCommunityUnread returns dot and mention count without total unread co () => [], // 5. mutes events (parallel with read-state) () => [], - // 6. unread events + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events () => [ event({ id: "unread".padEnd(64, "0"), @@ -156,7 +158,7 @@ test("fetchCommunityUnread returns dot and mention count without total unread co tags: [["h", CHANNEL_ID]], }), ], - // 7. mention events + // 8. mention events () => [ event({ id: "mention".padEnd(64, "0"), @@ -245,9 +247,11 @@ test("fetchCommunityUnread ignores self-authored and read thread/message events" ], // 5. mutes events (parallel with read-state) () => [], - // 6. unread events + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events () => [threadReply, selfMention], - // 7. mention events + // 8. mention events () => [threadReply, selfMention], ]); @@ -296,6 +300,8 @@ test("fetchCommunityUnread excludes muted-only channel — returns hasUnread:fal content: mutesContent([MUTED_CHANNEL]), }), ], + // 6. notify-prefs events (parallel with read-state) + () => [], // No per-channel fetches should follow — muted channel is skipped ]); @@ -352,7 +358,9 @@ test("fetchCommunityUnread counts unmuted channel but skips muted channel", asyn content: mutesContent([MUTED_CHANNEL]), }), ], - // 6. unread events for UNMUTED_CHANNEL (muted channel loop iteration never fires) + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events for UNMUTED_CHANNEL (muted channel loop iteration never fires) () => [ event({ id: "unread".padEnd(64, "0"), @@ -360,7 +368,7 @@ test("fetchCommunityUnread counts unmuted channel but skips muted channel", asyn tags: [["h", UNMUTED_CHANNEL]], }), ], - // 7. mention events for UNMUTED_CHANNEL + // 8. mention events for UNMUTED_CHANNEL () => [ event({ id: "mention".padEnd(64, "0"), @@ -416,7 +424,9 @@ test("fetchCommunityUnread treats decryption failure as empty mutes set", async content: "corrupted-ciphertext", }), ], - // 6. unread events — channel is NOT muted (decryption failed → empty set) + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events — channel is NOT muted (decryption failed → empty set) () => [ event({ id: "unread".padEnd(64, "0"), @@ -424,7 +434,7 @@ test("fetchCommunityUnread treats decryption failure as empty mutes set", async tags: [["h", CHANNEL_ID]], }), ], - // 7. mention events + // 8. mention events () => [], ]); @@ -469,7 +479,9 @@ test("fetchCommunityUnread treats absent mutes blob as empty mutes set", async ( () => [], // 5. mutes events — none () => [], - // 6. unread events + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events () => [ event({ id: "unread".padEnd(64, "0"), @@ -477,7 +489,7 @@ test("fetchCommunityUnread treats absent mutes blob as empty mutes set", async ( tags: [["h", CHANNEL_ID]], }), ], - // 7. mention events + // 8. mention events () => [], ]); @@ -537,9 +549,11 @@ function baseRelay(unreadEvent, mutesPayload = null) { // 5. mutes events () => mutesPayload ? [event({ pubkey: PUBKEY, content: mutesPayload })] : [], - // 6. unread events — the single event under test + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events — the single event under test () => [unreadEvent], - // 7. mention events + // 8. mention events () => [], ]); } @@ -671,9 +685,11 @@ function quietRelay() { () => [], // 5. mutes events () => [], - // 6. unread events — none + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events — none () => [], - // 7. mention events — none + // 8. mention events — none () => [], ]); } @@ -720,9 +736,11 @@ function quietRelayWithReadState(readAtSeconds) { ], // 5. mutes events () => [], - // 6. unread events — none (marker covers everything) + // 6. notify-prefs events (parallel with read-state) () => [], - // 7. mention events — none + // 7. unread events — none (marker covers everything) + () => [], + // 8. mention events — none () => [], ]); } @@ -796,7 +814,9 @@ test("fetchCommunityUnread forced-unread channel that is also muted → hasUnrea content: mutesContent([CHANNEL_ID]), }), ], - // No per-channel fetches expected — muted channel is skipped + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. no unread fetch for a muted channel; 8. mention fetch returns nothing ]); const result = await fetchCommunityUnread({ @@ -840,7 +860,9 @@ test("fetchCommunityUnread readForcedUnread returns empty map → falls through () => [], // 5. mutes () => [], - // 6. unread events + // 6. notify-prefs events (parallel with read-state) + () => [], + // 7. unread events () => [ event({ id: "real-unread".padEnd(64, "0"), @@ -848,7 +870,7 @@ test("fetchCommunityUnread readForcedUnread returns empty map → falls through tags: [["h", CHANNEL_ID]], }), ], - // 7. mention events + // 8. mention events () => [], ]); @@ -919,3 +941,189 @@ test("fetchCommunityUnread forced-unread with null baseline + synced marker pres assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); }); + +// ── NIP-CN per-channel notification prefs ────────────────────────────────── + +// Encode a notify-prefs payload (decryptNotifyPrefs stub returns content as-is). +function notifyPrefsContent(channels) { + return JSON.stringify({ version: 1, channels }); +} + +// A relay serving one stream channel plus the read-state / mutes / prefs trio. +// `mutes` and `prefs` are the blob stubs; the per-channel fetches follow. +function relayWithPrefs({ mutes = [], prefs = [], perChannel = [] }) { + return relayFor([ + // 1. member events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events (parallel with visibility) + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["t", "stream"], + ], + }), + ], + // 3. visibility events + () => [], + // 4. read-state events (parallel with mutes) + () => [], + // 5. mutes events + () => mutes, + // 6. notify-prefs events + () => prefs, + ...perChannel, + ]); +} + +function unreadEvent() { + return event({ + id: "unread".padEnd(64, "0"), + created_at: 20, + tags: [["h", CHANNEL_ID]], + }); +} + +function mentionEvent() { + return event({ + id: "mention".padEnd(64, "0"), + created_at: 30, + tags: [ + ["h", CHANNEL_ID], + ["p", PUBKEY], + ], + }); +} + +async function pollWithPrefs(relay) { + return fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + decryptNotifyPrefs: async (v) => v, + readThreadRelationships: readRelationships(), + readForcedUnread: () => ({}), + }); +} + +test("fetchCommunityUnread counts a mention in a muted channel (no dot)", async () => { + const relay = relayWithPrefs({ + mutes: [event({ pubkey: PUBKEY, content: mutesContent([CHANNEL_ID]) })], + // Muted → no unread fetch is issued, so the mention fetch is next in line. + perChannel: [() => [mentionEvent()]], + }); + + assert.deepEqual(await pollWithPrefs(relay), { + hasUnread: true, + mentionCount: 1, + }); +}); + +test("fetchCommunityUnread skips the dot for a prefs level 'mute' channel", async () => { + const relay = relayWithPrefs({ + prefs: [ + event({ + pubkey: PUBKEY, + content: notifyPrefsContent({ + [CHANNEL_ID]: { level: "mute", updatedAt: 10 }, + }), + }), + ], + perChannel: [() => []], + }); + + assert.deepEqual(await pollWithPrefs(relay), { + hasUnread: false, + mentionCount: 0, + }); +}); + +test("fetchCommunityUnread lights the dot at prefs level 'mentions'", async () => { + const relay = relayWithPrefs({ + prefs: [ + event({ + pubkey: PUBKEY, + content: notifyPrefsContent({ + [CHANNEL_ID]: { level: "mentions", updatedAt: 10 }, + }), + }), + ], + perChannel: [() => [unreadEvent()], () => []], + }); + + assert.deepEqual(await pollWithPrefs(relay), { + hasUnread: true, + mentionCount: 0, + }); +}); + +test("fetchCommunityUnread lets a newer legacy unmute revive a stale prefs mute", async () => { + const relay = relayWithPrefs({ + mutes: [ + event({ + pubkey: PUBKEY, + content: JSON.stringify({ + version: 1, + channels: { [CHANNEL_ID]: { muted: false, updatedAt: 20 } }, + }), + }), + ], + prefs: [ + event({ + pubkey: PUBKEY, + content: notifyPrefsContent({ + [CHANNEL_ID]: { level: "mute", updatedAt: 10 }, + }), + }), + ], + perChannel: [() => [unreadEvent()], () => []], + }); + + assert.deepEqual(await pollWithPrefs(relay), { + hasUnread: true, + mentionCount: 0, + }); +}); + +test("fetchCommunityUnread ignores a notify-prefs blob authored by someone else", async () => { + // A relay that ignores the `authors` filter and replays one of the user's own + // older ciphertexts under a foreign key would otherwise roll the prefs back — + // here a stale level "mute" that would silence the community rail. + const relay = relayWithPrefs({ + prefs: [ + event({ + pubkey: "beef".padEnd(64, "0"), + content: notifyPrefsContent({ + [CHANNEL_ID]: { level: "mute", updatedAt: 10 }, + }), + }), + ], + perChannel: [() => [unreadEvent()], () => []], + }); + + assert.deepEqual(await pollWithPrefs(relay), { + hasUnread: true, + mentionCount: 0, + }); +}); + +test("fetchCommunityUnread treats a corrupt notify-prefs blob as no prefs", async () => { + const relay = relayWithPrefs({ + prefs: [event({ pubkey: PUBKEY, content: "not-json" })], + perChannel: [() => [unreadEvent()], () => []], + }); + + assert.deepEqual(await pollWithPrefs(relay), { + hasUnread: true, + mentionCount: 0, + }); +}); diff --git a/desktop/src/features/communities/communityUnreadObserver.ts b/desktop/src/features/communities/communityUnreadObserver.ts index 5bc3298413..65a0ec4cc1 100644 --- a/desktop/src/features/communities/communityUnreadObserver.ts +++ b/desktop/src/features/communities/communityUnreadObserver.ts @@ -13,11 +13,21 @@ import { getThreadReference, isBroadcastReply, } from "@/features/messages/lib/threading"; -import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import { - mutedChannelIdsFromStore, + DEFAULT_CHANNEL_NOTIFY_STATE, + resolveChannelNotifyState, +} from "@/features/notifications/lib/resolveChannelNotifyState"; +import { notifyDecisionForEvent } from "@/features/notifications/lib/shouldNotify"; +import { + DEFAULT_STORE as DEFAULT_LEGACY_MUTE_STORE, parseMutePayload, + type ChannelMuteStore, } from "@/features/sidebar/lib/channelMutesStorage"; +import { + DEFAULT_STORE as DEFAULT_NOTIFY_PREFS_STORE, + parseNotifyPrefsPayload, + type ChannelNotifyPrefsStore, +} from "@/features/sidebar/lib/channelNotifyPrefsStorage"; import type { Community } from "@/features/communities/types"; import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; @@ -27,6 +37,7 @@ import { CHANNEL_MESSAGE_EVENT_KINDS, HOME_MENTION_EVENT_KINDS, KIND_CHANNEL_MUTES, + KIND_CHANNEL_NOTIFY_PREFS, KIND_DM_VISIBILITY, KIND_READ_STATE, } from "@/shared/constants/kinds"; @@ -158,6 +169,7 @@ export async function fetchCommunityUnread(args: { nowSeconds?: number; decryptReadState?: (ciphertext: string) => Promise; decryptMutes?: (ciphertext: string) => Promise; + decryptNotifyPrefs?: (ciphertext: string) => Promise; readThreadRelationships?: (pubkey: string) => ThreadRelationships; readForcedUnread?: (pubkey: string) => ForcedUnreadMap; }): Promise { @@ -165,6 +177,7 @@ export async function fetchCommunityUnread(args: { const normalizedPubkey = pubkey.toLowerCase(); const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000); const decryptMutes = args.decryptMutes ?? nip44DecryptFromSelf; + const decryptNotifyPrefs = args.decryptNotifyPrefs ?? nip44DecryptFromSelf; const readRelationships = args.readThreadRelationships ?? defaultReadThreadRelationships; const readForcedUnread = @@ -175,7 +188,7 @@ export async function fetchCommunityUnread(args: { return { hasUnread: false, mentionCount: 0 }; } - const [readStateEvents, mutesEvents] = await Promise.all([ + const [readStateEvents, mutesEvents, notifyPrefsEvents] = await Promise.all([ client.fetchEvents({ kinds: [KIND_READ_STATE], authors: [pubkey], @@ -189,6 +202,12 @@ export async function fetchCommunityUnread(args: { "#d": ["channel-mutes"], limit: 1, }), + client.fetchEvents({ + kinds: [KIND_CHANNEL_NOTIFY_PREFS], + authors: [pubkey], + "#d": ["channel-notify-prefs"], + limit: 1, + }), ]); const readState = await mergeReadStateEvents( @@ -197,19 +216,32 @@ export async function fetchCommunityUnread(args: { args.decryptReadState, ); - let mutedIds = new Set(); + let legacyMutes: ChannelMuteStore = DEFAULT_LEGACY_MUTE_STORE; if (mutesEvents.length > 0) { try { const plaintext = await decryptMutes(mutesEvents[0].content); - const store = parseMutePayload(JSON.parse(plaintext)); - if (store) { - mutedIds = mutedChannelIdsFromStore(store); - } + legacyMutes = parseMutePayload(JSON.parse(plaintext)) ?? legacyMutes; } catch { // decryption failure → treat as empty mutes set } } + let notifyPrefs: ChannelNotifyPrefsStore = DEFAULT_NOTIFY_PREFS_STORE; + // NIP-CN: ignore any returned event not authored by the user, mirroring + // `ChannelNotifyPrefsSyncManager`. The REQ carries `authors: [pubkey]`, so + // this only bites on a relay that ignores the filter — but a relay that also + // replays one of the user's own older ciphertexts under a foreign key would + // otherwise roll the prefs back (e.g. a stale "mute" silencing the rail). + if (notifyPrefsEvents.length > 0 && notifyPrefsEvents[0].pubkey === pubkey) { + try { + const plaintext = await decryptNotifyPrefs(notifyPrefsEvents[0].content); + notifyPrefs = + parseNotifyPrefsPayload(JSON.parse(plaintext)) ?? notifyPrefs; + } catch { + // decryption failure → treat as no per-channel prefs + } + } + const { participatedRootIds, followedRootIds, @@ -226,7 +258,20 @@ export async function fetchCommunityUnread(args: { let mentionCount = 0; for (const channel of channels) { - if (mutedIds.has(channel.id)) continue; + // NIP-CN: DMs bypass levels; every other channel resolves through the same + // pure resolver the app uses. A muted channel no longer disqualifies the + // whole channel — it only loses the dot. Its direct mentions still count + // toward mentionCount, so a mention in a muted channel lights the rail. + const notify = + channel.channelType === "dm" + ? DEFAULT_CHANNEL_NOTIFY_STATE + : resolveChannelNotifyState( + channel.id, + notifyPrefs, + legacyMutes, + nowSeconds, + ); + const isMutedChannel = notify.level === "mute"; // Compute readAt first so the forced-unread gate can compare against it. const readAt = readState.get(channel.id) ?? null; @@ -237,7 +282,11 @@ export async function fetchCommunityUnread(args: { // read has covered the channel (the drain path in useUnreadChannels only // runs while the community is active, so the store may not be pruned for // inactive communities). - if (!hasUnread && Object.hasOwn(forcedUnreadMap, channel.id)) { + if ( + !hasUnread && + !isMutedChannel && + Object.hasOwn(forcedUnreadMap, channel.id) + ) { const markerAtWhenForced = forcedUnreadMap[channel.id]; if ( readAt === null || @@ -250,14 +299,15 @@ export async function fetchCommunityUnread(args: { const since = readAt === null ? 0 : readAt + 1; const kinds = unreadKindsForChannel(channel.channelType); - const unreadEventsPromise: Promise = hasUnread - ? Promise.resolve([]) - : client.fetchEvents({ - kinds, - "#h": [channel.id], - since, - limit: UNREAD_EXISTENCE_LIMIT, - }); + const unreadEventsPromise: Promise = + hasUnread || isMutedChannel + ? Promise.resolve([]) + : client.fetchEvents({ + kinds, + "#h": [channel.id], + since, + limit: UNREAD_EXISTENCE_LIMIT, + }); const mentionEventsPromise: Promise = client.fetchEvents({ kinds: [...HOME_MENTION_EVENT_KINDS], "#h": [channel.id], @@ -275,14 +325,14 @@ export async function fetchCommunityUnread(args: { hasUnread = unreadEvents.some( (event) => isUnreadExternalEvent(event, readState, readAt, normalizedPubkey) && - shouldNotifyForEvent(event, normalizedPubkey, { + notifyDecisionForEvent(event, normalizedPubkey, { participatedRootIds, followedRootIds, authoredRootIds, mutedRootIds, - mutedChannelIds: mutedIds, + channelPrefs: () => notify, channelId: channel.id, - }), + }).unread, ); } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index abb49485d2..3d81272296 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -23,6 +23,7 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { resetTimedMuteTicker } from "@/features/notifications/lib/timedMuteTicker"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -53,6 +54,7 @@ function resetCommunityState({ resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); + resetTimedMuteTicker(); if (resetAvatarState) { resetAvatarProfileSync(); resetAvatarPresentations(); diff --git a/desktop/src/features/messages/lib/threading.test.mjs b/desktop/src/features/messages/lib/threading.test.mjs new file mode 100644 index 0000000000..75f9d497c4 --- /dev/null +++ b/desktop/src/features/messages/lib/threading.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { eventNotifyMode, isBroadcastReply } from "./threading.ts"; + +// `notify` (channel-wide mention, NIP-CN/#3146) and `broadcast` (NIP-CW reply +// surfaced to the timeline) are separate concepts gated by separate +// preferences, so the two readers must never see each other's tag. + +test("eventNotifyMode reads the channel and here markers", () => { + assert.equal(eventNotifyMode([["notify", "channel"]]), "channel"); + assert.equal(eventNotifyMode([["notify", "here"]]), "here"); + assert.equal( + eventNotifyMode([ + ["h", "chan-1"], + ["notify", "here"], + ]), + "here", + ); +}); + +test("eventNotifyMode ignores absent, empty and unknown markers", () => { + assert.equal(eventNotifyMode([]), null); + assert.equal(eventNotifyMode([["notify"]]), null); + assert.equal(eventNotifyMode([["notify", "everyone"]]), null); + assert.equal(eventNotifyMode([["h", "chan-1"]]), null); +}); + +test("notify and broadcast markers do not alias each other", () => { + assert.equal(eventNotifyMode([["broadcast", "1"]]), null); + assert.equal(isBroadcastReply([["notify", "channel"]]), false); +}); diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index 95694f4989..d438166e5c 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -17,6 +17,25 @@ export function isBroadcastReply(tags: string[][]): boolean { return tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1"); } +/** Channel-wide notification marker of an `@channel` / `@here` post (#3146). */ +export type EventNotifyMode = "channel" | "here"; + +/** + * Read the `["notify","channel"|"here"]` marker from an event's tags. + * + * Deliberately distinct from {@link isBroadcastReply}: that marks a NIP-CW + * thread reply surfaced to the channel timeline, this marks a channel-wide + * mention. The two must never be conflated — they are gated by different + * per-channel preferences (NIP-CN). + */ +export function eventNotifyMode(tags: string[][]): EventNotifyMode | null { + for (const tag of tags) { + if (tag[0] !== "notify") continue; + if (tag[1] === "channel" || tag[1] === "here") return tag[1]; + } + return null; +} + export function isThreadReply(tags: string[][]): boolean { const ref = getThreadReference(tags); return ref.parentId !== null && !isBroadcastReply(tags); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index ccc9544f94..a7aa54788a 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -4,6 +4,11 @@ import { useHomeFeedQuery } from "@/features/home/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { + DEFAULT_CHANNEL_NOTIFY_STATE, + type ResolvedChannelNotifyState, +} from "./lib/resolveChannelNotifyState"; +import { allowsFeedItemForChannel } from "./lib/shouldNotify"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, @@ -364,7 +369,6 @@ export function useHomeFeedNotificationState( readStateVersion: number, highPriorityChannelIds: ReadonlySet, profiles?: UserProfileLookup, - mutedChannelIds?: ReadonlySet, localUnreadFeedIds: ReadonlySet = EMPTY_FEED_ID_SET, extraInboxItems: readonly FeedItem[] = [], getThreadReadAt: ( @@ -377,6 +381,11 @@ export function useHomeFeedNotificationState( // has not been advanced by opening Home. getMessageReadAt: (messageId: string) => number | null = () => null, channels: ReadonlyArray> = [], + // Resolved per-channel notification prefs (NIP-CN). Defaults to the + // all-defaults state so callers that have no prefs store behave as before. + resolveChannelNotify: ( + channelId: string, + ) => ResolvedChannelNotifyState = () => DEFAULT_CHANNEL_NOTIFY_STATE, ) { useFeedDesktopNotifications( feed, @@ -384,8 +393,8 @@ export function useHomeFeedNotificationState( settings, setDesktopEnabled, profiles, - mutedChannelIds, channels, + resolveChannelNotify, ); const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const [seenFeedIds, setSeenFeedIds] = React.useState(() => @@ -441,8 +450,12 @@ export function useHomeFeedNotificationState( } if ( item.channelId && - mutedChannelIds?.has(item.channelId) && - item.category !== "mention" + !allowsFeedItemForChannel( + resolveChannelNotify(item.channelId), + item.category === "mention", + item.tags, + normalizedPubkey, + ) ) { continue; } @@ -477,8 +490,9 @@ export function useHomeFeedNotificationState( highPriorityChannelIds, isHomeActive, localUnreadFeedIds, - mutedChannelIds, + normalizedPubkey, readStateVersion, + resolveChannelNotify, seenFeedIds, settings.homeBadgeEnabled, ]); diff --git a/desktop/src/features/notifications/lib/channelNotifyLabels.test.mjs b/desktop/src/features/notifications/lib/channelNotifyLabels.test.mjs new file mode 100644 index 0000000000..124b87fc81 --- /dev/null +++ b/desktop/src/features/notifications/lib/channelNotifyLabels.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CHANNEL_MUTE_PRESETS, + CHANNEL_NOTIFY_LEVEL_OPTIONS, + channelNotifyHeaderSuffix, + formatMuteUntil, +} from "./channelNotifyLabels.ts"; +import { DEFAULT_CHANNEL_NOTIFY_STATE } from "./resolveChannelNotifyState.ts"; + +test("the level options cover every level exactly once", () => { + assert.deepEqual( + CHANNEL_NOTIFY_LEVEL_OPTIONS.map((option) => option.value), + ["all", "mentions", "mute"], + ); +}); + +test("header suffix is null at the default level", () => { + assert.equal(channelNotifyHeaderSuffix(DEFAULT_CHANNEL_NOTIFY_STATE), null); +}); + +test("header suffix names the non-default levels", () => { + assert.equal( + channelNotifyHeaderSuffix({ + ...DEFAULT_CHANNEL_NOTIFY_STATE, + level: "mentions", + }), + "Notifications: Just mentions", + ); + assert.equal( + channelNotifyHeaderSuffix({ + ...DEFAULT_CHANNEL_NOTIFY_STATE, + level: "mute", + }), + "Notifications: Muted", + ); +}); + +test("mute presets return future timestamps in seconds", () => { + const now = Math.floor(Date.now() / 1_000); + for (const preset of CHANNEL_MUTE_PRESETS) { + assert.ok(preset.getTimestamp() > now, preset.label); + } + assert.ok(CHANNEL_MUTE_PRESETS[0].getTimestamp() - now <= 3_600); +}); + +test("formatMuteUntil omits the weekday for a same-day expiry", () => { + const now = new Date(2026, 0, 5, 8, 0, 0); + const until = new Date(2026, 0, 5, 9, 4, 0); + const formatted = formatMuteUntil(Math.floor(until.getTime() / 1_000), now); + assert.equal( + formatted, + until.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }), + ); +}); + +test("formatMuteUntil includes the weekday once the expiry rolls over", () => { + const now = new Date(2026, 0, 5, 20, 0, 0); + const until = new Date(2026, 0, 6, 9, 0, 0); + const formatted = formatMuteUntil(Math.floor(until.getTime() / 1_000), now); + assert.ok( + formatted.startsWith( + until.toLocaleDateString(undefined, { weekday: "short" }), + ), + formatted, + ); +}); diff --git a/desktop/src/features/notifications/lib/channelNotifyLabels.ts b/desktop/src/features/notifications/lib/channelNotifyLabels.ts new file mode 100644 index 0000000000..97382be03a --- /dev/null +++ b/desktop/src/features/notifications/lib/channelNotifyLabels.ts @@ -0,0 +1,81 @@ +import { inOneHour, nextDayAt9am } from "@/features/reminders/lib/timePresets"; +import type { ResolvedChannelNotifyState } from "@/features/notifications/lib/resolveChannelNotifyState"; +import type { ChannelNotifyLevel } from "@/features/sidebar/lib/channelNotifyPrefsStorage"; + +/** + * User-facing copy for the NIP-CN per-channel notification levels, shared by + * the sidebar context menu and the channel sheet so the two surfaces can never + * drift. Pure and React-free. + */ +export const CHANNEL_NOTIFY_LEVEL_OPTIONS: readonly { + value: ChannelNotifyLevel; + label: string; + description: string; +}[] = [ + { + value: "all", + label: "All new posts", + description: "Mark every new post unread.", + }, + { + value: "mentions", + label: "Just mentions", + description: "Only mentions and followed threads alert you.", + }, + { + value: "mute", + label: "Mute and hide", + description: "Hide the channel from the sidebar until it mentions you.", + }, +]; + +/** + * Timed-mute presets. Both compute an absolute epoch on the setting device, so + * the synced value stays correct on other devices and time zones. + */ +export const CHANNEL_MUTE_PRESETS: readonly { + label: string; + testId: string; + getTimestamp: () => number; +}[] = [ + { + label: "Mute for 1 hour", + testId: "channel-notify-mute-1-hour", + getTimestamp: inOneHour, + }, + { + label: "Mute until tomorrow", + testId: "channel-notify-mute-tomorrow", + getTimestamp: () => nextDayAt9am(1), + }, +]; + +/** + * Sentence appended to the channel header description when the channel's + * notification level is not the default, or null when it is. + */ +export function channelNotifyHeaderSuffix( + state: ResolvedChannelNotifyState, +): string | null { + if (state.level === "mute") return "Notifications: Muted"; + if (state.level === "mentions") return "Notifications: Just mentions"; + return null; +} + +/** + * "Muted until 9:04 AM" caption for a running timed mute. Includes the weekday + * once the expiry falls outside the current local day. + */ +export function formatMuteUntil( + untilSeconds: number, + now: Date = new Date(), +): string { + const until = new Date(untilSeconds * 1_000); + const time = until.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + if (until.toDateString() === now.toDateString()) return time; + const weekday = until.toLocaleDateString(undefined, { weekday: "short" }); + return `${weekday} ${time}`; +} diff --git a/desktop/src/features/notifications/lib/resolveChannelNotifyState.test.mjs b/desktop/src/features/notifications/lib/resolveChannelNotifyState.test.mjs new file mode 100644 index 0000000000..ba7cedaf85 --- /dev/null +++ b/desktop/src/features/notifications/lib/resolveChannelNotifyState.test.mjs @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_CHANNEL_NOTIFY_STATE, + foldLegacyMuteDecision, + nextTimedMuteExpiry, + resolveChannelNotifyState, +} from "./resolveChannelNotifyState.ts"; + +const NOW = 1_000; + +function prefs(entry) { + return { version: 1, channels: entry ? { c: entry } : {} }; +} + +function legacy(entry) { + return { version: 1, channels: entry ? { c: entry } : {} }; +} + +function resolve(prefsEntry, legacyEntry, now = NOW) { + return resolveChannelNotifyState( + "c", + prefs(prefsEntry), + legacy(legacyEntry), + now, + ); +} + +// ── defaults ────────────────────────────────────────────────────────────────── + +test("no entry in either store returns the shared default state", () => { + assert.equal(resolve(null, null), DEFAULT_CHANNEL_NOTIFY_STATE); +}); + +test("an entry with only advanced fields keeps level 'all'", () => { + assert.deepEqual( + resolve({ + desktop: false, + followAllThreads: true, + broadcasts: false, + updatedAt: 1, + }), + { + level: "all", + timedMuteActive: false, + muteUntil: null, + desktop: false, + followAllThreads: true, + broadcasts: false, + hidden: false, + }, + ); +}); + +// ── levels ──────────────────────────────────────────────────────────────────── + +test("level 'mentions' resolves without hiding", () => { + const state = resolve({ level: "mentions", updatedAt: 1 }); + assert.equal(state.level, "mentions"); + assert.equal(state.hidden, false); + assert.equal(state.timedMuteActive, false); +}); + +test("explicit level 'mute' hides the channel", () => { + const state = resolve({ level: "mute", updatedAt: 1 }); + assert.equal(state.level, "mute"); + assert.equal(state.hidden, true); +}); + +test("advanced defaults apply when fields are absent", () => { + const state = resolve({ level: "mentions", updatedAt: 1 }); + assert.equal(state.desktop, true); + assert.equal(state.followAllThreads, false); + assert.equal(state.broadcasts, true); +}); + +// ── timed mute overlay ──────────────────────────────────────────────────────── + +test("a running muteUntil forces level 'mute' without hiding", () => { + const state = resolve({ muteUntil: NOW + 60, updatedAt: 1 }); + assert.equal(state.level, "mute"); + assert.equal(state.timedMuteActive, true); + assert.equal(state.hidden, false); +}); + +test("timed mute overlays 'mentions' and restores it on expiry", () => { + const entry = { level: "mentions", muteUntil: NOW + 60, updatedAt: 1 }; + assert.equal(resolve(entry).level, "mute"); + const expired = resolve(entry, null, NOW + 61); + assert.equal(expired.level, "mentions"); + assert.equal(expired.timedMuteActive, false); +}); + +test("muteUntil exactly at now is already expired", () => { + const state = resolve({ muteUntil: NOW, updatedAt: 1 }); + assert.equal(state.level, "all"); + assert.equal(state.timedMuteActive, false); +}); + +test("muteUntil is exposed while running and null once expired", () => { + const entry = { muteUntil: NOW + 60, updatedAt: 1 }; + assert.equal(resolve(entry).muteUntil, NOW + 60); + assert.equal(resolve(entry, null, NOW + 61).muteUntil, null); +}); + +test("timed mute on an explicitly muted channel keeps hidden true", () => { + const state = resolve({ level: "mute", muteUntil: NOW + 60, updatedAt: 1 }); + assert.equal(state.level, "mute"); + assert.equal(state.timedMuteActive, true); + assert.equal(state.hidden, true); +}); + +// ── legacy channel-mutes interop ────────────────────────────────────────────── + +test("legacy-only mute resolves to level 'mute' but never hides", () => { + const state = resolve(null, { muted: true, updatedAt: 50 }); + assert.equal(state.level, "mute"); + assert.equal(state.hidden, false); +}); + +test("legacy-only unmute leaves the defaults untouched", () => { + const state = resolve(null, { muted: false, updatedAt: 50 }); + assert.equal(state.level, "all"); + assert.equal(state.hidden, false); +}); + +test("newer legacy unmute beats a stale prefs 'mute'", () => { + const state = resolve( + { level: "mute", updatedAt: 10 }, + { muted: false, updatedAt: 20 }, + ); + assert.equal(state.level, "all"); + assert.equal(state.hidden, false); +}); + +test("newer legacy mute overrides prefs 'mentions' without hiding", () => { + const state = resolve( + { level: "mentions", updatedAt: 10 }, + { muted: true, updatedAt: 20 }, + ); + assert.equal(state.level, "mute"); + assert.equal(state.hidden, false); +}); + +test("newer prefs 'mute' beats a stale legacy unmute", () => { + const state = resolve( + { level: "mute", updatedAt: 30 }, + { muted: false, updatedAt: 20 }, + ); + assert.equal(state.level, "mute"); + assert.equal(state.hidden, true); +}); + +test("newer prefs 'mentions' beats a stale legacy mute", () => { + const state = resolve( + { level: "mentions", updatedAt: 30 }, + { muted: true, updatedAt: 20 }, + ); + assert.equal(state.level, "mentions"); +}); + +test("prefs wins ties on the mute dimension", () => { + const state = resolve( + { level: "mentions", updatedAt: 20 }, + { muted: true, updatedAt: 20 }, + ); + assert.equal(state.level, "mentions"); +}); + +test("a newer legacy unmute does not disturb non-mute prefs fields", () => { + const state = resolve( + { level: "mute", desktop: false, followAllThreads: true, updatedAt: 10 }, + { muted: false, updatedAt: 20 }, + ); + assert.equal(state.level, "all"); + assert.equal(state.desktop, false); + assert.equal(state.followAllThreads, true); +}); + +test("a timed mute still applies over a newer legacy unmute", () => { + const state = resolve( + { level: "mute", muteUntil: NOW + 60, updatedAt: 10 }, + { muted: false, updatedAt: 20 }, + ); + assert.equal(state.level, "mute"); + assert.equal(state.timedMuteActive, true); + assert.equal(state.hidden, false); +}); + +test("a newer legacy mute keeps hidden true when prefs explicitly says 'mute'", () => { + const state = resolve( + { level: "mute", updatedAt: 10 }, + { muted: true, updatedAt: 20 }, + ); + assert.equal(state.level, "mute"); + assert.equal(state.hidden, true); +}); + +test("other channels' entries do not leak into the resolved state", () => { + const state = resolveChannelNotifyState( + "c", + { version: 1, channels: { other: { level: "mute", updatedAt: 5 } } }, + { version: 1, channels: { other: { muted: true, updatedAt: 5 } } }, + NOW, + ); + assert.equal(state, DEFAULT_CHANNEL_NOTIFY_STATE); +}); + +// ── foldLegacyMuteDecision (shared read/write interop rule) ─────────────────── + +test("foldLegacyMuteDecision: a newer legacy unmute clears a stale prefs 'mute'", () => { + assert.equal( + foldLegacyMuteDecision( + { level: "mute", updatedAt: 10 }, + { muted: false, updatedAt: 20 }, + ), + "all", + ); +}); + +test("foldLegacyMuteDecision: a newer legacy mute overrides prefs 'mentions'", () => { + assert.equal( + foldLegacyMuteDecision( + { level: "mentions", updatedAt: 10 }, + { muted: true, updatedAt: 20 }, + ), + "mute", + ); +}); + +test("foldLegacyMuteDecision: a newer legacy unmute leaves non-mute levels alone", () => { + assert.equal( + foldLegacyMuteDecision( + { level: "mentions", updatedAt: 10 }, + { muted: false, updatedAt: 20 }, + ), + "mentions", + ); +}); + +test("foldLegacyMuteDecision: prefs newer than legacy keeps the stored level", () => { + assert.equal( + foldLegacyMuteDecision( + { level: "mute", updatedAt: 30 }, + { muted: false, updatedAt: 20 }, + ), + "mute", + ); + // Ties go to prefs. + assert.equal( + foldLegacyMuteDecision( + { level: "mentions", updatedAt: 20 }, + { muted: true, updatedAt: 20 }, + ), + "mentions", + ); +}); + +test("foldLegacyMuteDecision: no legacy entry keeps the stored level", () => { + assert.equal( + foldLegacyMuteDecision({ level: "mentions", updatedAt: 10 }, undefined), + "mentions", + ); + assert.equal(foldLegacyMuteDecision(undefined, undefined), "all"); +}); + +test("foldLegacyMuteDecision: a legacy-only entry decides on its own", () => { + assert.equal( + foldLegacyMuteDecision(undefined, { muted: true, updatedAt: 20 }), + "mute", + ); + assert.equal( + foldLegacyMuteDecision(undefined, { muted: false, updatedAt: 20 }), + "all", + ); +}); + +test("an advanced-toggle write over a newer legacy unmute does not re-mute or hide", () => { + // Composed write path (useChannelNotifyPrefs.updateEntry): seed from the entry + // folded against the legacy blob, apply the patch, stamp a fresh updatedAt. + const raw = { level: "mute", updatedAt: 10 }; + const legacyEntry = { muted: false, updatedAt: 20 }; + const seeded = { ...raw, level: foldLegacyMuteDecision(raw, legacyEntry) }; + const written = { ...seeded, desktop: false, updatedAt: 30 }; + assert.equal(written.level, "all"); + + const state = resolve(written, legacyEntry); + assert.equal(state.level, "all"); + assert.equal(state.hidden, false); + assert.equal(state.desktop, false); +}); + +test("an advanced-toggle write over a newer legacy mute keeps the mute", () => { + const raw = { level: "mentions", updatedAt: 10 }; + const legacyEntry = { muted: true, updatedAt: 20 }; + const seeded = { ...raw, level: foldLegacyMuteDecision(raw, legacyEntry) }; + const written = { ...seeded, desktop: false, updatedAt: 30 }; + assert.equal(written.level, "mute"); + assert.equal(resolve(written, legacyEntry).level, "mute"); +}); + +// ── nextTimedMuteExpiry ─────────────────────────────────────────────────────── + +test("nextTimedMuteExpiry: null when no timed mute is running", () => { + assert.equal(nextTimedMuteExpiry({ version: 1, channels: {} }, NOW), null); + assert.equal( + nextTimedMuteExpiry( + { version: 1, channels: { a: { muteUntil: NOW - 1, updatedAt: 1 } } }, + NOW, + ), + null, + ); +}); + +test("nextTimedMuteExpiry: earliest still-running expiry across channels", () => { + const store = { + version: 1, + channels: { + a: { muteUntil: NOW + 300, updatedAt: 1 }, + b: { muteUntil: NOW + 60, updatedAt: 1 }, + expired: { muteUntil: NOW - 10, updatedAt: 1 }, + none: { level: "mute", updatedAt: 1 }, + }, + }; + assert.equal(nextTimedMuteExpiry(store, NOW), NOW + 60); +}); diff --git a/desktop/src/features/notifications/lib/resolveChannelNotifyState.ts b/desktop/src/features/notifications/lib/resolveChannelNotifyState.ts new file mode 100644 index 0000000000..c46c273c14 --- /dev/null +++ b/desktop/src/features/notifications/lib/resolveChannelNotifyState.ts @@ -0,0 +1,133 @@ +import type { + ChannelMuteEntry, + ChannelMuteStore, +} from "@/features/sidebar/lib/channelMutesStorage"; +import type { + ChannelNotifyEntry, + ChannelNotifyLevel, + ChannelNotifyPrefsStore, +} from "@/features/sidebar/lib/channelNotifyPrefsStorage"; + +/** + * The single resolved notification state for a channel. Every consumer (unread + * aggregation, the notify ladder, delivery sites, the sidebar, the + * cross-community rail observer) reads this instead of re-deriving level or + * expiry logic. Pure and React-free so the non-React observer can use it. + */ +export type ResolvedChannelNotifyState = { + level: ChannelNotifyLevel; + /** True when `level` is "mute" only because a timed mute is still running. */ + timedMuteActive: boolean; + /** Expiry of the running timed mute (Unix seconds), or null when none runs. */ + muteUntil: number | null; + desktop: boolean; + followAllThreads: boolean; + broadcasts: boolean; + /** Hide the channel from sidebar lists ("Mute and hide"). */ + hidden: boolean; +}; + +export const DEFAULT_CHANNEL_NOTIFY_STATE: ResolvedChannelNotifyState = + Object.freeze({ + level: "all" as ChannelNotifyLevel, + timedMuteActive: false, + muteUntil: null, + desktop: true, + followAllThreads: false, + broadcasts: true, + hidden: false, + }); + +/** + * The NIP-CN legacy interop rule (N2) for the **mute dimension only**: a + * `channel-mutes` write that is newer than the prefs entry owns the channel's + * durable mute state, so an unmute performed on an old client (or mobile) beats + * a stale prefs "mute" and a newer legacy mute beats a stale prefs level. Prefs + * wins ties. + * + * Exported because writers need it too: a mutation that reseeds an entry has to + * fold this decision in before stamping a fresh `updatedAt`, otherwise the new + * timestamp flips the tie-break and silently resurrects the level the legacy + * blob had already overruled. + */ +export function foldLegacyMuteDecision( + entry: ChannelNotifyEntry | undefined, + legacy: ChannelMuteEntry | undefined, +): ChannelNotifyLevel { + const stored = entry?.level ?? "all"; + if (!legacy) return stored; + if (entry && legacy.updatedAt <= entry.updatedAt) return stored; + if (legacy.muted) return "mute"; + // Old clients can only express muted/unmuted; a newer unmute clears the mute + // dimension and leaves the channel at the default level. + return stored === "mute" ? "all" : stored; +} + +/** + * Resolve a channel's effective notification state from the prefs blob and the + * legacy `channel-mutes` blob. + * + * Interop rule (NIP-CN): when both stores have an entry for the channel, the + * newer `updatedAt` wins **for the mute dimension only** — an unmute performed + * on an old client (or mobile) must beat a stale prefs "mute", and prefs wins + * ties. A legacy-only mute resolves to level "mute" without `hidden`, so + * channels muted under the old UI never disappear unexpectedly. + * + * `hidden` needs both terms: the prefs entry must explicitly say "mute" AND the + * interop decision must still resolve to "mute". A newer legacy unmute clears + * hiding along with the mute; a newer legacy *mute* leaves hiding in place + * instead of silently downgrading "Mute and hide" to plain mute. + * + * A running `muteUntil` is a lazy overlay: it forces level "mute" without + * touching the stored level, so expiry restores the prior level automatically + * and never hides the channel. + */ +export function resolveChannelNotifyState( + channelId: string, + prefs: ChannelNotifyPrefsStore, + legacyMutes: ChannelMuteStore, + nowSeconds: number, +): ResolvedChannelNotifyState { + const entry = prefs.channels[channelId]; + const legacy = legacyMutes.channels[channelId]; + if (!entry && !legacy) return DEFAULT_CHANNEL_NOTIFY_STATE; + + const storedLevel: ChannelNotifyLevel = entry?.level ?? "all"; + const durableLevel = foldLegacyMuteDecision(entry, legacy); + // Derived from the durable level, before the timed-mute overlay: a timed mute + // must never hide, and it must not resurrect hiding a legacy unmute cleared. + const hidden = + Boolean(entry) && storedLevel === "mute" && durableLevel === "mute"; + + let level = durableLevel; + const timedMuteActive = + entry?.muteUntil !== undefined && entry.muteUntil > nowSeconds; + if (timedMuteActive) level = "mute"; + + return { + level, + timedMuteActive, + muteUntil: timedMuteActive ? (entry?.muteUntil ?? null) : null, + desktop: entry?.desktop ?? true, + followAllThreads: entry?.followAllThreads ?? false, + broadcasts: entry?.broadcasts ?? true, + hidden, + }; +} + +/** + * Earliest still-running `muteUntil` in the store, or null when no timed mute is + * active. Drives the single UI-refresh timer that re-resolves state on expiry. + */ +export function nextTimedMuteExpiry( + prefs: ChannelNotifyPrefsStore, + nowSeconds: number, +): number | null { + let earliest: number | null = null; + for (const entry of Object.values(prefs.channels)) { + const until = entry.muteUntil; + if (until === undefined || until <= nowSeconds) continue; + if (earliest === null || until < earliest) earliest = until; + } + return earliest; +} diff --git a/desktop/src/features/notifications/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs index 2642b9b204..aa0c659fac 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { isHighPriorityEventForUser, - shouldNotifyForEvent, + notifyDecisionForEvent, } from "./shouldNotify.ts"; const PUBKEY = "a".repeat(64); @@ -39,20 +39,23 @@ const opts = (overrides = {}) => ({ ...overrides, }); +const unreadFor = (event, pubkey, options) => + notifyDecisionForEvent(event, pubkey, options).unread; + test("top-level message (no e-tags) notifies", () => { - assert.equal(shouldNotifyForEvent(makeEvent([]), PUBKEY, opts()), true); + assert.equal(unreadFor(makeEvent([]), PUBKEY, opts()), true); }); test("top-level message with unrelated p-tag notifies", () => { assert.equal( - shouldNotifyForEvent(makeEvent([pTag(OTHER_PUBKEY)]), PUBKEY, opts()), + unreadFor(makeEvent([pTag(OTHER_PUBKEY)]), PUBKEY, opts()), true, ); }); test("broadcast reply to unrelated thread notifies", () => { const event = makeEvent([replyTag(ROOT_ID), broadcastTag()]); - assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), true); + assert.equal(unreadFor(event, PUBKEY, opts()), true); }); test("broadcast reply with root+reply tags notifies", () => { @@ -61,7 +64,7 @@ test("broadcast reply with root+reply tags notifies", () => { replyTag(PARENT_ID), broadcastTag(), ]); - assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), true); + assert.equal(unreadFor(event, PUBKEY, opts()), true); }); test("thread reply with p-tag mention of currentPubkey notifies", () => { @@ -70,12 +73,12 @@ test("thread reply with p-tag mention of currentPubkey notifies", () => { replyTag(PARENT_ID), pTag(PUBKEY), ]); - assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), true); + assert.equal(unreadFor(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, opts()), true); + assert.equal(unreadFor(event, PUBKEY, opts()), true); }); test("p-tag mention of a different pubkey does not trigger mention path", () => { @@ -84,17 +87,13 @@ test("p-tag mention of a different pubkey does not trigger mention path", () => replyTag(PARENT_ID), pTag(OTHER_PUBKEY), ]); - assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), false); + assert.equal(unreadFor(event, PUBKEY, opts()), false); }); test("thread reply to participated thread notifies", () => { const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); assert.equal( - shouldNotifyForEvent( - event, - PUBKEY, - opts({ participatedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, PUBKEY, opts({ participatedRootIds: new Set([ROOT_ID]) })), true, ); }); @@ -102,11 +101,7 @@ test("thread reply to participated thread notifies", () => { test("shallow thread reply (root===parent) to participated thread notifies", () => { const event = makeEvent([replyTag(ROOT_ID)]); assert.equal( - shouldNotifyForEvent( - event, - PUBKEY, - opts({ participatedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, PUBKEY, opts({ participatedRootIds: new Set([ROOT_ID]) })), true, ); }); @@ -114,11 +109,7 @@ 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, - opts({ followedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, PUBKEY, opts({ followedRootIds: new Set([ROOT_ID]) })), true, ); }); @@ -126,24 +117,20 @@ 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, - opts({ authoredRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, PUBKEY, opts({ authoredRootIds: new Set([ROOT_ID]) })), true, ); }); test("thread reply to unrelated thread does not notify", () => { const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); - assert.equal(shouldNotifyForEvent(event, PUBKEY, opts()), false); + assert.equal(unreadFor(event, PUBKEY, opts()), false); }); test("muted thread reply suppresses participated", () => { const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); assert.equal( - shouldNotifyForEvent( + unreadFor( event, PUBKEY, opts({ @@ -158,7 +145,7 @@ test("muted thread reply suppresses participated", () => { test("muted thread reply suppresses followed", () => { const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); assert.equal( - shouldNotifyForEvent( + unreadFor( event, PUBKEY, opts({ @@ -173,7 +160,7 @@ test("muted thread reply suppresses followed", () => { test("muted thread reply suppresses authored", () => { const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); assert.equal( - shouldNotifyForEvent( + unreadFor( event, PUBKEY, opts({ @@ -192,11 +179,7 @@ test("muted thread reply still notifies when currentPubkey is mentioned via p-ta pTag(PUBKEY), ]); assert.equal( - shouldNotifyForEvent( - event, - PUBKEY, - opts({ mutedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, PUBKEY, opts({ mutedRootIds: new Set([ROOT_ID]) })), true, ); }); @@ -204,11 +187,7 @@ test("muted thread reply still notifies when currentPubkey is mentioned via p-ta test("muted rootId does not suppress a top-level (non-reply) message", () => { const event = makeEvent([]); assert.equal( - shouldNotifyForEvent( - event, - PUBKEY, - opts({ mutedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, PUBKEY, opts({ mutedRootIds: new Set([ROOT_ID]) })), true, ); }); @@ -216,24 +195,20 @@ 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)]); assert.equal( - shouldNotifyForEvent( - event, - PUBKEY, - opts({ participatedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(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, opts()), false); + assert.equal(unreadFor(event, PUBKEY, opts()), false); }); test("muted shallow thread reply (rootId falls back to parentId) is suppressed", () => { const event = makeEvent([replyTag(ROOT_ID)]); assert.equal( - shouldNotifyForEvent( + unreadFor( event, PUBKEY, opts({ @@ -252,11 +227,7 @@ test("broadcast reply on a muted thread still notifies (broadcast overrides mute broadcastTag(), ]); assert.equal( - shouldNotifyForEvent( - event, - PUBKEY, - opts({ mutedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, PUBKEY, opts({ mutedRootIds: new Set([ROOT_ID]) })), true, ); }); @@ -268,7 +239,7 @@ test("empty currentPubkey skips p-tag check — muted thread is suppressed", () pTag(PUBKEY), ]); assert.equal( - shouldNotifyForEvent( + unreadFor( event, "", opts({ @@ -287,11 +258,7 @@ test("empty currentPubkey with participated thread still notifies (no mute)", () pTag(PUBKEY), ]); assert.equal( - shouldNotifyForEvent( - event, - "", - opts({ participatedRootIds: new Set([ROOT_ID]) }), - ), + unreadFor(event, "", opts({ participatedRootIds: new Set([ROOT_ID]) })), true, ); }); @@ -326,3 +293,56 @@ test("isHighPriorityEventForUser returns false for event with no tags at all", ( const event = makeEvent([]); assert.equal(isHighPriorityEventForUser(event, PUBKEY), false); }); + +// ── decision tiers (channel-blind defaults) ─────────────────────────────────── + +test("top-level post decision alerts without mention tier", () => { + assert.deepEqual(notifyDecisionForEvent(makeEvent([]), PUBKEY, opts()), { + unread: true, + alert: true, + highPriority: false, + }); +}); + +test("p-tag mention decision is the full mention tier", () => { + const event = makeEvent([ + rootTag(ROOT_ID), + replyTag(PARENT_ID), + pTag(PUBKEY), + ]); + assert.deepEqual(notifyDecisionForEvent(event, PUBKEY, opts()), { + unread: true, + alert: true, + highPriority: true, + }); +}); + +test("broadcast reply decision is mention tier at level 'all'", () => { + const event = makeEvent([replyTag(ROOT_ID), broadcastTag()]); + assert.deepEqual(notifyDecisionForEvent(event, PUBKEY, opts()), { + unread: true, + alert: true, + highPriority: true, + }); +}); + +test("followed thread reply decision alerts without mention tier", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.deepEqual( + notifyDecisionForEvent( + event, + PUBKEY, + opts({ followedRootIds: new Set([ROOT_ID]) }), + ), + { unread: true, alert: true, highPriority: false }, + ); +}); + +test("ignored thread reply decision is all-false", () => { + const event = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.deepEqual(notifyDecisionForEvent(event, PUBKEY, opts()), { + unread: false, + alert: false, + highPriority: false, + }); +}); diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 9ceb9c2df8..e0e704f462 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -1,21 +1,44 @@ import type { RelayEvent } from "@/shared/api/types"; import { + eventNotifyMode, getThreadReference, isBroadcastReply, } from "@/features/messages/lib/threading"; +import { + DEFAULT_CHANNEL_NOTIFY_STATE, + type ResolvedChannelNotifyState, +} from "@/features/notifications/lib/resolveChannelNotifyState"; + +/** + * Whether `tags` carry a direct `p`-tag mention of `normalizedPubkey`. Tag + * values are compared case-insensitively; an empty pubkey never matches. Kept + * tags-only so both the event-shaped ladder and the tag-shaped feed predicate + * share one discrimination point. + */ +export function tagsMentionPubkey( + tags: string[][] | undefined, + normalizedPubkey: string, +): boolean { + if (normalizedPubkey.length === 0) return false; + return ( + tags?.some( + (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === normalizedPubkey, + ) ?? false + ); +} export function hasMentionForEvent( event: RelayEvent, currentPubkey: string, ): boolean { - return ( - currentPubkey.length > 0 && - event.tags.some( - (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey, - ) - ); + return tagsMentionPubkey(event.tags, currentPubkey); } +/** Per-channel resolved notification state lookup, injected by the caller. */ +export type ChannelNotifyPrefsLookup = ( + channelId: string, +) => ResolvedChannelNotifyState; + export type NotifyOptions = { participatedRootIds: ReadonlySet; followedRootIds: ReadonlySet; @@ -23,72 +46,175 @@ export type NotifyOptions = { mutedRootIds?: ReadonlySet; mutedChannelIds?: ReadonlySet; channelId?: string | null; + /** + * Resolved per-channel prefs (NIP-CN). When supplied it is authoritative — + * `resolveChannelNotifyState` already folds in the legacy `channel-mutes` + * blob, so `mutedChannelIds` is ignored. + */ + channelPrefs?: ChannelNotifyPrefsLookup; +}; + +/** + * What a single event earns the user, split by tier so a channel can record + * unread without alerting. + */ +export type NotifyDecision = { + /** Marks the channel unread / advances latest-per-channel / counts. */ + unread: boolean; + /** Dock bounce, OS banner, sound tier — still slot-gated at delivery. */ + alert: boolean; + /** Numeric-badge (mention) tier. */ + highPriority: boolean; }; -export function shouldNotifyForEvent( +const NO_NOTIFY: NotifyDecision = Object.freeze({ + unread: false, + alert: false, + highPriority: false, +}); + +const MENTION_NOTIFY: NotifyDecision = Object.freeze({ + unread: true, + alert: true, + highPriority: true, +}); + +type ChannelStateOptions = Pick< + NotifyOptions, + "channelId" | "mutedChannelIds" | "channelPrefs" +>; + +function channelNotifyState( + options: ChannelStateOptions, +): ResolvedChannelNotifyState { + const { channelId = null, channelPrefs, mutedChannelIds } = options; + if (channelId === null) return DEFAULT_CHANNEL_NOTIFY_STATE; + if (channelPrefs) return channelPrefs(channelId); + // Callers not yet threading prefs still express a boolean mute. + if (mutedChannelIds?.has(channelId)) { + return { ...DEFAULT_CHANNEL_NOTIFY_STATE, level: "mute" }; + } + return DEFAULT_CHANNEL_NOTIFY_STATE; +} + +/** + * The normative NIP-CN precedence ladder: the one place level, broadcast + * opt-out, thread-follow and thread-mute semantics are combined. Pure, so the + * non-React cross-community observer uses it too. + * + * Direct `p`-tag mentions pierce every level (Slack keeps their badge); + * `@channel` / `@here` markers are gated by the level and the broadcasts + * opt-out; top-level posts and NIP-CW broadcast replies sit under the level + * gate; thread replies need a follow (explicit, participation, authorship or + * the channel's "follow every thread") and lose to both mutes. + */ +export function notifyDecisionForEvent( event: RelayEvent, currentPubkey: string, options: NotifyOptions, -): boolean { +): NotifyDecision { const { participatedRootIds, followedRootIds, authoredRootIds, mutedRootIds = new Set(), - mutedChannelIds = new Set(), - channelId = null, } = options; const { parentId, rootId } = getThreadReference(event.tags); - - if (isBroadcastReply(event.tags)) { - return true; - } + const state = channelNotifyState(options); if (hasMentionForEvent(event, currentPubkey)) { - return true; + return MENTION_NOTIFY; } - if (channelId !== null && mutedChannelIds.has(channelId)) { - return false; + // Trust seam: the `notify` marker is validated by the relay at ingest + // (NIP-CM — channel membership plus a kind allowlist), not verified here. This + // client re-reads the tag at notify time and cannot re-check membership, so an + // event stored by a relay predating that validation is trusted for good. + if ( + eventNotifyMode(event.tags) !== null && + state.level !== "mute" && + state.broadcasts + ) { + return MENTION_NOTIFY; } - if (parentId === null) { - return true; + const broadcastReply = isBroadcastReply(event.tags); + if (parentId === null || broadcastReply) { + if (state.level === "mute") return NO_NOTIFY; + if (state.level === "mentions") { + return { unread: true, alert: false, highPriority: false }; + } + return { unread: true, alert: true, highPriority: broadcastReply }; } - if (rootId !== null && mutedRootIds.has(rootId)) { - return false; - } + if (rootId !== null && mutedRootIds.has(rootId)) return NO_NOTIFY; - if (rootId !== null && participatedRootIds.has(rootId)) { - return true; - } + const followsThread = + state.followAllThreads || + (rootId !== null && + (participatedRootIds.has(rootId) || + followedRootIds.has(rootId) || + authoredRootIds.has(rootId))); + if (!followsThread || state.level === "mute") return NO_NOTIFY; - if (rootId !== null && followedRootIds.has(rootId)) { - return true; - } + return { unread: true, alert: true, highPriority: false }; +} - if (rootId !== null && authoredRootIds.has(rootId)) { - return true; +/** + * Channel gate for a Home-feed item (badge counts and feed-driven desktop + * banners). The feed has no event graph, so it cannot run the full ladder — + * this is the ladder's channel dimension expressed over what a `FeedItem` + * carries: its `notify` marker tags and whether the relay categorised it as a + * mention. + * + * Evaluated in the ladder's order, first match wins: + * + * 1. a direct `p`-tag mention of `currentPubkey` pierces every level — even + * when the same item also carries an `@channel` / `@here` marker; + * 2. otherwise `@channel` / `@here` items obey the level and the broadcasts + * opt-out (NIP-CN N7) instead of riding the mention exemption; + * 3. otherwise a relay-categorised mention passes (the feed may know the item + * is a mention without exposing the tags that prove it); + * 4. otherwise the item is suppressed while the channel resolves to "mute". + * + * `isMentionCategory` is passed in because the feed's category taxonomy is + * spelled differently at different call sites. `currentPubkey` must already be + * normalized (trimmed, lowercased) or "". + */ +export function allowsFeedItemForChannel( + state: ResolvedChannelNotifyState, + isMentionCategory: boolean, + tags: string[][] | undefined, + currentPubkey: string, +): boolean { + if (tagsMentionPubkey(tags, currentPubkey)) return true; + if (eventNotifyMode(tags ?? []) !== null) { + return state.level !== "mute" && state.broadcasts; } - - return false; + if (isMentionCategory) return true; + return state.level !== "mute"; } +/** + * Mention-tier classification for badge counts. Agrees with + * `notifyDecisionForEvent(...).highPriority` — channel-blind callers get the + * default state, so an `@channel` in an opted-out or muted channel and a + * broadcast reply below level "all" are no longer badged. + */ export function isHighPriorityEventForUser( event: RelayEvent, currentPubkey: string, + options: ChannelStateOptions = {}, ): boolean { - if ( - currentPubkey.length > 0 && - event.tags.some( - (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey, - ) - ) { + if (hasMentionForEvent(event, currentPubkey)) { return true; } + const state = channelNotifyState(options); + if (eventNotifyMode(event.tags) !== null) { + return state.level !== "mute" && state.broadcasts; + } if (isBroadcastReply(event.tags)) { - return true; + return state.level === "all"; } return false; } diff --git a/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs b/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs index 9f94135550..00f5130088 100644 --- a/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { hasMentionForEvent, shouldNotifyForEvent } from "./shouldNotify.ts"; +import { + allowsFeedItemForChannel, + hasMentionForEvent, + isHighPriorityEventForUser, + notifyDecisionForEvent, + tagsMentionPubkey, +} from "./shouldNotify.ts"; +import { resolveChannelNotifyState } from "./resolveChannelNotifyState.ts"; const PUBKEY = "a".repeat(64); const OTHER_PUBKEY = "b".repeat(64); @@ -12,6 +19,9 @@ const PARENT_ID = `parent-${"0".repeat(57)}`; const EMPTY = new Set(); +const unreadFor = (event, pubkey, options) => + notifyDecisionForEvent(event, pubkey, options).unread; + function makeEvent(tags = [], overrides = {}) { return { id: `event-${"0".repeat(59)}`, @@ -56,10 +66,26 @@ test("hasMentionForEvent: empty currentPubkey returns false", () => { assert.equal(hasMentionForEvent(event, ""), false); }); +test("tagsMentionPubkey: matches a p-tag case-insensitively", () => { + assert.equal(tagsMentionPubkey([pTag(PUBKEY)], PUBKEY), true); + assert.equal(tagsMentionPubkey([pTag(PUBKEY.toUpperCase())], PUBKEY), true); +}); + +test("tagsMentionPubkey: no p-tag for the reader returns false", () => { + assert.equal(tagsMentionPubkey([pTag(OTHER_PUBKEY)], PUBKEY), false); + assert.equal(tagsMentionPubkey([hTag(CHANNEL_ID)], PUBKEY), false); + assert.equal(tagsMentionPubkey([], PUBKEY), false); + assert.equal(tagsMentionPubkey(undefined, PUBKEY), false); +}); + +test("tagsMentionPubkey: an empty pubkey never matches", () => { + assert.equal(tagsMentionPubkey([pTag(PUBKEY)], ""), false); +}); + test("top-level message in muted channel is suppressed", () => { const event = makeEvent([hTag(CHANNEL_ID)]); assert.equal( - shouldNotifyForEvent(event, PUBKEY, { + unreadFor(event, PUBKEY, { participatedRootIds: EMPTY, followedRootIds: EMPTY, authoredRootIds: EMPTY, @@ -73,7 +99,7 @@ test("top-level message in muted channel is suppressed", () => { 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, { + unreadFor(event, PUBKEY, { participatedRootIds: EMPTY, followedRootIds: EMPTY, authoredRootIds: EMPTY, @@ -91,7 +117,7 @@ test("thread reply in muted channel is suppressed", () => { replyTag(PARENT_ID), ]); assert.equal( - shouldNotifyForEvent(event, PUBKEY, { + unreadFor(event, PUBKEY, { participatedRootIds: new Set([ROOT_ID]), followedRootIds: EMPTY, authoredRootIds: EMPTY, @@ -102,28 +128,28 @@ test("thread reply in muted channel is suppressed", () => { ); }); -test("broadcast reply in muted channel still notifies (broadcast fires before mute check)", () => { +test("broadcast reply in muted channel is suppressed (NIP-CN: mute beats broadcast)", () => { const event = makeEvent([ hTag(CHANNEL_ID), replyTag(ROOT_ID), broadcastTag(), ]); assert.equal( - shouldNotifyForEvent(event, PUBKEY, { + unreadFor(event, PUBKEY, { participatedRootIds: EMPTY, followedRootIds: EMPTY, authoredRootIds: EMPTY, mutedChannelIds: new Set([CHANNEL_ID]), channelId: CHANNEL_ID, }), - true, + false, ); }); test("top-level message in unmuted channel notifies", () => { const event = makeEvent([hTag(CHANNEL_ID)]); assert.equal( - shouldNotifyForEvent(event, PUBKEY, { + unreadFor(event, PUBKEY, { participatedRootIds: EMPTY, followedRootIds: EMPTY, authoredRootIds: EMPTY, @@ -137,7 +163,7 @@ 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, { + unreadFor(event, PUBKEY, { participatedRootIds: EMPTY, followedRootIds: EMPTY, authoredRootIds: EMPTY, @@ -155,7 +181,7 @@ test("thread in mutedRootIds AND in muted channel is suppressed", () => { ]); // Both the root thread and the channel are muted; mute channel check fires first assert.equal( - shouldNotifyForEvent(event, PUBKEY, { + unreadFor(event, PUBKEY, { participatedRootIds: new Set([ROOT_ID]), followedRootIds: EMPTY, authoredRootIds: EMPTY, @@ -166,3 +192,442 @@ test("thread in mutedRootIds AND in muted channel is suppressed", () => { false, ); }); + +// ── NIP-CN per-channel levels ───────────────────────────────────────────────── + +const NOW = 1_000; +const notifyTag = (mode) => ["notify", mode]; + +/** Real resolver over a single-channel prefs + legacy pair, as AppShell wires it. */ +const prefsLookup = + (entry, legacyEntry = null, now = NOW) => + (channelId) => + resolveChannelNotifyState( + channelId, + { version: 1, channels: entry ? { [CHANNEL_ID]: entry } : {} }, + { + version: 1, + channels: legacyEntry ? { [CHANNEL_ID]: legacyEntry } : {}, + }, + now, + ); + +const decide = (event, options = {}) => + notifyDecisionForEvent(event, PUBKEY, { + participatedRootIds: EMPTY, + followedRootIds: EMPTY, + authoredRootIds: EMPTY, + channelId: CHANNEL_ID, + ...options, + }); + +const level = (value) => ({ level: value, updatedAt: 1 }); +const NONE = { unread: false, alert: false, highPriority: false }; +const ALERT = { unread: true, alert: true, highPriority: false }; +const QUIET = { unread: true, alert: false, highPriority: false }; +const MENTION = { unread: true, alert: true, highPriority: true }; + +const topLevel = () => makeEvent([hTag(CHANNEL_ID)]); +const broadcast = () => + makeEvent([ + hTag(CHANNEL_ID), + rootTag(ROOT_ID), + replyTag(PARENT_ID), + broadcastTag(), + ]); +const threadReply = () => + makeEvent([hTag(CHANNEL_ID), rootTag(ROOT_ID), replyTag(PARENT_ID)]); +const channelMention = (mode = "channel") => + makeEvent([hTag(CHANNEL_ID), notifyTag(mode)]); + +test("top-level post: level 'all' alerts, 'mentions' is quiet, 'mute' is silent", () => { + assert.deepEqual( + decide(topLevel(), { channelPrefs: prefsLookup(level("all")) }), + ALERT, + ); + assert.deepEqual( + decide(topLevel(), { channelPrefs: prefsLookup(level("mentions")) }), + QUIET, + ); + assert.deepEqual( + decide(topLevel(), { channelPrefs: prefsLookup(level("mute")) }), + NONE, + ); +}); + +test("broadcast reply follows the level like a top-level post", () => { + assert.deepEqual( + decide(broadcast(), { channelPrefs: prefsLookup(level("all")) }), + MENTION, + ); + assert.deepEqual( + decide(broadcast(), { channelPrefs: prefsLookup(level("mentions")) }), + QUIET, + ); + assert.deepEqual( + decide(broadcast(), { channelPrefs: prefsLookup(level("mute")) }), + NONE, + ); +}); + +test("direct p-tag mention pierces every level", () => { + const event = makeEvent([hTag(CHANNEL_ID), pTag(PUBKEY)]); + for (const value of ["all", "mentions", "mute"]) { + assert.deepEqual( + decide(event, { channelPrefs: prefsLookup(level(value)) }), + MENTION, + ); + } +}); + +test("@channel / @here is mention tier at levels 'all' and 'mentions'", () => { + for (const mode of ["channel", "here"]) { + assert.deepEqual( + decide(channelMention(mode), { channelPrefs: prefsLookup(level("all")) }), + MENTION, + ); + assert.deepEqual( + decide(channelMention(mode), { + channelPrefs: prefsLookup(level("mentions")), + }), + MENTION, + ); + } +}); + +test("@channel in a muted channel is silent, not mention tier", () => { + assert.deepEqual( + decide(channelMention(), { channelPrefs: prefsLookup(level("mute")) }), + NONE, + ); +}); + +test("broadcasts opt-out demotes @channel to an ordinary post", () => { + assert.deepEqual( + decide(channelMention(), { + channelPrefs: prefsLookup({ broadcasts: false, updatedAt: 1 }), + }), + ALERT, + ); + assert.deepEqual( + decide(channelMention(), { + channelPrefs: prefsLookup({ + level: "mentions", + broadcasts: false, + updatedAt: 1, + }), + }), + QUIET, + ); +}); + +test("broadcasts opt-out does not gate NIP-CW broadcast replies", () => { + assert.deepEqual( + decide(broadcast(), { + channelPrefs: prefsLookup({ broadcasts: false, updatedAt: 1 }), + }), + MENTION, + ); +}); + +test("an unknown notify value is not treated as a channel mention", () => { + const event = makeEvent([hTag(CHANNEL_ID), notifyTag("someone-else")]); + assert.deepEqual( + decide(event, { channelPrefs: prefsLookup(level("mentions")) }), + QUIET, + ); +}); + +test("timed mute silences the channel until it expires", () => { + const entry = { muteUntil: NOW + 60, updatedAt: 1 }; + assert.deepEqual( + decide(topLevel(), { channelPrefs: prefsLookup(entry) }), + NONE, + ); + assert.deepEqual( + decide(topLevel(), { channelPrefs: prefsLookup(entry, null, NOW + 61) }), + ALERT, + ); +}); + +test("timed mute restores the stored level on expiry", () => { + const entry = { level: "mentions", muteUntil: NOW + 60, updatedAt: 1 }; + assert.deepEqual( + decide(topLevel(), { channelPrefs: prefsLookup(entry) }), + NONE, + ); + assert.deepEqual( + decide(topLevel(), { channelPrefs: prefsLookup(entry, null, NOW + 61) }), + QUIET, + ); +}); + +test("followAllThreads notifies replies to threads the user never touched", () => { + const entry = { followAllThreads: true, updatedAt: 1 }; + assert.deepEqual( + decide(threadReply(), { channelPrefs: prefsLookup(entry) }), + ALERT, + ); + assert.deepEqual( + decide(threadReply(), { channelPrefs: prefsLookup(level("all")) }), + NONE, + ); +}); + +test("followAllThreads loses to a thread mute and to channel mute", () => { + assert.deepEqual( + decide(threadReply(), { + channelPrefs: prefsLookup({ followAllThreads: true, updatedAt: 1 }), + mutedRootIds: new Set([ROOT_ID]), + }), + NONE, + ); + assert.deepEqual( + decide(threadReply(), { + channelPrefs: prefsLookup({ + level: "mute", + followAllThreads: true, + updatedAt: 1, + }), + }), + NONE, + ); +}); + +test("explicit thread follows still alert at level 'mentions'", () => { + assert.deepEqual( + decide(threadReply(), { + channelPrefs: prefsLookup(level("mentions")), + followedRootIds: new Set([ROOT_ID]), + }), + ALERT, + ); +}); + +test("channel mute beats thread participation", () => { + assert.deepEqual( + decide(threadReply(), { + channelPrefs: prefsLookup(level("mute")), + participatedRootIds: new Set([ROOT_ID]), + }), + NONE, + ); +}); + +test("legacy interop: a newer legacy mute silences prefs level 'mentions'", () => { + assert.deepEqual( + decide(topLevel(), { + channelPrefs: prefsLookup( + { level: "mentions", updatedAt: 10 }, + { muted: true, updatedAt: 20 }, + ), + }), + NONE, + ); +}); + +test("legacy interop: a newer legacy unmute revives a stale prefs mute", () => { + assert.deepEqual( + decide(topLevel(), { + channelPrefs: prefsLookup( + { level: "mute", updatedAt: 10 }, + { muted: false, updatedAt: 20 }, + ), + }), + ALERT, + ); +}); + +test("channelPrefs is authoritative over the legacy mutedChannelIds set", () => { + assert.deepEqual( + decide(topLevel(), { + channelPrefs: prefsLookup(level("all")), + mutedChannelIds: new Set([CHANNEL_ID]), + }), + ALERT, + ); +}); + +// ── isHighPriorityEventForUser ──────────────────────────────────────────────── + +test("isHighPriorityEventForUser: @channel is mention tier only when heard", () => { + const event = channelMention(); + const at = (entry) => + isHighPriorityEventForUser(event, PUBKEY, { + channelId: CHANNEL_ID, + channelPrefs: prefsLookup(entry), + }); + assert.equal(at(level("all")), true); + assert.equal(at(level("mentions")), true); + assert.equal(at(level("mute")), false); + assert.equal(at({ broadcasts: false, updatedAt: 1 }), false); +}); + +test("isHighPriorityEventForUser: broadcast reply is mention tier only at level 'all'", () => { + const event = broadcast(); + const at = (entry) => + isHighPriorityEventForUser(event, PUBKEY, { + channelId: CHANNEL_ID, + channelPrefs: prefsLookup(entry), + }); + assert.equal(at(level("all")), true); + assert.equal(at(level("mentions")), false); + assert.equal(at(level("mute")), false); +}); + +test("isHighPriorityEventForUser: p-tag mention stays mention tier in a muted channel", () => { + const event = makeEvent([hTag(CHANNEL_ID), pTag(PUBKEY)]); + assert.equal( + isHighPriorityEventForUser(event, PUBKEY, { + channelId: CHANNEL_ID, + channelPrefs: prefsLookup(level("mute")), + }), + true, + ); +}); + +test("isHighPriorityEventForUser: legacy mutedChannelIds demotes a broadcast reply", () => { + const event = broadcast(); + assert.equal( + isHighPriorityEventForUser(event, PUBKEY, { + channelId: CHANNEL_ID, + mutedChannelIds: new Set([CHANNEL_ID]), + }), + false, + ); + assert.equal(isHighPriorityEventForUser(event, PUBKEY), true); +}); + +// ── allowsFeedItemForChannel (Home feed / badge seam) ───────────────────────── + +const feedState = (overrides = {}) => ({ + level: "all", + timedMuteActive: false, + desktop: true, + followAllThreads: false, + broadcasts: true, + hidden: false, + ...overrides, +}); + +test("allowsFeedItemForChannel: ordinary item is dropped only while muted", () => { + assert.equal(allowsFeedItemForChannel(feedState(), false, [], PUBKEY), true); + assert.equal( + allowsFeedItemForChannel( + feedState({ level: "mentions" }), + false, + [], + PUBKEY, + ), + true, + ); + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), false, [], PUBKEY), + false, + ); +}); + +test("allowsFeedItemForChannel: a direct mention pierces the mute", () => { + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), true, [], PUBKEY), + true, + ); + assert.equal( + allowsFeedItemForChannel( + feedState({ level: "mute" }), + false, + [pTag(PUBKEY)], + PUBKEY, + ), + true, + ); +}); + +test("allowsFeedItemForChannel: a notify-tag item obeys the level, not the mention exemption", () => { + const tags = [["notify", "channel"]]; + assert.equal(allowsFeedItemForChannel(feedState(), true, tags, PUBKEY), true); + assert.equal( + allowsFeedItemForChannel( + feedState({ level: "mentions" }), + true, + tags, + PUBKEY, + ), + true, + ); + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), true, tags, PUBKEY), + false, + ); +}); + +test("allowsFeedItemForChannel: the broadcasts opt-out drops notify-tag items", () => { + assert.equal( + allowsFeedItemForChannel( + feedState({ broadcasts: false }), + true, + [["notify", "here"]], + PUBKEY, + ), + false, + ); +}); + +test("allowsFeedItemForChannel: a notify item that also p-tags the reader pierces the mute", () => { + const tags = [["notify", "channel"], pTag(PUBKEY)]; + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), true, tags, PUBKEY), + true, + ); + // The relay's category is irrelevant — the p-tag alone carries the rung. + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), false, tags, PUBKEY), + true, + ); +}); + +test("allowsFeedItemForChannel: a notify item that also p-tags the reader survives the broadcasts opt-out", () => { + assert.equal( + allowsFeedItemForChannel( + feedState({ broadcasts: false }), + true, + [["notify", "here"], pTag(PUBKEY)], + PUBKEY, + ), + true, + ); + assert.equal( + allowsFeedItemForChannel( + feedState({ level: "mute", broadcasts: false }), + true, + [["notify", "here"], pTag(PUBKEY)], + PUBKEY, + ), + true, + ); +}); + +test("allowsFeedItemForChannel: a p-tag of somebody else does not pierce", () => { + assert.equal( + allowsFeedItemForChannel( + feedState({ level: "mute" }), + true, + [["notify", "channel"], pTag(OTHER_PUBKEY)], + PUBKEY, + ), + false, + ); +}); + +test("allowsFeedItemForChannel: an empty reader pubkey keeps the notify gate", () => { + const tags = [["notify", "channel"], pTag(PUBKEY)]; + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), true, tags, ""), + false, + ); + assert.equal( + allowsFeedItemForChannel(feedState({ broadcasts: false }), true, tags, ""), + false, + ); + assert.equal(allowsFeedItemForChannel(feedState(), true, tags, ""), true); +}); diff --git a/desktop/src/features/notifications/lib/timedMuteTicker.test.mjs b/desktop/src/features/notifications/lib/timedMuteTicker.test.mjs new file mode 100644 index 0000000000..96a912d234 --- /dev/null +++ b/desktop/src/features/notifications/lib/timedMuteTicker.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// The ticker reads window.setTimeout at call time, so a stub window is enough. +const timers = []; +globalThis.window = { + clearTimeout: (id) => { + const timer = timers.find((t) => t.id === id); + if (timer) timer.cleared = true; + }, + setTimeout: (fn, delay) => { + timers.push({ cleared: false, delay, fn, id: timers.length + 1 }); + return timers.length; + }, +}; + +const { + getTimedMuteVersion, + resetTimedMuteTicker, + scheduleTimedMuteRefresh, + subscribeTimedMuteVersion, +} = await import("./timedMuteTicker.ts"); + +function pending() { + return timers.filter((t) => !t.cleared); +} + +test("scheduling the same expiry twice arms only one timer", () => { + timers.length = 0; + const expiry = Math.floor(Date.now() / 1_000) + 60; + scheduleTimedMuteRefresh(expiry); + scheduleTimedMuteRefresh(expiry); + assert.equal(pending().length, 1); + resetTimedMuteTicker(); +}); + +test("a nearer expiry replaces the armed timer", () => { + timers.length = 0; + const now = Math.floor(Date.now() / 1_000); + scheduleTimedMuteRefresh(now + 600); + scheduleTimedMuteRefresh(now + 60); + assert.equal(pending().length, 1); + assert.ok(pending()[0].delay < 120_000); + resetTimedMuteTicker(); +}); + +test("null disarms the timer", () => { + timers.length = 0; + scheduleTimedMuteRefresh(Math.floor(Date.now() / 1_000) + 60); + scheduleTimedMuteRefresh(null); + assert.equal(pending().length, 0); +}); + +test("firing bumps the version and notifies subscribers", () => { + timers.length = 0; + let notified = 0; + const unsubscribe = subscribeTimedMuteVersion(() => { + notified += 1; + }); + const before = getTimedMuteVersion(); + scheduleTimedMuteRefresh(Math.floor(Date.now() / 1_000) + 60); + pending()[0].fn(); + assert.equal(getTimedMuteVersion(), before + 1); + assert.equal(notified, 1); + unsubscribe(); + resetTimedMuteTicker(); +}); + +test("an already-past expiry arms an immediate timer rather than a negative delay", () => { + timers.length = 0; + scheduleTimedMuteRefresh(Math.floor(Date.now() / 1_000) - 3_600); + assert.equal(pending().length, 1); + assert.equal(pending()[0].delay, 0); + resetTimedMuteTicker(); +}); + +test("a far-future expiry is capped so setTimeout cannot overflow", () => { + timers.length = 0; + scheduleTimedMuteRefresh(Math.floor(Date.now() / 1_000) + 90 * 86_400); + assert.equal(pending()[0].delay, 6 * 60 * 60 * 1_000); + resetTimedMuteTicker(); +}); + +test("reset drops subscribers so a later fire cannot reach them", () => { + timers.length = 0; + let notified = 0; + subscribeTimedMuteVersion(() => { + notified += 1; + }); + scheduleTimedMuteRefresh(Math.floor(Date.now() / 1_000) + 60); + const timer = pending()[0]; + resetTimedMuteTicker(); + timer.fn(); + assert.equal(notified, 0); +}); diff --git a/desktop/src/features/notifications/lib/timedMuteTicker.ts b/desktop/src/features/notifications/lib/timedMuteTicker.ts new file mode 100644 index 0000000000..3f90eefa8b --- /dev/null +++ b/desktop/src/features/notifications/lib/timedMuteTicker.ts @@ -0,0 +1,80 @@ +import * as React from "react"; + +/** + * Single UI-refresh timer for timed mutes. + * + * Timed-mute expiry itself is evaluated lazily (`resolveChannelNotifyState` + * compares `muteUntil` against the current clock), so this module exists only to + * re-render at the moment the nearest mute expires. One module-level timer + * serves every consumer; callers schedule the nearest expiry and read the + * version via `useTimedMuteVersion`. + */ + +// setTimeout delays above ~24.8 days overflow and fire immediately; cap the +// wait and let the re-scheduled tick carry us the rest of the way. +const MAX_DELAY_MS = 6 * 60 * 60 * 1_000; +// Small skew so the resolver sees `muteUntil` as strictly past when we fire. +const EXPIRY_SKEW_MS = 250; + +let version = 0; +let timer: number | null = null; +let scheduledFor: number | null = null; +const listeners = new Set<() => void>(); + +function clearTimer(): void { + if (timer !== null) { + window.clearTimeout(timer); + timer = null; + } + scheduledFor = null; +} + +export function getTimedMuteVersion(): number { + return version; +} + +export function subscribeTimedMuteVersion(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Arm (or disarm, with `null`) the refresh timer for the nearest running + * timed-mute expiry, in Unix seconds. Re-arming for the same instant is a no-op. + */ +export function scheduleTimedMuteRefresh(expirySeconds: number | null): void { + if (expirySeconds === null) { + clearTimer(); + return; + } + if (scheduledFor === expirySeconds && timer !== null) return; + clearTimer(); + const delay = Math.min( + MAX_DELAY_MS, + Math.max(0, expirySeconds * 1_000 - Date.now() + EXPIRY_SKEW_MS), + ); + scheduledFor = expirySeconds; + timer = window.setTimeout(() => { + timer = null; + scheduledFor = null; + version += 1; + for (const listener of listeners) listener(); + }, delay); +} + +/** Community-scoped teardown — see resetCommunityState() in useCommunityInit. */ +export function resetTimedMuteTicker(): void { + clearTimer(); + listeners.clear(); +} + +/** Re-renders the caller whenever a timed mute expires. */ +export function useTimedMuteVersion(): number { + return React.useSyncExternalStore( + subscribeTimedMuteVersion, + getTimedMuteVersion, + getTimedMuteVersion, + ); +} diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index e7b4d726a3..ad16891d1f 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -7,6 +7,11 @@ import { } from "@/features/profile/lib/identity"; import { getThreadReference } from "@/features/messages/lib/threading"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { + DEFAULT_CHANNEL_NOTIFY_STATE, + type ResolvedChannelNotifyState, +} from "./lib/resolveChannelNotifyState"; +import { allowsFeedItemForChannel } from "./lib/shouldNotify"; import { collectHomeAlertItems, eligibleFeedNotificationItems, @@ -74,8 +79,10 @@ export function useFeedDesktopNotifications( settings: NotificationSettings, setDesktopEnabled: (enabled: boolean) => Promise, profiles?: UserProfileLookup, - mutedChannelIds?: ReadonlySet, channels: readonly NotificationChannel[] = [], + resolveChannelNotify: ( + channelId: string, + ) => ResolvedChannelNotifyState = () => DEFAULT_CHANNEL_NOTIFY_STATE, ) { const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const seenItemIdsRef = React.useRef>( @@ -168,12 +175,22 @@ export function useFeedDesktopNotifications( channels, ) .filter((item) => !nextSeenItemIds.has(item.id)) - .filter( - (item) => - !item.channelId || - !mutedChannelIds?.has(item.channelId) || - item.category === "mention", - ) + // Per-channel gate: the NIP-CN level and broadcasts opt-out decide + // eligibility, and `desktop: false` silences this channel's banners + // on desktop without touching its unread state. + .filter((item) => { + if (!item.channelId) return true; + const state = resolveChannelNotify(item.channelId); + return ( + state.desktop && + allowsFeedItemForChannel( + state, + item.category === "mention", + item.tags, + normalizedPubkey, + ) + ); + }) : []; for (const item of currentFeedItems) { @@ -216,7 +233,7 @@ export function useFeedDesktopNotifications( }, [ feed, channels, - mutedChannelIds, + resolveChannelNotify, normalizedPubkey, profiles, settings.desktopEnabled, diff --git a/desktop/src/features/reminders/lib/timePresets.ts b/desktop/src/features/reminders/lib/timePresets.ts index 90400e2d08..3b9799c33a 100644 --- a/desktop/src/features/reminders/lib/timePresets.ts +++ b/desktop/src/features/reminders/lib/timePresets.ts @@ -17,7 +17,7 @@ function nowSeconds(): number { * instant is already past (e.g. it is after 9am and offset is 0), roll to the * following day so the result is always in the future. */ -function nextDayAt9am(dayOffset: number): number { +export function nextDayAt9am(dayOffset: number): number { const now = new Date(); const target = new Date(now); target.setDate(target.getDate() + dayOffset); @@ -28,9 +28,14 @@ function nextDayAt9am(dayOffset: number): number { return Math.floor(target.getTime() / 1_000); } +/** One hour from now, as a Unix timestamp in seconds. */ +export function inOneHour(): number { + return nowSeconds() + 60 * 60; +} + export const TIME_PRESETS: TimePreset[] = [ { label: "In 30 minutes", getTimestamp: () => nowSeconds() + 30 * 60 }, - { label: "In 1 hour", getTimestamp: () => nowSeconds() + 60 * 60 }, + { label: "In 1 hour", getTimestamp: inOneHour }, { label: "In 3 hours", getTimestamp: () => nowSeconds() + 3 * 60 * 60 }, { label: "Tomorrow at 9am", getTimestamp: () => nextDayAt9am(1) }, { diff --git a/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.test.mjs new file mode 100644 index 0000000000..265a1d83cc --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.test.mjs @@ -0,0 +1,472 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + entriesEqual, + isDefaultEntry, + mergeStores, + parseNotifyEntry, + parseNotifyPrefsPayload, + readChannelNotifyPrefsStore, + setChannelEntry, + storageKey, + storesEqual, + writeChannelNotifyPrefsStore, +} from "./channelNotifyPrefsStorage.ts"; + +// ── parseNotifyPrefsPayload ─────────────────────────────────────────────────── + +test("parse: valid payload keeps every known field", () => { + const payload = { + version: 1, + channels: { + "chan-1": { level: "mentions", updatedAt: 10 }, + "chan-2": { + level: "mute", + muteUntil: 500, + desktop: false, + followAllThreads: true, + broadcasts: false, + updatedAt: 20, + }, + }, + }; + assert.deepEqual(parseNotifyPrefsPayload(payload), payload); +}); + +test("parse: rejects non-object and wrong-version payloads", () => { + assert.equal(parseNotifyPrefsPayload(null), null); + assert.equal(parseNotifyPrefsPayload("nope"), null); + assert.equal(parseNotifyPrefsPayload(42), null); + assert.equal(parseNotifyPrefsPayload({ channels: {} }), null); + assert.equal(parseNotifyPrefsPayload({ version: 2, channels: {} }), null); +}); + +test("parse: missing or malformed channels map yields an empty store", () => { + assert.deepEqual(parseNotifyPrefsPayload({ version: 1 }), { + version: 1, + channels: {}, + }); + assert.deepEqual(parseNotifyPrefsPayload({ version: 1, channels: [] }), { + version: 1, + channels: {}, + }); +}); + +test("parse: drops entries without a usable updatedAt", () => { + const result = parseNotifyPrefsPayload({ + version: 1, + channels: { + good: { level: "mute", updatedAt: 5 }, + noTimestamp: { level: "mute" }, + nanTimestamp: { level: "mute", updatedAt: Number.NaN }, + negative: { level: "mute", updatedAt: -1 }, + notAnObject: "mute", + arrayEntry: [1, 2], + }, + }); + assert.deepEqual(Object.keys(result.channels), ["good"]); +}); + +test("parse: drops malformed known fields but keeps the entry", () => { + const result = parseNotifyPrefsPayload({ + version: 1, + channels: { + "chan-1": { + level: "loud", + muteUntil: "soon", + desktop: "yes", + followAllThreads: 1, + broadcasts: null, + updatedAt: 7, + }, + }, + }); + assert.deepEqual(result.channels["chan-1"], { updatedAt: 7 }); +}); + +test("parse: preserves unknown fields on entries it keeps (forward compat)", () => { + const result = parseNotifyPrefsPayload({ + version: 1, + channels: { + "chan-1": { + level: "mentions", + mobile: false, + futureThing: 3, + updatedAt: 9, + }, + }, + }); + assert.deepEqual(result.channels["chan-1"], { + level: "mentions", + mobile: false, + futureThing: 3, + updatedAt: 9, + }); +}); + +test("parseNotifyEntry: ignores a zero muteUntil", () => { + assert.deepEqual(parseNotifyEntry({ muteUntil: 0, updatedAt: 4 }), { + updatedAt: 4, + }); +}); + +// ── hydration republish predicate (what applyRemote gates the republish on) ─── + +test("merge/storesEqual: a local edit the remote blob lacks is detected, then settles", () => { + // The F2 sequence at the store level: a remote blob already exists from an + // earlier session, the user edits #random, and the debounce window is cut + // short (community switch / reload) so nothing pending survives. On return, + // the merge keeps the local edit — and comparing against the remote blob is + // what tells the hook it still has to publish. + const remote = { + version: 1, + channels: { eng: { level: "mentions", updatedAt: 100 } }, + }; + const localAfterLostDebounce = { + version: 1, + channels: { + eng: { level: "mentions", updatedAt: 100 }, + random: { level: "mute", updatedAt: 200 }, + }, + }; + + const merged = mergeStores(localAfterLostDebounce, remote); + assert.deepEqual(merged.channels.random, { level: "mute", updatedAt: 200 }); + assert.equal(storesEqual(merged, remote), false); + + // Terminates: once our republished blob comes back on the subscription, the + // merge result equals it and the hook stops republishing. + assert.equal(storesEqual(mergeStores(merged, merged), merged), true); +}); + +test("merge/storesEqual: a remote blob that already holds every local entry needs no republish", () => { + const store = { + version: 1, + channels: { eng: { level: "mute", desktop: false, updatedAt: 100 } }, + }; + const remote = { + version: 1, + channels: { eng: { level: "mute", desktop: false, updatedAt: 100 } }, + }; + assert.equal(storesEqual(mergeStores(store, remote), remote), true); +}); + +// ── updatedAt clock-skew clamp ──────────────────────────────────────────────── + +test("parse: an in-tolerance skewed updatedAt is preserved verbatim", () => { + const now = 1_800_000_000; + const parsed = parseNotifyPrefsPayload( + { + version: 1, + channels: { eng: { level: "mute", updatedAt: now + 120 } }, + }, + now, + ); + assert.equal(parsed.channels.eng.updatedAt, now + 120); +}); + +test("parse: a far-future updatedAt is clamped, keeping every other field", () => { + const now = 1_800_000_000; + const parsed = parseNotifyPrefsPayload( + { + version: 1, + channels: { + eng: { + level: "mute", + muteUntil: now + 315_360_000, + desktop: false, + broadcasts: false, + followAllThreads: true, + mobile: false, + updatedAt: now + 315_360_000, + }, + }, + }, + now, + ); + assert.deepEqual(parsed.channels.eng, { + level: "mute", + // muteUntil is a legitimate absolute future timestamp — never clamped. + muteUntil: now + 315_360_000, + desktop: false, + broadcasts: false, + followAllThreads: true, + mobile: false, + updatedAt: now + 3_600, + }); +}); + +test("merge: a local edit beats a clamped far-future remote entry", () => { + const now = 1_800_000_000; + // One of the user's own devices has a clock set to 2030 and published a mute. + const remote = parseNotifyPrefsPayload( + { + version: 1, + channels: { eng: { level: "mute", updatedAt: now + 315_360_000 } }, + }, + now, + ); + // The correctly-clocked device then picks "All new posts". + const local = { + version: 1, + channels: { eng: { level: "all", updatedAt: now + 3_601 } }, + }; + assert.deepEqual(mergeStores(local, remote).channels.eng, { + level: "all", + updatedAt: now + 3_601, + }); +}); + +// ── merge (per-channel max-updatedAt LWW, local wins ties) ──────────────────── + +test("merge: unions keys and keeps the newer entry per channel", () => { + const local = { + version: 1, + channels: { + both: { level: "mentions", updatedAt: 30 }, + localOnly: { level: "mute", updatedAt: 5 }, + }, + }; + const remote = { + version: 1, + channels: { + both: { level: "mute", updatedAt: 20 }, + remoteOnly: { desktop: false, updatedAt: 8 }, + }, + }; + assert.deepEqual(mergeStores(local, remote), { + version: 1, + channels: { + both: { level: "mentions", updatedAt: 30 }, + localOnly: { level: "mute", updatedAt: 5 }, + remoteOnly: { desktop: false, updatedAt: 8 }, + }, + }); +}); + +test("merge: local wins ties", () => { + const merged = mergeStores( + { version: 1, channels: { c: { level: "mentions", updatedAt: 10 } } }, + { version: 1, channels: { c: { level: "mute", updatedAt: 10 } } }, + ); + assert.deepEqual(merged.channels.c, { level: "mentions", updatedAt: 10 }); +}); + +test("merge: newer remote entry replaces the whole local entry (no field merge)", () => { + const merged = mergeStores( + { + version: 1, + channels: { c: { level: "mute", desktop: false, updatedAt: 10 } }, + }, + { version: 1, channels: { c: { level: "mentions", updatedAt: 11 } } }, + ); + assert.deepEqual(merged.channels.c, { level: "mentions", updatedAt: 11 }); +}); + +test("merge: preserves unknown fields carried by the winning entry", () => { + const merged = mergeStores( + { version: 1, channels: { c: { level: "mute", updatedAt: 1 } } }, + { version: 1, channels: { c: { mobile: true, updatedAt: 2 } } }, + ); + assert.deepEqual(merged.channels.c, { mobile: true, updatedAt: 2 }); +}); + +// ── sparse entries ──────────────────────────────────────────────────────────── + +test("isDefaultEntry: default-valued entries, explicit or implicit", () => { + assert.equal(isDefaultEntry({ updatedAt: 1 }), true); + assert.equal( + isDefaultEntry({ + level: "all", + desktop: true, + followAllThreads: false, + broadcasts: true, + updatedAt: 1, + }), + true, + ); +}); + +test("isDefaultEntry: any divergence, including unknown fields, is not default", () => { + assert.equal(isDefaultEntry({ level: "mentions", updatedAt: 1 }), false); + assert.equal(isDefaultEntry({ muteUntil: 99, updatedAt: 1 }), false); + assert.equal(isDefaultEntry({ desktop: false, updatedAt: 1 }), false); + assert.equal(isDefaultEntry({ followAllThreads: true, updatedAt: 1 }), false); + assert.equal(isDefaultEntry({ broadcasts: false, updatedAt: 1 }), false); + // Unknown fields must never be pruned away. + assert.equal(isDefaultEntry({ mobile: false, updatedAt: 1 }), false); +}); + +test("setChannelEntry: does not materialize a default entry for an untouched channel", () => { + const store = { version: 1, channels: {} }; + assert.equal( + setChannelEntry(store, "c", { level: "all", updatedAt: 5 }), + store, + ); +}); + +test("setChannelEntry: materializes a default entry over a non-default one (LWW tombstone)", () => { + const store = { + version: 1, + channels: { c: { level: "mute", updatedAt: 5 } }, + }; + const next = setChannelEntry(store, "c", { level: "all", updatedAt: 9 }); + assert.deepEqual(next.channels.c, { level: "all", updatedAt: 9 }); +}); + +test("setChannelEntry: prunes a default entry once the prior entry is also default", () => { + const store = { + version: 1, + channels: { + c: { level: "all", updatedAt: 9 }, + other: { level: "mute", updatedAt: 1 }, + }, + }; + const next = setChannelEntry(store, "c", { updatedAt: 12 }); + assert.deepEqual(Object.keys(next.channels), ["other"]); +}); + +test("setChannelEntry: stores diverging entries and leaves siblings alone", () => { + const store = { + version: 1, + channels: { other: { level: "mute", updatedAt: 1 } }, + }; + const next = setChannelEntry(store, "c", { muteUntil: 500, updatedAt: 12 }); + assert.deepEqual(next.channels, { + other: { level: "mute", updatedAt: 1 }, + c: { muteUntil: 500, updatedAt: 12 }, + }); +}); + +// ── publish-dedup equality (all fields, not just the level) ─────────────────── + +test("entriesEqual: compares every field, including unknown ones", () => { + assert.equal( + entriesEqual( + { level: "mute", updatedAt: 1 }, + { level: "mute", updatedAt: 1 }, + ), + true, + ); + assert.equal( + entriesEqual( + { level: "mute", updatedAt: 1 }, + { level: "mute", updatedAt: 2 }, + ), + false, + ); + assert.equal( + entriesEqual( + { level: "mute", desktop: false, updatedAt: 1 }, + { level: "mute", updatedAt: 1 }, + ), + false, + ); + assert.equal( + entriesEqual( + { level: "mute", mobile: true, updatedAt: 1 }, + { level: "mute", mobile: false, updatedAt: 1 }, + ), + false, + ); +}); + +test("storesEqual: same keys and identical entries only", () => { + const a = { + version: 1, + channels: { c: { level: "mute", updatedAt: 1 } }, + }; + assert.equal(storesEqual(a, structuredClone(a)), true); + assert.equal( + storesEqual(a, { + version: 1, + channels: { c: { level: "mute", updatedAt: 1 }, d: { updatedAt: 2 } }, + }), + false, + ); + assert.equal( + storesEqual(a, { + version: 1, + channels: { d: { level: "mute", updatedAt: 1 } }, + }), + false, + ); + // A toggle that only changes desktop must not be deduped away. + assert.equal( + storesEqual(a, { + version: 1, + channels: { c: { level: "mute", desktop: false, updatedAt: 1 } }, + }), + false, + ); +}); + +// ── relay-scoped localStorage key ───────────────────────────────────────────── + +function withFakeLocalStorage(run) { + const store = new Map(); + const previousWindow = globalThis.window; + globalThis.window = { + localStorage: { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }, + }; + try { + run(store); + } finally { + globalThis.window = previousWindow; + } +} + +test("storageKey: scoped to the normalized relay and the pubkey", () => { + assert.equal( + storageKey("pk", "wss://Relay.Example.com/"), + "buzz-channel-notify-prefs.v1:wss%3A%2F%2Frelay.example.com:pk", + ); + assert.notEqual( + storageKey("pk", "wss://a.example"), + storageKey("pk", "wss://b.example"), + ); +}); + +test("read/write: round-trips per relay without cross-relay bleed", () => { + withFakeLocalStorage(() => { + const store = { + version: 1, + channels: { c: { level: "mentions", updatedAt: 3 } }, + }; + assert.equal( + writeChannelNotifyPrefsStore("pk", "wss://a.example", store), + true, + ); + assert.deepEqual( + readChannelNotifyPrefsStore("pk", "wss://a.example"), + store, + ); + assert.deepEqual(readChannelNotifyPrefsStore("pk", "wss://b.example"), { + version: 1, + channels: {}, + }); + }); +}); + +test("read: corrupt or wrong-version localStorage falls back to the default store", () => { + withFakeLocalStorage((raw) => { + raw.set(storageKey("pk", "wss://a.example"), "{not json"); + assert.deepEqual(readChannelNotifyPrefsStore("pk", "wss://a.example"), { + version: 1, + channels: {}, + }); + raw.set( + storageKey("pk", "wss://a.example"), + JSON.stringify({ version: 2, channels: { c: { updatedAt: 1 } } }), + ); + assert.deepEqual(readChannelNotifyPrefsStore("pk", "wss://a.example"), { + version: 1, + channels: {}, + }); + }); +}); diff --git a/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.ts b/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.ts new file mode 100644 index 0000000000..fab49c993c --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.ts @@ -0,0 +1,257 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; + +const STORAGE_KEY_PREFIX = "buzz-channel-notify-prefs.v1"; + +/** Per-channel notification level. Absent on an entry means inherit "all". */ +export type ChannelNotifyLevel = "all" | "mentions" | "mute"; + +/** + * One channel's notification preferences. Every field except `updatedAt` is + * optional — an absent field means "use the default" (level "all", no timed + * mute, desktop on, follow-all-threads off, broadcasts on). The whole entry + * moves under a single `updatedAt` (per-entry LWW, never per-field). + * + * Entries may carry fields this client does not know about (e.g. the `mobile` + * field reserved by NIP-CN for the mobile follow-up). Those are preserved + * verbatim on parse and merge so a newer client's data survives our writes. + */ +export type ChannelNotifyEntry = { + level?: ChannelNotifyLevel; + /** Absolute Unix seconds; effective level is "mute" while in the future. */ + muteUntil?: number; + desktop?: boolean; + followAllThreads?: boolean; + broadcasts?: boolean; + updatedAt: number; +}; + +export type ChannelNotifyPrefsStore = { + version: 1; + channels: Record; +}; + +export const DEFAULT_STORE: ChannelNotifyPrefsStore = Object.freeze({ + version: 1, + channels: {}, +}); + +const KNOWN_ENTRY_KEYS = new Set([ + "level", + "muteUntil", + "desktop", + "followAllThreads", + "broadcasts", + "updatedAt", +]); + +/** + * localStorage key for the notify prefs mirror, scoped to both the relay and + * the pubkey so preferences from different communities never bleed across each + * other (the pubkey-only key used by `channelMutesStorage` is a known defect). + */ +export function storageKey(pubkey: string, relayUrl: string): string { + // Encode the normalized relay so it can't contain the `:` delimiter. + const normalized = encodeURIComponent(normalizeRelayUrl(relayUrl)); + return `${STORAGE_KEY_PREFIX}:${normalized}:${pubkey}`; +} + +function isValidTimestamp(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +/** + * How far into the future an `updatedAt` is trusted. `mergeStores` is a pure + * max-`updatedAt` comparison with no clock reference, so one of the user's own + * devices with a badly wrong clock would otherwise pin a channel's entry + * permanently: every correctly-clocked edit loses the merge, gets overwritten by + * the remote blob, and the level silently reverts (a legacy `channel-mutes` + * unmute cannot win either). One hour absorbs realistic NTP-less cross-device + * skew while capping the damage from a wrong clock to an hour. + */ +const MAX_FUTURE_SKEW_SECONDS = 3_600; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1_000); +} + +/** + * Coerce one raw entry, dropping malformed known fields but preserving any + * unknown fields. Returns null when the entry has no usable `updatedAt` (it + * cannot participate in LWW merges, so it is not worth keeping). + * + * `updatedAt` is clamped to `now + MAX_FUTURE_SKEW_SECONDS`. This is the single + * choke point for both inbound paths (the localStorage mirror and the decrypted + * remote blob), which keeps `mergeStores` clock-free. `muteUntil` is *not* + * clamped — it is a legitimate absolute future timestamp. + */ +export function parseNotifyEntry( + value: unknown, + now: number = nowSeconds(), +): ChannelNotifyEntry | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const { + level, + muteUntil, + desktop, + followAllThreads, + broadcasts, + updatedAt, + ...unknownFields + } = value as Record; + if (!isValidTimestamp(updatedAt)) return null; + // Cast: `unknownFields` is intentionally opaque (forward-compat fields we + // pass through untouched); the known fields are validated below. + const entry = { + ...unknownFields, + updatedAt: Math.min(updatedAt, now + MAX_FUTURE_SKEW_SECONDS), + } as ChannelNotifyEntry; + if (level === "all" || level === "mentions" || level === "mute") { + entry.level = level; + } + if (isValidTimestamp(muteUntil) && muteUntil > 0) entry.muteUntil = muteUntil; + if (typeof desktop === "boolean") entry.desktop = desktop; + if (typeof followAllThreads === "boolean") { + entry.followAllThreads = followAllThreads; + } + if (typeof broadcasts === "boolean") entry.broadcasts = broadcasts; + return entry; +} + +export function parseNotifyPrefsPayload( + json: unknown, + now: number = nowSeconds(), +): ChannelNotifyPrefsStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + if (obj.version !== 1) return null; + const channels: Record = {}; + if ( + typeof obj.channels === "object" && + obj.channels !== null && + !Array.isArray(obj.channels) + ) { + for (const [channelId, value] of Object.entries( + obj.channels as Record, + )) { + const entry = parseNotifyEntry(value, now); + if (entry) channels[channelId] = entry; + } + } + return { version: 1, channels }; +} + +/** + * True when an entry carries no divergence from the defaults — and no unknown + * fields, which must never be discarded by our pruning. + */ +export function isDefaultEntry(entry: ChannelNotifyEntry): boolean { + for (const key of Object.keys(entry)) { + if (!KNOWN_ENTRY_KEYS.has(key)) return false; + } + return ( + (entry.level === undefined || entry.level === "all") && + entry.muteUntil === undefined && + (entry.desktop === undefined || entry.desktop) && + (entry.followAllThreads === undefined || !entry.followAllThreads) && + (entry.broadcasts === undefined || entry.broadcasts) + ); +} + +/** + * Assign one channel's entry, keeping the store sparse. + * + * A default-valued entry is only materialized when it has to override an + * existing non-default entry: LWW merge unions keys, so a deleted key would be + * resurrected by our own older blob (or another device's) and silently re-apply + * the level the user just cleared. Once the explicit default row is the newest + * one everywhere, the next default-valued write drops it — resurrecting a + * default row is harmless. + */ +export function setChannelEntry( + store: ChannelNotifyPrefsStore, + channelId: string, + entry: ChannelNotifyEntry, +): ChannelNotifyPrefsStore { + const prior = store.channels[channelId]; + if (isDefaultEntry(entry) && (prior === undefined || isDefaultEntry(prior))) { + if (prior === undefined) return store; + const channels = { ...store.channels }; + delete channels[channelId]; + return { version: 1, channels }; + } + return { version: 1, channels: { ...store.channels, [channelId]: entry } }; +} + +export function readChannelNotifyPrefsStore( + pubkey: string, + relayUrl: string, +): ChannelNotifyPrefsStore { + try { + const raw = window.localStorage.getItem(storageKey(pubkey, relayUrl)); + if (!raw) return DEFAULT_STORE; + return parseNotifyPrefsPayload(JSON.parse(raw)) ?? DEFAULT_STORE; + } catch { + return DEFAULT_STORE; + } +} + +export function writeChannelNotifyPrefsStore( + pubkey: string, + relayUrl: string, + store: ChannelNotifyPrefsStore, +): boolean { + try { + window.localStorage.setItem( + storageKey(pubkey, relayUrl), + JSON.stringify(store), + ); + return true; + } catch { + return false; + } +} + +/** Per-channel max-`updatedAt` LWW merge over the union of keys; local wins ties. */ +export function mergeStores( + local: ChannelNotifyPrefsStore, + remote: ChannelNotifyPrefsStore, +): ChannelNotifyPrefsStore { + const channels: Record = { ...local.channels }; + for (const [channelId, remoteEntry] of Object.entries(remote.channels)) { + const localEntry = channels[channelId]; + if (!localEntry || remoteEntry.updatedAt > localEntry.updatedAt) { + channels[channelId] = remoteEntry; + } + } + return { version: 1, channels }; +} + +/** + * Full-field entry equality (including unknown fields). Publish dedup must + * compare everything — `channelMutesSync` comparing only `muted`/`updatedAt` is + * a known trap that suppresses legitimate republishes. + */ +export function entriesEqual( + a: ChannelNotifyEntry, + b: ChannelNotifyEntry, +): boolean { + const aRecord = a as Record; + const bRecord = b as Record; + const aKeys = Object.keys(aRecord); + if (aKeys.length !== Object.keys(bRecord).length) return false; + return aKeys.every((key) => aRecord[key] === bRecord[key]); +} + +export function storesEqual( + a: ChannelNotifyPrefsStore, + b: ChannelNotifyPrefsStore, +): boolean { + const aKeys = Object.keys(a.channels); + if (aKeys.length !== Object.keys(b.channels).length) return false; + return aKeys.every((channelId) => { + const other = b.channels[channelId]; + return other !== undefined && entriesEqual(a.channels[channelId], other); + }); +} diff --git a/desktop/src/features/sidebar/lib/channelNotifyPrefsSync.test.mjs b/desktop/src/features/sidebar/lib/channelNotifyPrefsSync.test.mjs new file mode 100644 index 0000000000..7635adf5f6 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelNotifyPrefsSync.test.mjs @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelNotifyPrefsSyncManager } from "./channelNotifyPrefsSync.ts"; + +// The merge and publish-dedup rules the manager applies are pure functions +// (mergeStores / storesEqual) covered in channelNotifyPrefsStorage.test.mjs; +// these tests cover the manager's own scheduling and teardown behavior, which +// is where the cross-relay publish bugs (#1556) live. + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +function installFakeTimers() { + if (typeof globalThis.window === "undefined") { + globalThis.window = {}; + } + const original = { + setTimeout: globalThis.window.setTimeout, + clearTimeout: globalThis.window.clearTimeout, + }; + const state = { callback: null, nextId: 1 }; + globalThis.window.setTimeout = (fn) => { + state.callback = fn; + return state.nextId++; + }; + globalThis.window.clearTimeout = () => { + state.callback = null; + }; + return { + state, + restore: () => { + globalThis.window.setTimeout = original.setTimeout; + globalThis.window.clearTimeout = original.clearTimeout; + }, + }; +} + +test("publishPrefs: debounces and exposes the pending store", () => { + const timers = installFakeTimers(); + try { + const manager = new ChannelNotifyPrefsSyncManager("pk-debounce"); + const store = makeStore({ c: { level: "mute", updatedAt: 1 } }); + manager.publishPrefs(store); + assert.ok(timers.state.callback !== null, "debounce timer should be set"); + assert.deepEqual(manager.getPendingStore(), store); + } finally { + timers.restore(); + } +}); + +// Regression guard for the community-switch cross-relay publish vector (#1556): +// a level change on relay A followed by destroy() (relayUrl dep change) must not +// publish. The relay-scoped localStorage write is durable and the hook +// re-publishes when the user returns to relay A. +test("destroy: cancels the pending publish without flushing to the relay", () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const timers = installFakeTimers(); + try { + const manager = new ChannelNotifyPrefsSyncManager("pk-destroy"); + manager.publishPrefs(makeStore({ c: { level: "mentions", updatedAt: 1 } })); + manager.destroy(); + assert.equal(timers.state.callback, null, "timer must be cleared"); + assert.equal( + manager.getPendingStore(), + null, + "pendingStore must be cleared", + ); + assert.equal(publishCalls.length, 0, "nothing may be published on destroy"); + } finally { + timers.restore(); + mock.reset(); + } +}); + +test("destroy: aborts an in-flight publish after the own-blob fetch resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((resolve) => { + releaseFetch = () => resolve([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const timers = installFakeTimers(); + try { + const manager = new ChannelNotifyPrefsSyncManager("pk-race"); + manager.publishPrefs(makeStore({ c: { level: "mute", updatedAt: 1 } })); + const timerFn = timers.state.callback; + timers.state.callback = null; // the timer clears itself when it fires + timerFn(); + + manager.destroy(); + releaseFetch(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal( + publishCalls.length, + 0, + "publishEvent must not run after destroy even once the timer fired", + ); + } finally { + timers.restore(); + mock.reset(); + } +}); + +test("destroy: is safe with no pending publish", () => { + const manager = new ChannelNotifyPrefsSyncManager("pk-idle"); + assert.doesNotThrow(() => manager.destroy()); +}); + +test("fetchRemotePrefs: null when the relay returns nothing", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + try { + const manager = new ChannelNotifyPrefsSyncManager("pk-empty"); + assert.equal(await manager.fetchRemotePrefs(), null); + } finally { + mock.reset(); + } +}); + +test("fetchRemotePrefs: ignores events authored by anyone else", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + id: "evt", + pubkey: "someone-else", + created_at: 10, + kind: 30078, + tags: [["d", "channel-notify-prefs"]], + content: "ciphertext", + sig: "sig", + }, + ]), + ); + try { + const manager = new ChannelNotifyPrefsSyncManager("pk-mismatch"); + assert.equal(await manager.fetchRemotePrefs(), null); + } finally { + mock.reset(); + } +}); + +test("fetchRemotePrefs: swallows relay errors", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay down")), + ); + try { + const manager = new ChannelNotifyPrefsSyncManager("pk-error"); + assert.equal(await manager.fetchRemotePrefs(), null); + } finally { + mock.reset(); + } +}); + +test("fetchRemotePrefs: queries kind 30078 scoped to the notify-prefs d-tag", async () => { + const filters = []; + mock.method(relayClient, "fetchEvents", (filter) => { + filters.push(filter); + return Promise.resolve([]); + }); + try { + const manager = new ChannelNotifyPrefsSyncManager("pk-filter"); + await manager.fetchRemotePrefs(); + assert.deepEqual(filters, [ + { + kinds: [30078], + authors: ["pk-filter"], + "#d": ["channel-notify-prefs"], + limit: 1, + }, + ]); + } finally { + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelNotifyPrefsSync.ts b/desktop/src/features/sidebar/lib/channelNotifyPrefsSync.ts new file mode 100644 index 0000000000..c4c143e45c --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelNotifyPrefsSync.ts @@ -0,0 +1,205 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_CHANNEL_NOTIFY_PREFS } from "@/shared/constants/kinds"; +import { + mergeStores, + parseNotifyPrefsPayload, + storesEqual, + type ChannelNotifyPrefsStore, +} from "./channelNotifyPrefsStorage"; + +const D_TAG = "channel-notify-prefs"; +const DEBOUNCE_MS = 2_000; + +export type RemoteNotifyPrefs = { + store: ChannelNotifyPrefsStore; + createdAt: number; + eventId: string; +}; + +async function decryptAndParse( + event: RelayEvent, +): Promise { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const store = parseNotifyPrefsPayload(JSON.parse(plaintext)); + if (!store) return null; + return { store, createdAt: event.created_at, eventId: event.id }; + } catch { + return null; + } +} + +/** + * Syncs per-channel notification preferences across a user's clients via + * encrypted NIP-78 app data (kind 30078, d-tag `channel-notify-prefs`, NIP-44 + * encrypted to self). Writes are debounced and merged per channel with the + * user's own remote blob before publishing (max-`updatedAt` LWW), so a stale + * device cannot erase a newer entry from another device. See docs/nips/NIP-CN.md. + */ +export class ChannelNotifyPrefsSyncManager { + private pubkey: string; + private debounceTimer: number | null = null; + private lastRemoteCreatedAt = 0; + private pendingStore: ChannelNotifyPrefsStore | null = null; + private lastPublishedStore: ChannelNotifyPrefsStore | null = null; + private destroyed = false; + + constructor(pubkey: string) { + this.pubkey = pubkey; + } + + private async fetchOwnEvent(): Promise { + const events = await relayClient.fetchEvents({ + kinds: [KIND_CHANNEL_NOTIFY_PREFS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0 || events[0].pubkey !== this.pubkey) return null; + return decryptAndParse(events[0]); + } + + async fetchRemotePrefs(): Promise { + try { + const result = await this.fetchOwnEvent(); + 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(): ChannelNotifyPrefsStore | null { + return this.pendingStore; + } + + publishPrefs(store: ChannelNotifyPrefsStore): 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: ChannelNotifyPrefsStore, + ): Promise { + try { + const remote = await this.fetchOwnEvent(); + if (!remote) return store; + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + remote.createdAt, + ); + return mergeStores(store, remote.store); + } catch { + return store; + } + } + + private async doPublish(store: ChannelNotifyPrefsStore): Promise { + try { + const merged = await this.fetchOwnBlobBeforePublish(store); + // The manager may have been destroyed while the fetch was awaited + // (community switch mid-flight) — never publish relay A's prefs to relay B + // through the shared relayClient singleton. + if (this.destroyed) return; + if ( + this.lastPublishedStore && + storesEqual(this.lastPublishedStore, merged) + ) { + this.pendingStore = null; + return; + } + // Size seam: NIP-44 rejects plaintext over 65,535 bytes and the catch + // below only warns, so an oversized blob silently stops syncing while + // local state keeps working. Sparse entries keep the ceiling at several + // hundred *customized* channels. Shared with the four sibling kind-30078 + // sidebar blobs; a common pre-encrypt budget is tracked separately. + const ciphertext = await nip44EncryptToSelf( + JSON.stringify({ version: 1, channels: merged.channels }), + ); + const createdAt = Math.max( + Math.floor(Date.now() / 1_000), + this.lastRemoteCreatedAt + 1, + ); + const event = await signRelayEvent({ + kind: KIND_CHANNEL_NOTIFY_PREFS, + content: ciphertext, + createdAt, + tags: [ + ["d", D_TAG], + ["t", D_TAG], // relay discoverability; not used in our filters + ], + }); + if (this.destroyed) return; + await relayClient.publishEvent( + event, + "Timed out publishing channel notification preferences.", + "Failed to publish channel notification preferences.", + ); + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + event.created_at, + ); + this.lastPublishedStore = merged; + this.pendingStore = null; + } catch (error) { + console.warn("[channelNotifyPrefsSync] publish failed:", error); + } + } + + async subscribeToPrefs( + onUpdate: (remote: RemoteNotifyPrefs) => void, + ): Promise<() => Promise> { + return relayClient.subscribeLive( + { + kinds: [KIND_CHANNEL_NOTIFY_PREFS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 0, + }, + (event: RelayEvent) => { + if (event.pubkey !== this.pubkey) return; + void decryptAndParse(event).then((result) => { + if (!result) return; + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + result.createdAt, + ); + onUpdate(result); + }); + }, + ); + } + + destroy(): void { + // Cancel rather than flush: the relay-scoped localStorage write is already + // durable and the hook re-publishes pending edits when the user returns to + // this relay. Flushing here races community switching (see #1556). + this.destroyed = true; + this.cancelPendingPublish(); + this.pendingStore = null; + } +} diff --git a/desktop/src/features/sidebar/lib/hiddenChannelFilter.test.mjs b/desktop/src/features/sidebar/lib/hiddenChannelFilter.test.mjs new file mode 100644 index 0000000000..f686ee8430 --- /dev/null +++ b/desktop/src/features/sidebar/lib/hiddenChannelFilter.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { filterHiddenChannels } from "./hiddenChannelFilter.ts"; + +const CHANNELS = [{ id: "a" }, { id: "b" }, { id: "c" }]; + +function ids(channels) { + return channels.map((channel) => channel.id); +} + +test("returns the same array when nothing is hidden", () => { + assert.equal(filterHiddenChannels(CHANNELS, {}), CHANNELS); + assert.equal( + filterHiddenChannels(CHANNELS, { hiddenChannelIds: new Set() }), + CHANNELS, + ); +}); + +test("drops hidden channels", () => { + assert.deepEqual( + ids(filterHiddenChannels(CHANNELS, { hiddenChannelIds: new Set(["b"]) })), + ["a", "c"], + ); +}); + +test("keeps the active channel visible", () => { + assert.deepEqual( + ids( + filterHiddenChannels(CHANNELS, { + hiddenChannelIds: new Set(["b", "c"]), + activeChannelId: "b", + }), + ), + ["a", "b"], + ); +}); + +test("keeps a hidden channel holding a mention-tier unread", () => { + assert.deepEqual( + ids( + filterHiddenChannels(CHANNELS, { + hiddenChannelIds: new Set(["b", "c"]), + mentionUnreadChannelIds: new Set(["c"]), + }), + ), + ["a", "c"], + ); +}); diff --git a/desktop/src/features/sidebar/lib/hiddenChannelFilter.ts b/desktop/src/features/sidebar/lib/hiddenChannelFilter.ts new file mode 100644 index 0000000000..f6b1283403 --- /dev/null +++ b/desktop/src/features/sidebar/lib/hiddenChannelFilter.ts @@ -0,0 +1,26 @@ +/** + * Drop "Mute and hide" channels (NIP-CN level "mute" set explicitly) from a + * sidebar list, with Slack's two escape hatches: the channel the viewer is + * looking at stays put, and a channel holding a mention-tier unread resurfaces + * (rendered in the existing muted styling by the row itself). + * + * Pure: the caller supplies the resolved hidden set and the mention-tier set. + */ +export function filterHiddenChannels( + channels: readonly T[], + options: { + hiddenChannelIds?: ReadonlySet; + activeChannelId?: string | null; + mentionUnreadChannelIds?: ReadonlySet; + }, +): readonly T[] { + const hidden = options.hiddenChannelIds; + if (!hidden || hidden.size === 0) return channels; + + return channels.filter( + (channel) => + !hidden.has(channel.id) || + channel.id === options.activeChannelId || + Boolean(options.mentionUnreadChannelIds?.has(channel.id)), + ); +} diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 1fe92b60a3..6017a7f0de 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -16,6 +16,12 @@ import type { RemoteMutes } from "./channelMutesSync"; export function useChannelMutes(pubkey: string | undefined): { mutedChannelIds: Set; + /** + * The raw legacy `channel-mutes` store. Exposed so NIP-CN resolution can + * compare per-entry `updatedAt` values across the two blobs (a newer unmute + * on an old client must beat a stale prefs "mute"). + */ + muteStore: ChannelMuteStore; muteChannel: (channelId: string) => void; unmuteChannel: (channelId: string) => void; } { @@ -184,6 +190,7 @@ export function useChannelMutes(pubkey: string | undefined): { return { mutedChannelIds, + muteStore: store, muteChannel, unmuteChannel, }; diff --git a/desktop/src/features/sidebar/lib/useChannelNotifyPrefs.ts b/desktop/src/features/sidebar/lib/useChannelNotifyPrefs.ts new file mode 100644 index 0000000000..48e159df7e --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelNotifyPrefs.ts @@ -0,0 +1,325 @@ +import * as React from "react"; + +import { + foldLegacyMuteDecision, + nextTimedMuteExpiry, + resolveChannelNotifyState, + type ResolvedChannelNotifyState, +} from "@/features/notifications/lib/resolveChannelNotifyState"; +import { + scheduleTimedMuteRefresh, + useTimedMuteVersion, +} from "@/features/notifications/lib/timedMuteTicker"; +import { relayClient } from "@/shared/api/relayClient"; +import { + DEFAULT_STORE as DEFAULT_LEGACY_MUTE_STORE, + type ChannelMuteStore, +} from "./channelMutesStorage"; +import { + DEFAULT_STORE, + mergeStores, + readChannelNotifyPrefsStore, + setChannelEntry, + storageKey, + storesEqual, + writeChannelNotifyPrefsStore, + type ChannelNotifyEntry, + type ChannelNotifyLevel, + type ChannelNotifyPrefsStore, +} from "./channelNotifyPrefsStorage"; +import { + ChannelNotifyPrefsSyncManager, + type RemoteNotifyPrefs, +} from "./channelNotifyPrefsSync"; + +/** Fields the advanced (per-channel) toggles can change. */ +export type ChannelNotifyAdvancedPatch = { + desktop?: boolean; + followAllThreads?: boolean; + broadcasts?: boolean; +}; + +export type UseChannelNotifyPrefs = { + prefsStore: ChannelNotifyPrefsStore; + /** + * Resolved state for a channel. Reference-stable per channel until the store, + * the legacy mutes, or a timed-mute expiry changes it, so consumers can pass + * the result to memoized components. + */ + resolveChannel: (channelId: string) => ResolvedChannelNotifyState; + setChannelLevel: (channelId: string, level: ChannelNotifyLevel) => void; + muteChannelUntil: (channelId: string, untilSeconds: number) => void; + clearTimedMute: (channelId: string) => void; + setChannelAdvanced: ( + channelId: string, + patch: ChannelNotifyAdvancedPatch, + ) => void; +}; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1_000); +} + +/** + * Owns the per-channel notification preferences blob (kind 30078, d-tag + * `channel-notify-prefs`): local-first state, relay-scoped localStorage mirror, + * cross-tab storage events, remote merge with a `(createdAt, eventId)` + * watermark, and reconnect resync. + * + * This hook is deliberately single-purpose: it does **not** touch the legacy + * `channel-mutes` blob. Callers that need the NIP-CN dual-write compose it + * themselves — e.g. call `setChannelLevel(id, "mute")` alongside + * `muteChannel(id)` from `useChannelMutes` (and `unmuteChannel` for the other + * levels). Timed mutes are never dual-written: old clients cannot express them. + * + * `legacyMutes` is read-only input used for the interop half of resolution. + */ +export function useChannelNotifyPrefs( + pubkey: string | undefined, + relayUrl: string | undefined, + legacyMutes: ChannelMuteStore = DEFAULT_LEGACY_MUTE_STORE, +): UseChannelNotifyPrefs { + const [store, setStore] = React.useState(() => + pubkey && relayUrl + ? readChannelNotifyPrefsStore(pubkey, relayUrl) + : DEFAULT_STORE, + ); + + const managerRef = React.useRef(null); + const lastAppliedRemoteTs = React.useRef(0); + const lastAppliedEventId = React.useRef(""); + + React.useEffect(() => { + if (!pubkey || !relayUrl) { + setStore(DEFAULT_STORE); + lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; + return; + } + setStore(readChannelNotifyPrefsStore(pubkey, relayUrl)); + lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; + managerRef.current = new ChannelNotifyPrefsSyncManager(pubkey); + return () => { + managerRef.current?.destroy(); + managerRef.current = null; + }; + }, [pubkey, relayUrl]); + + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + const key = storageKey(pubkey, relayUrl); + const handler = (e: StorageEvent) => { + if (e.key !== key) return; + setStore(readChannelNotifyPrefsStore(pubkey, relayUrl)); + }; + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + }; + }, [pubkey, relayUrl]); + + const applyRemote = React.useCallback( + ( + remote: RemoteNotifyPrefs, + ): ((prev: ChannelNotifyPrefsStore) => ChannelNotifyPrefsStore) => { + return (prev) => { + if (!pubkey || !relayUrl) 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; + // Hydration guard (#2947), generalized: republish whenever the merge + // result still holds local state the remote blob does not. Gating this + // on a surviving pending publish lost the edit outright whenever the + // debounce window (2 s) was cut short — a community switch, sign-out or + // reload runs `destroy()`, which drops `pendingStore`, and the fresh + // manager built on return has nothing pending to rescue. The comparison + // subsumes the pending case (a still-debounced edit is by definition + // absent from the remote blob) and terminates: once the subscription + // delivers our own new blob, `merged` equals `remote.store`. + managerRef.current?.cancelPendingPublish(); + const merged = mergeStores(prev, remote.store); + if (!writeChannelNotifyPrefsStore(pubkey, relayUrl, merged)) + return prev; + if (!storesEqual(merged, remote.store)) { + managerRef.current?.publishPrefs(merged); + } + return merged; + }; + }, + [pubkey, relayUrl], + ); + + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let cancelled = false; + void managerRef.current?.fetchRemotePrefs().then((remote) => { + if (cancelled) return; + if (remote) { + setStore(applyRemote(remote)); + return; + } + const local = readChannelNotifyPrefsStore(pubkey, relayUrl); + if (Object.keys(local.channels).length > 0) { + managerRef.current?.publishPrefs(local); + } + }); + return () => { + cancelled = true; + }; + }, [pubkey, relayUrl, applyRemote]); + + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let unsub: (() => Promise) | null = null; + let cancelled = false; + void managerRef.current + ?.subscribeToPrefs((remote) => { + if (cancelled) return; + setStore(applyRemote(remote)); + }) + .then((dispose) => { + if (cancelled) { + void dispose(); + } else { + unsub = dispose; + } + }); + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, [pubkey, relayUrl, applyRemote]); + + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let cancelled = false; + const unsub = relayClient.subscribeToReconnects(() => { + void managerRef.current?.fetchRemotePrefs().then((remote) => { + if (cancelled) return; + if (remote) setStore(applyRemote(remote)); + const pending = managerRef.current?.getPendingStore(); + if (pending) managerRef.current?.publishPrefs(pending); + }); + }); + return () => { + cancelled = true; + unsub(); + }; + }, [pubkey, relayUrl, applyRemote]); + + const timedMuteVersion = useTimedMuteVersion(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store/legacyMutes are read through their .channels maps, which are the real deps; timedMuteVersion re-arms the timer after an expiry + React.useEffect(() => { + scheduleTimedMuteRefresh(nextTimedMuteExpiry(store, nowSeconds())); + }, [store.channels, timedMuteVersion]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: same as above — the cache is keyed to the channel maps and the expiry version, not the store object identities + const resolveChannel = React.useMemo(() => { + // Per-channel memo so repeated lookups return the same object reference + // (React.memo consumers compare by identity). + const cache = new Map(); + return (channelId: string): ResolvedChannelNotifyState => { + const cached = cache.get(channelId); + if (cached) return cached; + const resolved = resolveChannelNotifyState( + channelId, + store, + legacyMutes, + nowSeconds(), + ); + cache.set(channelId, resolved); + return resolved; + }; + }, [store.channels, legacyMutes.channels, timedMuteVersion]); + + const updateEntry = React.useCallback( + ( + channelId: string, + mutate: (entry: ChannelNotifyEntry) => ChannelNotifyEntry, + ) => { + if (!pubkey || !relayUrl) return; + setStore((prev) => { + const raw = prev.channels[channelId]; + // Seed from the entry as *resolved* against the legacy blob, not raw: + // the write stamps a fresh `updatedAt`, which would otherwise make a + // stale stored level win retroactively over a newer legacy mute/unmute + // — silently re-muting (or un-muting) a channel because the user + // toggled an unrelated switch. + const current: ChannelNotifyEntry = raw + ? { + ...raw, + level: foldLegacyMuteDecision( + raw, + legacyMutes.channels[channelId], + ), + } + : { updatedAt: 0 }; + const nextEntry: ChannelNotifyEntry = { + ...mutate(current), + updatedAt: nowSeconds(), + }; + const next = setChannelEntry(prev, channelId, nextEntry); + if (next === prev) return prev; + if (!writeChannelNotifyPrefsStore(pubkey, relayUrl, next)) return prev; + managerRef.current?.publishPrefs(next); + return next; + }); + }, + [pubkey, relayUrl, legacyMutes.channels], + ); + + const setChannelLevel = React.useCallback( + (channelId: string, level: ChannelNotifyLevel) => { + updateEntry(channelId, (entry) => { + // Picking any level clears a running timed mute. + const { muteUntil: _dropped, ...rest } = entry; + return { ...rest, level }; + }); + }, + [updateEntry], + ); + + const muteChannelUntil = React.useCallback( + (channelId: string, untilSeconds: number) => { + updateEntry(channelId, (entry) => ({ + ...entry, + muteUntil: untilSeconds, + })); + }, + [updateEntry], + ); + + const clearTimedMute = React.useCallback( + (channelId: string) => { + updateEntry(channelId, (entry) => { + const { muteUntil: _dropped, ...rest } = entry; + return rest; + }); + }, + [updateEntry], + ); + + const setChannelAdvanced = React.useCallback( + (channelId: string, patch: ChannelNotifyAdvancedPatch) => { + updateEntry(channelId, (entry) => ({ ...entry, ...patch })); + }, + [updateEntry], + ); + + return { + prefsStore: store, + resolveChannel, + setChannelLevel, + muteChannelUntil, + clearTimedMute, + setChannelAdvanced, + }; +} diff --git a/desktop/src/features/sidebar/lib/useSidebarChannelGroups.ts b/desktop/src/features/sidebar/lib/useSidebarChannelGroups.ts new file mode 100644 index 0000000000..88cc72afbd --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarChannelGroups.ts @@ -0,0 +1,121 @@ +import * as React from "react"; + +import { + sectionSortGroupKey, + sortChannelsForSidebar, + type ChannelSortGroupKey, + type ChannelSortMode, +} from "@/features/sidebar/lib/channelSortPreference"; +import { filterHiddenChannels } from "@/features/sidebar/lib/hiddenChannelFilter"; +import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; +import type { Channel } from "@/shared/api/types"; + +export type SidebarChannelGroups = { + /** Stream channels, minus NIP-CN hidden ones. */ + streamChannels: Channel[]; + /** Stream channels bucketed by custom section, each sorted by its own mode. */ + sectionBuckets: { + bySection: Record; + unassigned: Channel[]; + }; + starredChannels: Channel[]; + forumChannels: Channel[]; +}; + +/** + * Derives the sidebar's channel groupings: hide filtering (NIP-CN "Mute and + * hide"), section bucketing, and per-grouping sort. Pure derivation kept out of + * the sidebar component so `AppSidebar` only renders. + */ +export function useSidebarChannelGroups({ + activeChannelId, + channelAssignments, + channels, + channelSections, + hiddenChannelIds, + mentionUnreadChannelIds, + sortModeFor, + starredChannelIds, +}: { + activeChannelId: string | null; + channelAssignments: Record; + channels: Channel[]; + channelSections: ChannelSection[]; + hiddenChannelIds?: ReadonlySet; + mentionUnreadChannelIds?: ReadonlySet; + sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode; + starredChannelIds?: ReadonlySet; +}): SidebarChannelGroups { + // Hidden channels leave the sidebar lists but stay reachable from search and + // the quick switcher, which read the unfiltered channel set. + const visibleChannels = React.useMemo( + () => + filterHiddenChannels(channels, { + activeChannelId, + hiddenChannelIds, + mentionUnreadChannelIds, + }), + [channels, activeChannelId, hiddenChannelIds, mentionUnreadChannelIds], + ); + + const streamChannels = React.useMemo( + () => visibleChannels.filter((channel) => channel.channelType === "stream"), + [visibleChannels], + ); + + const sectionBuckets = React.useMemo(() => { + const bySection: Record = {}; + const unassigned: Channel[] = []; + const sectionIds = new Set(channelSections.map((s) => s.id)); + + for (const channel of streamChannels) { + if (starredChannelIds?.has(channel.id)) continue; + const sectionId = channelAssignments[channel.id]; + if (sectionId && sectionIds.has(sectionId)) { + if (!bySection[sectionId]) { + bySection[sectionId] = []; + } + bySection[sectionId].push(channel); + } else { + unassigned.push(channel); + } + } + // Apply each grouping's own sort preference; section membership itself + // is untouched. + for (const sectionId of Object.keys(bySection)) { + bySection[sectionId] = sortChannelsForSidebar( + bySection[sectionId], + sortModeFor(sectionSortGroupKey(sectionId)), + ); + } + return { + bySection, + unassigned: sortChannelsForSidebar(unassigned, sortModeFor("channels")), + }; + }, [ + streamChannels, + channelSections, + channelAssignments, + starredChannelIds, + sortModeFor, + ]); + + const starredChannels = React.useMemo(() => { + if (!starredChannelIds || starredChannelIds.size === 0) return []; + return sortChannelsForSidebar( + streamChannels.filter((channel) => starredChannelIds.has(channel.id)), + sortModeFor("starred"), + ); + }, [streamChannels, starredChannelIds, sortModeFor]); + + const forumChannels = React.useMemo( + () => + sortChannelsForSidebar( + visibleChannels.filter((channel) => channel.channelType === "forum"), + sortModeFor("forums"), + ), + [visibleChannels, sortModeFor], + ); + + return { streamChannels, sectionBuckets, starredChannels, forumChannels }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 55f467f215..59d32bfdde 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,5 +1,6 @@ // biome-ignore format: keep compact to stay within file size limit import * as React from "react"; +import { useAppShell } from "@/app/AppShellContext"; import { FeatureGate } from "@/shared/features"; import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; @@ -15,13 +16,11 @@ import { import { useActiveWorkingChannelsById } from "@/features/sidebar/lib/useActiveWorkingChannelsById"; import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata"; import { sortDmChannelsForSidebar } from "@/features/sidebar/lib/dmSidebarSort"; -import { - sectionSortGroupKey, - sortChannelsForSidebar, -} from "@/features/sidebar/lib/channelSortPreference"; +import { sectionSortGroupKey } from "@/features/sidebar/lib/channelSortPreference"; import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget"; +import { useSidebarChannelGroups } from "@/features/sidebar/lib/useSidebarChannelGroups"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; import { CreateSectionDialog, @@ -167,9 +166,6 @@ type AppSidebarProps = { onBackgroundClick?: () => void; isCreateChannelOpen?: boolean; onCreateChannelOpenChange?: (open: boolean) => void; - mutedChannelIds?: ReadonlySet; - onMuteChannel?: (channelId: string) => void; - onUnmuteChannel?: (channelId: string) => void; starredChannelIds?: ReadonlySet; onStarChannel?: (channelId: string) => void; onUnstarChannel?: (channelId: string) => void; @@ -230,13 +226,19 @@ export function AppSidebar({ onNewMessage, isCreateChannelOpen: isCreateChannelOpenProp, onCreateChannelOpenChange, - mutedChannelIds, - onMuteChannel, - onUnmuteChannel, starredChannelIds, onStarChannel, onUnstarChannel, }: AppSidebarProps) { + // NIP-CN notification state comes from the shell surface, not props: the + // sidebar, its rows, and the channel context menu all read the same resolver. + const { channelNotify, mentionUnreadChannelIds } = useAppShell(); + const { + hiddenChannelIds, + mutedChannelIds, + muteChannel: onMuteChannel, + unmuteChannel: onUnmuteChannel, + } = channelNotify; const activeWorkingByChannelId = useActiveWorkingChannelsById(); const { status: updateStatus } = useUpdaterContext(); const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus); @@ -383,55 +385,17 @@ export function AppSidebar({ if (channel.id === selectedChannelId) onSelectHome(); }); - const streamChannels = React.useMemo( - () => channels.filter((channel) => channel.channelType === "stream"), - [channels], - ); - - const sectionBuckets = React.useMemo(() => { - const bySection: Record = {}; - const unassigned: Channel[] = []; - const sectionIds = new Set(channelSections.map((s) => s.id)); - - for (const channel of streamChannels) { - if (starredChannelIds?.has(channel.id)) continue; - const sectionId = channelAssignments[channel.id]; - if (sectionId && sectionIds.has(sectionId)) { - if (!bySection[sectionId]) { - bySection[sectionId] = []; - } - bySection[sectionId].push(channel); - } else { - unassigned.push(channel); - } - } - // Apply each grouping's own sort preference; section membership itself - // is untouched. - for (const sectionId of Object.keys(bySection)) { - bySection[sectionId] = sortChannelsForSidebar( - bySection[sectionId], - sortModeFor(sectionSortGroupKey(sectionId)), - ); - } - return { - bySection, - unassigned: sortChannelsForSidebar(unassigned, sortModeFor("channels")), - }; - }, [ - streamChannels, - channelSections, - channelAssignments, - starredChannelIds, - sortModeFor, - ]); - - const starredChannels = React.useMemo(() => { - if (!starredChannelIds || starredChannelIds.size === 0) return []; - return sortChannelsForSidebar( - streamChannels.filter((channel) => starredChannelIds.has(channel.id)), - sortModeFor("starred"), - ); - }, [streamChannels, starredChannelIds, sortModeFor]); + const { forumChannels, sectionBuckets, starredChannels, streamChannels } = + useSidebarChannelGroups({ + activeChannelId: selectedView === "channel" ? selectedChannelId : null, + channelAssignments, + channels, + channelSections, + hiddenChannelIds, + mentionUnreadChannelIds, + sortModeFor, + starredChannelIds, + }); const handleCreateSectionForChannel = React.useCallback( (channelId: string) => { @@ -454,14 +418,6 @@ export function AppSidebar({ [createSection, assignChannel, createSectionState.pendingChannelId], ); - const forumChannels = React.useMemo( - () => - sortChannelsForSidebar( - channels.filter((channel) => channel.channelType === "forum"), - sortModeFor("forums"), - ), - [channels, sortModeFor], - ); const directMessages = React.useMemo( () => channels.filter((channel) => channel.channelType === "dm"), [channels], diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 76cae3ed38..5422f3f50a 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -21,6 +21,7 @@ import { } from "@/features/channels/hooks"; import { useChannelModerationCapabilities } from "@/features/channels/ui/ChannelManagementModerationActions"; import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; +import { ChannelNotificationsSubmenu } from "@/features/sidebar/ui/ChannelNotificationsSubmenu"; import { ContextMenuIconSlot, deferMenuAction, @@ -207,6 +208,9 @@ export function ChannelContextMenuItems({ const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) : Boolean(onMarkChannelUnread); + // The mute handlers stay the presence gate for the whole notification block: + // channels get the NIP-CN "Notifications" submenu, DMs keep the binary pair + // (levels are channel-scoped only in v1). const showMuteToggle = Boolean(onMuteChannel && onUnmuteChannel); const showMove = Boolean( sections && @@ -256,7 +260,10 @@ export function ChannelContextMenuItems({ ) : null} {showMuteToggle || showStar ? : null} - {showMuteToggle ? ( + {showMuteToggle && channel.channelType !== "dm" ? ( + + ) : null} + {showMuteToggle && channel.channelType === "dm" ? ( isMuted ? ( diff --git a/desktop/src/features/sidebar/ui/ChannelNotificationsSubmenu.tsx b/desktop/src/features/sidebar/ui/ChannelNotificationsSubmenu.tsx new file mode 100644 index 0000000000..b85a6d1316 --- /dev/null +++ b/desktop/src/features/sidebar/ui/ChannelNotificationsSubmenu.tsx @@ -0,0 +1,131 @@ +import { Bell, BellOff, Clock, Settings2 } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import { + CHANNEL_MUTE_PRESETS, + CHANNEL_NOTIFY_LEVEL_OPTIONS, + formatMuteUntil, +} from "@/features/notifications/lib/channelNotifyLabels"; +import type { ChannelNotifyLevel } from "@/features/sidebar/lib/channelNotifyPrefsStorage"; +import { + ContextMenuIconSlot, + deferMenuAction, +} from "@/features/sidebar/ui/sidebarMenuHelpers"; +import { + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@/shared/ui/context-menu"; + +/** + * The channel context menu's "Notifications" submenu (NIP-CN): the per-channel + * level radio group, the two timed-mute presets, and a link into the channel + * sheet's notification preferences. State and mutations come from the single + * AppShell notification surface, so this component only renders. + */ +export function ChannelNotificationsSubmenu({ + channelId, +}: { + channelId: string; +}) { + const { channelNotify, openChannelManagement } = useAppShell(); + const state = channelNotify.resolveChannelNotify(channelId); + + return ( + + + + {state.level === "all" ? ( + + ) : ( + + )} + + Notifications + + + + deferMenuAction(() => + channelNotify.setChannelNotifyLevel( + channelId, + value as ChannelNotifyLevel, + ), + ) + } + // A running timed mute is an overlay, not a level: leave the group + // unselected so it never looks like the channel was muted and hidden. + value={state.timedMuteActive ? "" : state.level} + > + {CHANNEL_NOTIFY_LEVEL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + {state.muteUntil !== null ? ( + <> + + {`Muted until ${formatMuteUntil(state.muteUntil)}`} + + + deferMenuAction(() => + channelNotify.clearChannelTimedMute(channelId), + ) + } + > + + + + Unmute + + + ) : ( + CHANNEL_MUTE_PRESETS.map((preset) => ( + + deferMenuAction(() => + channelNotify.muteChannelUntil( + channelId, + preset.getTimestamp(), + ), + ) + } + > + + + + {preset.label} + + )) + )} + + + deferMenuAction(() => openChannelManagement(channelId)) + } + > + + + + Notification preferences... + + + + ); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..0da0591793 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -40,12 +40,15 @@ export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; export const KIND_HUDDLE_ENDED = 48103; // NIP-78 application-specific data. All use kind 30078; the relay -// differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars", "channel-sort"). +// differentiates them by d-tag ("read-state:", "channel-sections", +// "channel-mutes", "channel-stars", "channel-sort", "channel-notify-prefs"). export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078; export const KIND_CHANNEL_STARS = 30078; export const KIND_CHANNEL_SORT = 30078; +/** Per-channel notification preferences (NIP-CN). */ +export const KIND_CHANNEL_NOTIFY_PREFS = 30078; // NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published // backend-side as secrets-stripped snapshots; the inbound sync hook subscribes // to all three to patch local records. Mirror of buzz-core's KIND_PERSONA etc. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 23c924baf0..ffec005d05 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2784,6 +2784,13 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; +/** + * NIP-78 app-data blobs (kind 30078) the mock relay stores and replays, keyed by + * `|`. Only the d-tags listed below round-trip; the rest are + * accepted and dropped as before. + */ +const mockAppDataEvents = new Map(); +const STORED_APP_DATA_D_TAGS = new Set(["channel-notify-prefs"]); let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; @@ -8910,6 +8917,21 @@ function sendToMockSocket(args: { return; } + const storedAppDataTags = filter.kinds?.includes(30078) + ? (filter["#d"] ?? []).filter((dTag) => STORED_APP_DATA_D_TAGS.has(dTag)) + : []; + if (storedAppDataTags.length > 0) { + const authors = filter.authors?.map((a) => a.toLowerCase()); + for (const [key, event] of mockAppDataEvents) { + const [pubkey, dTag] = key.split("|"); + if (!storedAppDataTags.includes(dTag)) continue; + if (authors && !authors.includes(pubkey)) continue; + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + return; + } + if (filter.kinds?.includes(KIND_EVENT_REMINDER)) { const authors = filter.authors?.map((a) => a.toLowerCase()); for (const event of mockReminderEvents) { @@ -9001,6 +9023,15 @@ function sendToMockSocket(args: { } if (event.kind === 30078) { + // Upsert by d-tag (replaceable event) so app-data sync managers see their + // own blob on the next fetch instead of an empty relay. + const dTag = event.tags.find((t) => t[0] === "d")?.[1]; + if (dTag && STORED_APP_DATA_D_TAGS.has(dTag)) { + mockAppDataEvents.set( + `${event.pubkey.toLowerCase()}|${dTag}`, + structuredClone(event), + ); + } sendWsText(socket.handler, ["OK", event.id, true, ""]); return; } diff --git a/desktop/tests/e2e/channel-mute.spec.ts b/desktop/tests/e2e/channel-mute.spec.ts index a710ca245f..14f834295d 100644 --- a/desktop/tests/e2e/channel-mute.spec.ts +++ b/desktop/tests/e2e/channel-mute.spec.ts @@ -49,7 +49,11 @@ async function waitForMockLiveSubscription( } test.describe("channel muting", () => { - test("01 — context menu shows Mute channel", async ({ page }) => { + // Channels carry the NIP-CN "Notifications" submenu instead of the binary + // Mute/Unmute pair (which DMs keep); see channel-notify-settings.spec.ts. + test("01 — context menu shows the Notifications submenu", async ({ + page, + }) => { await installMockBridge(page); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -59,9 +63,9 @@ test.describe("channel muting", () => { 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) => + const submenu = page.getByTestId("channel-notify-submenu"); + await expect(submenu).toBeVisible(); + await submenu.evaluate((el) => Promise.all( el .closest("[data-state]") @@ -126,7 +130,9 @@ test.describe("channel muting", () => { await expect(page.getByTestId("channel-unread-engineering")).toBeVisible(); }); - test("04 — context menu shows Unmute channel when muted", async ({ + // A legacy-blob mute resolves to level "mute" (NIP-CN interop) without + // hiding the channel, so the submenu opens on the muted radio item. + test("04 — submenu shows the mute level for a legacy mute", async ({ page, }) => { await seedMuteState(page, ENGINEERING_CHANNEL_ID); @@ -137,15 +143,12 @@ test.describe("channel muting", () => { 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) ?? [], - ), + const submenu = page.getByTestId("channel-notify-submenu"); + await expect(submenu).toBeVisible(); + await submenu.click(); + await expect(page.getByTestId("channel-notify-level-mute")).toHaveAttribute( + "aria-checked", + "true", ); }); diff --git a/desktop/tests/e2e/channel-notify-settings.spec.ts b/desktop/tests/e2e/channel-notify-settings.spec.ts new file mode 100644 index 0000000000..7a0b47da1a --- /dev/null +++ b/desktop/tests/e2e/channel-notify-settings.spec.ts @@ -0,0 +1,258 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/channel-notify-settings"; + +// Mock-mode current-user pubkey and relay (see e2eBridge DEFAULT_MOCK_PUBKEY / +// DEFAULT_RELAY_WS_URL). NIP-CN prefs persist under the relay-scoped key +// buzz-channel-notify-prefs.v1::. +const MOCK_PUBKEY = "deadbeef".repeat(8); +const MOCK_RELAY_ENCODED = encodeURIComponent("ws://localhost:3000"); +const PREFS_STORAGE_KEY = `buzz-channel-notify-prefs.v1:${MOCK_RELAY_ENCODED}:${MOCK_PUBKEY}`; +// `engineering` has no pre-seeded messages, so it holds clean visual states +// (`general` is always unread). +const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"; + +const SIDEBAR_CLIP = { x: 0, y: 0, width: 256, height: 720 }; +const MENU_CLIP = { x: 0, y: 0, width: 640, height: 720 }; + +function seedPrefs( + page: Page, + channelId: string, + entry: Record, +) { + // addInitScript must run before installMockBridge: React reads the store on + // mount and the bridge triggers that mount. + return page.addInitScript( + ({ key, channelId, entry }) => { + window.localStorage.setItem( + key, + JSON.stringify({ version: 1, channels: { [channelId]: entry } }), + ); + }, + { key: PREFS_STORAGE_KEY, channelId, entry }, + ); +} + +async function openApp(page: Page, activeChannel = "general") { + await page.goto("/"); + await page.getByTestId(`channel-${activeChannel}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(activeChannel); +} + +/** Right-click a sidebar channel and open its Notifications submenu. */ +async function openNotificationsSubmenu(page: Page, channelName: string) { + await page.getByTestId(`channel-${channelName}`).click({ button: "right" }); + const trigger = page.getByTestId("channel-notify-submenu"); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(page.getByTestId("channel-notify-level-mentions")).toBeVisible(); +} + +async function setLevel( + page: Page, + channelName: string, + level: "all" | "mentions" | "mute", +) { + await openNotificationsSubmenu(page, channelName); + await page.getByTestId(`channel-notify-level-${level}`).click(); + // Selecting a level closes the menu; the mutation runs after that (the menu + // helpers defer it so Radix can finish its close animation first). + await expect(page.getByTestId("channel-notify-submenu")).toHaveCount(0); +} + +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + ({ ch }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? + false, + { ch: channelName }, + ), + ) + .toBe(true); +} + +function emitMention(page: Page, channelName: string) { + return page.evaluate( + ({ channelName, pubkey, mockPubkey }) => { + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + pubkey: string; + mentionPubkeys: string[]; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: "Ping — can you take a look?", + pubkey, + mentionPubkeys: [mockPubkey], + }); + }, + { + channelName, + pubkey: TEST_IDENTITIES.alice.pubkey, + mockPubkey: MOCK_PUBKEY, + }, + ); +} + +test.describe("per-channel notification settings", () => { + test("01 — Notifications submenu shows the level radio group", async ({ + page, + }) => { + await installMockBridge(page); + await openApp(page); + + await openNotificationsSubmenu(page, "engineering"); + await expect(page.getByTestId("channel-notify-level-all")).toHaveAttribute( + "aria-checked", + "true", + ); + await expect(page.getByTestId("channel-notify-mute-1-hour")).toBeVisible(); + + await waitForAnimations(page); + await page.screenshot({ + clip: MENU_CLIP, + path: `${SHOTS}/01-notifications-submenu.png`, + }); + }); + + test("02 — Just mentions is checked and named in the header", async ({ + page, + }) => { + await installMockBridge(page); + await openApp(page); + + await setLevel(page, "engineering", "mentions"); + + await page.getByTestId("channel-engineering").click(); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + // The header description is the title tooltip on the channel name. + await expect(page.getByTestId("chat-title")).toHaveAttribute( + "title", + /Notifications: Just mentions/, + ); + + await openNotificationsSubmenu(page, "engineering"); + await expect( + page.getByTestId("channel-notify-level-mentions"), + ).toHaveAttribute("aria-checked", "true"); + + await waitForAnimations(page); + await page.screenshot({ + clip: MENU_CLIP, + path: `${SHOTS}/02-just-mentions.png`, + }); + }); + + test("03 — Mute and hide removes the channel from the sidebar", async ({ + page, + }) => { + await installMockBridge(page); + await openApp(page); + + await expect(page.getByTestId("channel-engineering")).toBeVisible(); + await setLevel(page, "engineering", "mute"); + await expect(page.getByTestId("channel-engineering")).toHaveCount(0); + + await waitForAnimations(page); + await page.screenshot({ + clip: SIDEBAR_CLIP, + path: `${SHOTS}/03-mute-and-hide.png`, + }); + }); + + test("04 — a mention resurfaces a hidden channel", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + + // Subscribe to engineering first (live messages are dropped without a + // subscription), then move away so the unread indicator can appear. + await page.getByTestId("channel-engineering").click(); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await waitForMockLiveSubscription(page, "engineering"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await setLevel(page, "engineering", "mute"); + await expect(page.getByTestId("channel-engineering")).toHaveCount(0); + + await emitMention(page, "engineering"); + const row = page.getByTestId("channel-engineering"); + await expect(row).toBeVisible(); + await expect(row.locator("svg.lucide-bell-off")).toHaveCount(1); + + await waitForAnimations(page); + await page.screenshot({ + clip: SIDEBAR_CLIP, + path: `${SHOTS}/04-hidden-channel-mention.png`, + }); + }); + + test("05 — a running timed mute shows its expiry and Unmute", async ({ + page, + }) => { + await seedPrefs(page, ENGINEERING_CHANNEL_ID, { + muteUntil: Math.floor(Date.now() / 1_000) + 3_600, + updatedAt: Math.floor(Date.now() / 1_000), + }); + await installMockBridge(page); + await openApp(page); + + await openNotificationsSubmenu(page, "engineering"); + await expect(page.getByText(/^Muted until /)).toBeVisible(); + await expect(page.getByTestId("channel-notify-unmute")).toBeVisible(); + // A timed mute is an overlay, not a level: no radio item is selected. + await expect(page.getByTestId("channel-notify-level-mute")).toHaveAttribute( + "aria-checked", + "false", + ); + + await waitForAnimations(page); + await page.screenshot({ + clip: MENU_CLIP, + path: `${SHOTS}/05-timed-mute.png`, + }); + }); + + test("06 — the channel sheet exposes the notifications section", async ({ + page, + }) => { + await installMockBridge(page); + await openApp(page, "engineering"); + + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + + const section = page.getByTestId("channel-notifications-section"); + await section.scrollIntoViewIfNeeded(); + await expect( + page.getByTestId("channel-notifications-desktop-toggle"), + ).toBeVisible(); + await expect( + page.getByTestId("channel-notifications-broadcasts-toggle"), + ).toBeVisible(); + await expect( + page.getByTestId("channel-notifications-edit-defaults"), + ).toBeVisible(); + + await waitForAnimations(page); + await section.screenshot({ + path: `${SHOTS}/06-sheet-notifications-section.png`, + }); + }); +}); diff --git a/docs/nips/NIP-CN.md b/docs/nips/NIP-CN.md new file mode 100644 index 0000000000..8e0144213b --- /dev/null +++ b/docs/nips/NIP-CN.md @@ -0,0 +1,423 @@ +NIP-CN +====== + +Per-Channel Notification Preferences +------------------------------------ + +`draft` `optional` + +## Abstract + +This NIP defines a scheme for synchronizing a user's own per-channel +notification preferences — notification level, temporary mute, and a small set +of per-channel toggles — across the client instances belonging to that user, +using an encrypted `kind:30078` event with the `d` value +`channel-notify-prefs`. + +The blob is private to its author. It says nothing about other users, and it +carries no instruction for the relay: every decision described here is taken +client-side, at notification time. + +## Motivation + +A channel has exactly two notification states in most Nostr group clients: +subscribed or muted. Users need the middle ground and the temporary one — "only +tell me when someone mentions me here", "mute this for an hour", "hide this +channel until it needs me" — and they need those choices to follow them from +desktop to phone. + +A boolean mute blob (`d` = `channel-mutes`) cannot express any of that, and +extending it in place is unsafe: existing clients parse it strictly and would +drop fields they do not recognize on a last-write-wins round trip, silently +erasing preferences set on a newer client. This NIP therefore defines a +**separate** document, and defines how the two interoperate (see +[Legacy `channel-mutes` Interop](#legacy-channel-mutes-interop)). + +## Non-Goals + +This NIP does not define relay-side notification behavior. The relay stores an +opaque encrypted blob; it MUST NOT be expected to filter, prioritize, or +suppress anything on the basis of these preferences. + +This NIP does not define push delivery or any projection of these preferences +into a push transport. Mobile push wakes are the subject of NIP-PL, whose own +Non-Goals state that this NIP's preferences are not service-side flags — +"preferences are expressed as subscriptions and classes inside the lease". A +client that holds both documents MAY author a NIP-PL lease whose subscriptions +and priority classes reflect these preferences; that projection is client +policy, not part of this NIP. + +This NIP does not define a per-post banner class. The level `all` means "record +every post as unread"; whether a client raises an OS banner per post is a client +UX choice, and the reference implementation deliberately does not. + +This NIP does not define synced global defaults. Only per-channel divergence +from the default is stored; a user's baseline notification settings remain local +to each installation. + +This NIP does not define per-installation preferences. `desktop` (and the +reserved `mobile`) are **device classes**, not device identities — there is no +per-install key in this document, and clients MUST NOT invent one by overloading +a channel id. + +This NIP does not define admin- or community-side notification policy (forced +broadcast delivery, mandatory channels, and similar). That is separate work — +see issue [#2497](https://github.com/block/buzz/issues/2497). + +## Terminology + +This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as +defined in RFC 2119. + +- **channel**: a NIP-29 group, identified by the value of its `h` tag. +- **level**: one of `all`, `mentions`, `mute` (see [Levels](#levels)). +- **timed mute**: a `muteUntil` expiry that overlays the stored level. +- **entry**: the preference record for one channel inside the blob. +- **mention**: a direct `p` tag naming the reader. +- **broadcast marker**: a `["notify","channel"]` / `["notify","here"]` tag on a + channel post, i.e. an `@channel` / `@here` announcement (see issue + [#3146](https://github.com/block/buzz/issues/3146)). + +## Specification + +### Event Structure + +Clients publish a `kind:30078` addressable event (per [NIP-78](78.md)): + +```jsonc +{ + "kind": 30078, + "pubkey": "", + "created_at": 1753600000, + "tags": [ + ["d", "channel-notify-prefs"], + ["t", "channel-notify-prefs"] + ], + "content": "" +} +``` + +The `d` tag MUST be exactly `channel-notify-prefs`. The `t` tag is OPTIONAL and +exists only for relay-side discoverability; clients MUST NOT filter on it. + +`content` MUST be a [NIP-44](44.md) payload encrypted from the author to the +author (self-encryption), whose plaintext is the JSON document below. Because +the payload is self-encrypted, no other party — including the relay — learns +which channels a user has muted. + +### Content + +```jsonc +{ + "version": 1, + "channels": { + "": { + "level": "all" | "mentions" | "mute", // OPTIONAL; absent = "all" + "muteUntil": 1753603600, // OPTIONAL; absolute Unix seconds + "desktop": true, // OPTIONAL; default true + "followAllThreads": false, // OPTIONAL; default false + "broadcasts": true, // OPTIONAL; default true + "updatedAt": 1753600000 // REQUIRED; Unix seconds + } + } +} +``` + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `level` | string | `"all"` | Notification level for the channel. | +| `muteUntil` | number | absent | Absolute Unix **seconds**. While `muteUntil > now`, the effective level is `mute`. | +| `desktop` | boolean | `true` | Deliver OS banners / sounds / dock bounce for this channel on desktop clients. | +| `followAllThreads` | boolean | `false` | Treat every thread in the channel as followed. | +| `broadcasts` | boolean | `true` | Honor broadcast markers (`@channel` / `@here`) in this channel. | +| `updatedAt` | number | — | Unix seconds; the merge key for the **whole** entry. | +| `mobile` | boolean | `true` | **RESERVED.** The `desktop` equivalent for mobile clients. | + +`mobile` is reserved by this NIP and MUST NOT be written by a client that does +not implement mobile notification delivery. The reference desktop client neither +reads nor writes it. Reserving it here keeps the device-class shape stable for +the mobile follow-up instead of inviting a second, incompatible field name. + +`version` MUST be `1`. A reader that encounters a different `version` MUST +ignore the blob rather than guess. + +Unrecognized entry fields MUST be preserved verbatim through parse, merge and +republish, so a newer client's data survives an older client's write. This is +what makes `mobile` safe to reserve. + +`updatedAt` covers the entire entry: entries are replaced atomically, never +merged field by field. A client that changes one toggle MUST republish the whole +entry with a fresh `updatedAt`. + +### Sparse Entries + +An entry that matches every default (`level` absent or `"all"`, no `muteUntil`, +`desktop` true, `followAllThreads` false, `broadcasts` true, no unrecognized +fields) carries no information and SHOULD NOT be stored. Clients SHOULD delete +such an entry instead of writing an all-defaults row, keeping the blob +proportional to the number of channels the user actually customized. NIP-44 +payloads are capped at 65535 bytes; a client that materializes a row per channel +will eventually hit that wall. + +One exception: when clearing a channel back to the defaults, a client MAY write +an explicit all-defaults entry (with a fresh `updatedAt`) if a non-default entry +for that channel may still exist on another device. Merging is a **union** of +keys (see below), so a deletion alone can be undone by an older blob +resurrecting the entry it deleted. Once the all-defaults row is the newest one +everywhere, the next default-valued write MAY drop it. + +Deleting an entry cannot win a race against a concurrent remote write of that +same entry. This is an accepted limitation shared by the sibling `kind:30078` +documents. + +### Fetching + +```jsonc +{ + "kinds": [30078], + "authors": [""], + "#d": ["channel-notify-prefs"], + "limit": 1 +} +``` + +Clients MUST ignore any returned event whose `pubkey` is not the author's own. +Clients SHOULD also keep a live subscription on the same filter so a change made +on one device converges on the others without a poll. + +### Merge Rule + +Merging two blobs is a **per-channel last-write-wins union** over the channel +keys: + +- Every channel id present in either blob is present in the result. +- When both blobs have an entry for a channel, the entry with the greater + `updatedAt` wins **as a whole**. +- On a tie, the local entry wins (idempotent, and avoids a write loop between + two devices whose clocks agree). + +There is no field-level merge and no per-field timestamp. Entries are opaque +units under one `updatedAt`. + +Because the merge is a bare timestamp comparison, a single device with a badly +wrong clock could otherwise pin an entry permanently: every correctly-clocked +edit loses the merge, is overwritten by the remote blob, and the level silently +reverts. On parse — of both the local mirror and a decrypted remote blob — +clients therefore MUST clamp `updatedAt` to at most one hour ahead of their own +clock. Legitimate cross-device skew is far below that bound, and the clamp caps +the damage from a wrong clock at an hour. `muteUntil` MUST NOT be clamped: it is +an absolute future timestamp by design. + +### Writing + +Clients SHOULD debounce writes (the reference implementation uses 2 s), and MUST +re-fetch their own remote blob and merge into it immediately before publishing, +so a device holding stale state cannot erase a newer entry authored elsewhere. + +`created_at` MUST be strictly greater than the `created_at` of the newest blob +the client has seen from itself (`max(now, lastSeen + 1)`), so a replaceable-event +store never rejects the write as older under clock skew. + +Clients SHOULD suppress a publish whose merged payload is identical to the last +one they published, comparing **all** entry fields — comparing only the mute +dimension suppresses legitimate republishes of the other toggles. + +A local edit MUST NOT be clobbered by a fetched or subscribed snapshot: merge +the snapshot into the local state, and republish whenever the merge result still +holds anything the snapshot does not. Clients MUST decide this by comparing the +merge result against the snapshot, **not** by checking whether a debounced +publish is still outstanding — a debounce cancelled by a client restart, +sign-out or community switch would otherwise lose the edit permanently and +silently, since the local mirror keeps it while no device publishes it. The +comparison +terminates: once the client's own republished blob comes back, the merge result +equals it. + +## Levels + +| Level | Label (reference UI) | Meaning | +|---|---|---| +| `all` | All new posts | Every new post in the channel is recorded unread. The default. | +| `mentions` | Just mentions | Posts are recorded unread but do not alert; mentions and followed threads still alert. | +| `mute` | Mute and hide | The channel contributes nothing except direct `p`-tag mentions, and is hidden from channel lists. | + +### Timed Mute + +`muteUntil` is an **absolute epoch in seconds**, computed on the device that +sets it. Storing an absolute instant (rather than a duration plus a start time) +makes "until tomorrow at 9am" resolve in the setting user's local time zone +while remaining unambiguous on every other device. + +Timed mute is an **overlay**, not a level: + +- While `muteUntil > now`, the effective level is `mute`. +- The stored `level` is untouched, so expiry restores the previous level with no + further write. Clients MUST NOT rewrite `level` when setting a timed mute. +- A timed mute MUST NOT hide the channel (see [Hiding](#hiding)). +- Expiry MUST be evaluated lazily, at resolution time, against the current + clock. A client MUST NOT depend on a timer having fired: a client that was + closed across the expiry, and a non-reactive consumer, both resolve correctly. + A client MAY additionally run a coarse timer purely to refresh its UI at the + expiry instant. +- Setting a timed mute while one is running **replaces** it; durations do not + stack. +- Selecting any level, or explicitly unmuting, clears `muteUntil`. + +Because older clients cannot express a temporary mute, a timed mute is +deliberately **not** mirrored into the legacy blob (see below): clients that do +not implement this NIP simply do not honor it. + +## Resolution + +All of a client's notification decisions for a channel MUST derive from one +resolved state, computed from (a) the entry in this blob, (b) the legacy +`channel-mutes` entry, and (c) the current time: + +``` +{ level, timedMuteActive, muteUntil, desktop, followAllThreads, broadcasts, hidden } +``` + +### Legacy `channel-mutes` Interop + +The boolean `d` = `channel-mutes` blob remains authoritative for clients that do +not implement this NIP. Clients that implement both MUST keep them consistent: + +- **Write.** When a preference write changes the channel's *durable* mute state + — level set to `mute`, or away from `mute` — the client MUST also update the + legacy blob (`muted: true` / `muted: false`) so older clients see the change. + Timed mutes are exempt (see above); the other toggles have no legacy + representation and are not mirrored. +- **Read.** When both blobs have an entry for the channel, the entry with the + newer `updatedAt` wins **for the mute dimension only** — an unmute performed + on an old client MUST beat a stale `"mute"` here. This blob wins ties. A newer + legacy unmute resolves the channel to level `all`; the other fields of this + blob still apply. +- A channel muted **only** in the legacy blob resolves to level `mute` but MUST + NOT be hidden — users who muted under the old UI must not have channels + disappear on them. +- **Writes MUST fold the read decision.** A preference write stamps a fresh + `updatedAt`, so a client MUST first apply the rule above to the entry it seeds + from. Otherwise a stale stored `level` wins retroactively over a newer legacy + write and the channel is silently re-muted (or un-muted) because the user + toggled an unrelated preference. One consequence is deliberate: folding a + newer legacy *mute* materializes an explicit `level: "mute"`, so the + "legacy-only mute never hides" rule holds only until the user next edits that + channel's preferences on a client implementing this NIP. Keeping the two + dimensions independent past that point would require a stored `hidden` (or a + per-dimension timestamp) and is out of scope for this version. + +### Hiding + +`hidden` is derived, never stored: it is true if and only if this blob's entry +explicitly sets `level: "mute"` **and** the resolved level is `mute`. A newer +legacy unmute therefore clears hiding together with the mute, while a newer +legacy mute leaves hiding in place. Legacy-only mutes and running timed mutes +never hide. + +A client that hides channels SHOULD keep two escape hatches, so a hidden channel +cannot swallow something addressed to the user: + +1. the channel currently being viewed is always rendered; and +2. a channel holding an unread direct `p`-tag mention is rendered (in muted + styling). + +Only direct mentions qualify for hatch 2. A client that records a per-event +mention-tier classification alongside its unread evidence MUST NOT reuse it +here: that classification was decided under the level in force when the event +arrived, so a `@channel` marker or broadcast reply seen at level `all` would +keep resurfacing the channel after the user mutes it. Direct mentions pierce +every level, so their classification stays correct across later level changes. + +Hidden channels MUST remain reachable through channel browse / search surfaces. + +## Precedence Ladder (normative) + +The following ladder is evaluated top to bottom for one incoming channel event; +the first matching row decides. `unread` marks the channel unread and advances +per-channel counters; `alert` is the OS banner / sound / dock-bounce tier +(clients apply their own slot and dedupe rules on top); `highPriority` is the +mention-tier (numeric badge) classification. + +| # | Condition | `unread` | `alert` | `highPriority` | +|---|---|---|---|---| +| 1 | Direct-message conversation | unchanged — DM delivery bypasses channel levels entirely | | | +| 2 | Direct `p`-tag mention of self | ✅ | ✅ | ✅ | +| 3 | Broadcast marker (`@channel` / `@here`), and effective level ≠ `mute`, and `broadcasts` ≠ false | ✅ | ✅ | ✅ | +| 4 | Broadcast marker, but effective level = `mute` **or** `broadcasts` = false | — fall through to row 5 as an ordinary post — | | | +| 5 | Top-level post, or a NIP-CW broadcast reply — level `all` | ✅ | ✅ | broadcast reply only | +| 6 | Top-level post, or a NIP-CW broadcast reply — level `mentions` | ✅ | ❌ | ❌ | +| 7 | Top-level post, or a NIP-CW broadcast reply — level `mute` | ❌ | ❌ | ❌ | +| 8 | Thread reply whose root the user muted | ❌ | ❌ | ❌ | +| 9 | Thread reply, thread followed (explicitly, by participation, by authorship, or via `followAllThreads`) — level `all` or `mentions` | ✅ | ✅ | ❌ | +| 10 | Thread reply, thread followed — level `mute` | ❌ | ❌ | ❌ | +| 11 | Anything else | ❌ | ❌ | ❌ | + +Notes on the ladder: + +- **Mentions pierce mute** (row 2). A muted channel still badges a direct + mention; this matches Slack and is what makes "Mute and hide" safe. +- **Broadcast markers do not pierce mute** (rows 3–4). `@channel` is + channel-wide attention, and a muted channel has opted out of channel-wide + attention. The per-channel `broadcasts` toggle opts out while leaving the rest + of the level intact. +- **`["notify",…]` and NIP-CW `["broadcast","1"]` are different things.** The + first marks a channel-wide mention; the second marks a thread reply surfaced + to the channel timeline. Only the first is governed by `broadcasts`; the second + is governed by the level like any top-level post. +- **`followAllThreads` promotes replies, it does not defeat mute** (rows 9–10). +- `desktop` is deliberately absent from the ladder. It is a **delivery-side** + gate: a client MUST apply it where an alert is actually delivered, and MUST + NOT let it change `unread` or `highPriority`. The ladder stays + device-agnostic. +- A client whose notification surface has no event graph (e.g. a server-built + activity feed) MUST still apply the channel dimension of this ladder, **in the + ladder's order**: a direct `p`-tag mention of self pierces first (row 2) — + including when the same item also carries a broadcast marker; only items + without such a mention fall to the broadcast-marker rows and obey the level + and `broadcasts`; everything else is suppressed while the channel resolves to + `mute`. + +Level changes apply at resolution time. A client MUST NOT re-notify already +delivered events because a level changed, and MUST NOT retroactively re-tier +recorded unread events. + +## Privacy Considerations + +The blob names every channel the user has customized, which leaks the shape of +their attention. It is therefore self-encrypted with NIP-44 and MUST NOT be +published in plaintext, even though its contents are "only preferences". + +Metadata still leaks: the relay sees that the author holds a +`channel-notify-prefs` blob and how often it changes. Clients SHOULD debounce +writes (which they already do for convergence reasons) rather than publish per +keystroke or per toggle. + +## Kind Usage + +`kind:30078` (NIP-78 application-specific data), `d` = `channel-notify-prefs`. +This is one of several `kind:30078` documents in this family, distinguished only +by their `d` values (`read-state:` — NIP-RS, `channel-sections`, +`channel-mutes`, `channel-stars`, `channel-sort`). + +## Backwards Compatibility + +Clients that do not implement this NIP ignore the blob entirely and continue to +honor `channel-mutes`, which implementers keep in sync for the durable mute +dimension. Levels, timed mutes, and the per-channel toggles degrade to "not +honored" on those clients — never to a wrong value. + +No relay changes are required: `kind:30078` is generic addressable storage with +no `d`-value allowlist. + +## References + +- [NIP-29](29.md) — relay-based groups (channels, `h` tags) +- [NIP-44](44.md) — encrypted payloads +- [NIP-78](78.md) — application-specific data (`kind:30078`) +- NIP-RS — cross-device read state sync (sibling `kind:30078` document) +- NIP-PL — push leases; the eventual mobile push projection and its Non-Goals +- NIP-CW — channel-wide broadcast replies (`["broadcast","1"]`) +- Issue [#3146](https://github.com/block/buzz/issues/3146) — `@channel` / + `@here` broadcast markers, the source of the `["notify",…]` tag +- Issue [#3160](https://github.com/block/buzz/issues/3160) — per-channel + notification settings