From 76220864366488c1c53930d4d39969342a67f1ed Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 13:55:18 -0400 Subject: [PATCH 01/12] feat(desktop): add per-channel notification prefs store and resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of Slack-parity per-channel notification settings (#3160): the synced storage layer and the single pure resolver every consumer will read. - channelNotifyPrefsStorage: kind 30078 / d-tag `channel-notify-prefs` schema, tolerant parse that preserves unknown entry fields, per-channel max-updatedAt LWW merge (local wins ties), sparse entry assignment, and relay-scoped localStorage key. - channelNotifyPrefsSync: NIP-44 self-encrypted sync manager on the channelSortSync template — 2s debounce, fetch-own-blob-merge before publish, all-field publish dedup, monotonic created_at, live subscribe, cancel-not-flush teardown (no cross-relay publish on community switch). - resolveChannelNotifyState: pure, React-free resolution of level, timed mute overlay, advanced toggles, hide flag, plus legacy `channel-mutes` interop (newer updatedAt wins the mute dimension; legacy-only mutes never hide). - timedMuteTicker: one module-level UI-refresh timer for timed-mute expiry, reset from resetCommunityState(). - useChannelNotifyPrefs: local-first hook (cross-tab storage events, (createdAt,eventId) watermark, reconnect resync, pending-local-edit hydration guard) exposing the mutations and a per-channel memoized resolved lookup. Legacy dual-write stays the caller's composition. Unit tests cover the parse/merge/sparse matrix, the resolver matrix (levels x timed mute x legacy interop), and the sync manager's scheduling, filters, and teardown guards. Signed-off-by: LordMelkor Co-authored-by: Claude Code Ai-assisted: true Signed-off-by: LordMelkor --- .../features/communities/useCommunityInit.ts | 2 + .../lib/resolveChannelNotifyState.test.mjs | 217 +++++++++++ .../lib/resolveChannelNotifyState.ts | 106 +++++ .../notifications/lib/timedMuteTicker.ts | 80 ++++ .../src/features/reminders/lib/timePresets.ts | 2 +- .../lib/channelNotifyPrefsStorage.test.mjs | 365 ++++++++++++++++++ .../sidebar/lib/channelNotifyPrefsStorage.ts | 230 +++++++++++ .../lib/channelNotifyPrefsSync.test.mjs | 190 +++++++++ .../sidebar/lib/channelNotifyPrefsSync.ts | 200 ++++++++++ .../sidebar/lib/useChannelNotifyPrefs.ts | 302 +++++++++++++++ desktop/src/shared/constants/kinds.ts | 5 +- 11 files changed, 1697 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/notifications/lib/resolveChannelNotifyState.test.mjs create mode 100644 desktop/src/features/notifications/lib/resolveChannelNotifyState.ts create mode 100644 desktop/src/features/notifications/lib/timedMuteTicker.ts create mode 100644 desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.ts create mode 100644 desktop/src/features/sidebar/lib/channelNotifyPrefsSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelNotifyPrefsSync.ts create mode 100644 desktop/src/features/sidebar/lib/useChannelNotifyPrefs.ts 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/notifications/lib/resolveChannelNotifyState.test.mjs b/desktop/src/features/notifications/lib/resolveChannelNotifyState.test.mjs new file mode 100644 index 0000000000..0adaab8296 --- /dev/null +++ b/desktop/src/features/notifications/lib/resolveChannelNotifyState.test.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_CHANNEL_NOTIFY_STATE, + 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, + 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("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("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); +}); + +// ── 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..c7ded9aa93 --- /dev/null +++ b/desktop/src/features/notifications/lib/resolveChannelNotifyState.ts @@ -0,0 +1,106 @@ +import type { ChannelMuteStore } from "@/features/sidebar/lib/channelMutesStorage"; +import type { + 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; + 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, + desktop: true, + followAllThreads: false, + broadcasts: true, + hidden: false, + }); + +/** + * 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. + * + * 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"; + let level = storedLevel; + let hidden = Boolean(entry) && storedLevel === "mute"; + + if (legacy) { + const legacyWins = !entry || legacy.updatedAt > entry.updatedAt; + if (legacyWins) { + if (legacy.muted) { + level = "mute"; + hidden = false; + } else if (storedLevel === "mute") { + // Old clients can only express muted/unmuted; a newer unmute clears the + // mute dimension and leaves the channel at the default level. + level = "all"; + hidden = false; + } + } + } + + const timedMuteActive = + entry?.muteUntil !== undefined && entry.muteUntil > nowSeconds; + if (timedMuteActive) level = "mute"; + + return { + level, + timedMuteActive, + 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/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/reminders/lib/timePresets.ts b/desktop/src/features/reminders/lib/timePresets.ts index 90400e2d08..c70571b5bc 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); 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..b60975a1b1 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.test.mjs @@ -0,0 +1,365 @@ +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, + }); +}); + +// ── 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..f71135ecb9 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelNotifyPrefsStorage.ts @@ -0,0 +1,230 @@ +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; +} + +/** + * 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). + */ +export function parseNotifyEntry(value: unknown): 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 } 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, +): 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); + 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..a0d166ef6e --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelNotifyPrefsSync.ts @@ -0,0 +1,200 @@ +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; + } + 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/useChannelNotifyPrefs.ts b/desktop/src/features/sidebar/lib/useChannelNotifyPrefs.ts new file mode 100644 index 0000000000..5533a5e0df --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelNotifyPrefs.ts @@ -0,0 +1,302 @@ +import * as React from "react"; + +import { + 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, + 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): an edit made before the first remote blob + // arrived is still in the debounce window. Cancel that publish, but + // re-publish the merged store so the local edit is not silently dropped. + const pending = managerRef.current?.getPendingStore() ?? null; + managerRef.current?.cancelPendingPublish(); + const merged = mergeStores(prev, remote.store); + if (!writeChannelNotifyPrefsStore(pubkey, relayUrl, merged)) + return prev; + if (pending) 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 current = prev.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], + ); + + 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/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. From 683775ab93baac65d886cf818d41f1be6ed5d0ff Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 14:02:30 -0400 Subject: [PATCH 02/12] feat(desktop): resolve notifications through the NIP-CN decision ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of Slack-parity per-channel notification settings (#3160): the one precedence ladder every notification consumer will call. - threading: eventNotifyMode(tags) reads the ["notify","channel"|"here"] marker (#3146), kept strictly separate from isBroadcastReply. - shouldNotify: notifyDecisionForEvent returns {unread, alert, highPriority} so a channel can record unread without alerting. Direct p-tag mentions pierce every level; @channel/@here is gated by the level and the broadcasts opt-out; top-level posts and NIP-CW broadcast replies sit under the level gate; thread replies need a follow (explicit, participation, authorship, or the channel's follow-every-thread) and lose to both the thread mute and the channel mute. - NotifyOptions gains an injected channelPrefs lookup returning the phase-1 resolved state; without it a channel-blind caller falls back to the legacy mutedChannelIds boolean, so the existing call sites keep working until phase 3 threads the lookup through. - isHighPriorityEventForUser takes the same channel options and now agrees with the ladder: an @channel in an opted-out or muted channel, and a broadcast reply below level "all", are no longer mention tier. - shouldNotifyForEvent stays as a transitional boolean view of the decision. Behavior change, intentional: NIP-CW broadcast replies no longer short-circuit ahead of the mute check, matching the rail observer, which already skips muted channels. The two precedence suites were extended in place — every level x event class, the broadcasts toggle, timed-mute expiry via an injected clock, followAllThreads, legacy channel-mutes interop, and notify-tag vs broadcast-tag disambiguation. Signed-off-by: LordMelkor Co-authored-by: Claude Code Ai-assisted: true Signed-off-by: LordMelkor --- .../src/features/messages/lib/threading.ts | 19 ++ .../notifications/lib/shouldNotify.test.mjs | 146 ++++---- .../notifications/lib/shouldNotify.ts | 154 +++++++-- .../lib/shouldNotifyChannelMutes.test.mjs | 317 +++++++++++++++++- 4 files changed, 540 insertions(+), 96 deletions(-) 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/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs index 2642b9b204..9d8683dad9 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { isHighPriorityEventForUser, + notifyDecisionForEvent, shouldNotifyForEvent, } from "./shouldNotify.ts"; @@ -39,20 +40,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 +65,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 +74,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 +88,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 +102,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 +110,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 +118,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 +146,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 +161,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 +180,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 +188,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 +196,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 +228,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 +240,7 @@ test("empty currentPubkey skips p-tag check — muted thread is suppressed", () pTag(PUBKEY), ]); assert.equal( - shouldNotifyForEvent( + unreadFor( event, "", opts({ @@ -287,11 +259,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 +294,63 @@ 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, + }); +}); + +test("shouldNotifyForEvent mirrors the decision's unread flag", () => { + const notified = makeEvent([]); + const ignored = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); + assert.equal(shouldNotifyForEvent(notified, PUBKEY, opts()), true); + assert.equal(shouldNotifyForEvent(ignored, PUBKEY, opts()), false); +}); diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 9ceb9c2df8..0a10ebd452 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -1,8 +1,13 @@ 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"; export function hasMentionForEvent( event: RelayEvent, @@ -16,6 +21,11 @@ export function hasMentionForEvent( ); } +/** 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 +33,148 @@ 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; }; -export function shouldNotifyForEvent( +/** + * 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; +}; + +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; - } - - if (parentId === null) { - return true; + if ( + eventNotifyMode(event.tags) !== null && + state.level !== "mute" && + state.broadcasts + ) { + return MENTION_NOTIFY; } - if (rootId !== null && mutedRootIds.has(rootId)) { - return false; + 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 && participatedRootIds.has(rootId)) { - return true; - } + if (rootId !== null && mutedRootIds.has(rootId)) return NO_NOTIFY; - if (rootId !== null && followedRootIds.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 && authoredRootIds.has(rootId)) { - return true; - } + return { unread: true, alert: true, highPriority: false }; +} - return false; +/** + * Transitional boolean view of {@link notifyDecisionForEvent} for call sites + * that have not yet been split into the unread / alert tiers. + */ +export function shouldNotifyForEvent( + event: RelayEvent, + currentPubkey: string, + options: NotifyOptions, +): boolean { + return notifyDecisionForEvent(event, currentPubkey, options).unread; } +/** + * 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..860742823c 100644 --- a/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { hasMentionForEvent, shouldNotifyForEvent } from "./shouldNotify.ts"; +import { + hasMentionForEvent, + isHighPriorityEventForUser, + notifyDecisionForEvent, + shouldNotifyForEvent, +} from "./shouldNotify.ts"; +import { resolveChannelNotifyState } from "./resolveChannelNotifyState.ts"; const PUBKEY = "a".repeat(64); const OTHER_PUBKEY = "b".repeat(64); @@ -102,7 +108,7 @@ 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), @@ -116,7 +122,7 @@ test("broadcast reply in muted channel still notifies (broadcast fires before mu mutedChannelIds: new Set([CHANNEL_ID]), channelId: CHANNEL_ID, }), - true, + false, ); }); @@ -166,3 +172,308 @@ 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); +}); From 5ac5ca640e90dc7c485c3df396294b1318facbc4 Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 14:22:36 -0400 Subject: [PATCH 03/12] feat(desktop): route every notification consumer through the NIP-CN decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of Slack-parity per-channel notification settings (#3160): the consumers now read the phase-1 resolver and the phase-2 ladder instead of a boolean mute set. - useLiveChannelUpdates forks on the decision: `unread` drives unread tracking (onChannelMessage / onThreadReplyNotification) while the new `onChannelAlert` carries the alert tier, so a channel at level "mentions" still marks unread but never bounces the dock. The onThreadReplyCandidate flow, the active-channel exemption, and the dedupe-before-eligibility ordering are unchanged. - useUnreadChannels threads the prefs lookup through the live path, the catch-up scan, and mention-tier classification. The aggregation moved to a pure `aggregateUnreadChannels` and applies the CURRENTLY resolved level: a muted channel contributes nothing but its mention-tier items (mirroring the sidebar escape hatch), which keeps level changes from re-tiering or re-notifying frozen ObservedUnreadEvent records. - The home badge and the feed-driven desktop banners replace their unconditional `category === "mention"` mute escape with `allowsFeedItemForChannel`: notify-tag items (@channel/@here) obey the level and the broadcasts opt-out, direct mentions still pierce the mute. The banner site additionally honors per-channel `desktop: false`, which also gates all three useAppShellDesktopNotifications handlers (banner, sound, dock bounce) without touching unread state. - communityUnreadObserver fetches and decrypts the prefs blob alongside channel-mutes and projects both through the same pure resolver. A muted channel no longer short-circuits the whole channel: it loses the dot but its mentions still count toward the rail's mentionCount. - AppShell owns the composition through useChannelNotificationSettings, which folds the two blobs together, performs the legacy channel-mutes dual-write on level changes, and exports the effective-mute set that boolean consumers (sidebar glyphs, keyboard nav, thread activity) still read. The resolver and mutations reach the phase-4 UI via AppShellContext. `shouldNotifyForEvent` is gone — every call site now reads the decision object directly. Signed-off-by: LordMelkor Co-authored-by: Claude Code Ai-assisted: true Signed-off-by: LordMelkor --- desktop/src/app/AppShell.tsx | 20 +- desktop/src/app/AppShellContext.tsx | 9 + .../app/useAppShellDesktopNotifications.ts | 17 +- .../src/app/useChannelNotificationSettings.ts | 104 ++++++++ .../unreadChannelAggregation.test.mjs | 90 +++++++ .../features/channels/unreadChannelCounts.ts | 91 +++++++ .../channels/useLiveChannelUpdates.ts | 40 ++-- .../features/channels/useUnreadChannels.ts | 136 +++++------ .../communityUnreadObserver.test.mjs | 224 ++++++++++++++++-- .../communities/communityUnreadObserver.ts | 87 +++++-- desktop/src/features/notifications/hooks.ts | 22 +- .../notifications/lib/shouldNotify.test.mjs | 8 - .../notifications/lib/shouldNotify.ts | 29 ++- .../lib/shouldNotifyChannelMutes.test.mjs | 72 +++++- .../use-feed-desktop-notifications.ts | 32 ++- .../features/sidebar/lib/useChannelMutes.ts | 7 + 16 files changed, 809 insertions(+), 179 deletions(-) create mode 100644 desktop/src/app/useChannelNotificationSettings.ts create mode 100644 desktop/src/features/channels/unreadChannelAggregation.test.mjs diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 877cd948ad..c8e53559b7 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,7 @@ export function AppShell() { threadActivityItems, threadActivityFeedItems, feedItemState, + channelNotify, onOpenSettings: handleOpenSettings, }} > @@ -907,9 +911,9 @@ export function AppShell() { selectedView={selectedView} unreadChannelIds={unreadChannelIds} unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} + mutedChannelIds={channelNotify.mutedChannelIds} + onMuteChannel={channelNotify.muteChannel} + onUnmuteChannel={channelNotify.unmuteChannel} starredChannelIds={starredChannelIds} onStarChannel={starChannel} onUnstarChannel={unstarChannel} diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 4a64de0cb2..40dbfd7f11 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,10 @@ 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; // 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 +82,7 @@ const AppShellContext = React.createContext({ isThreadMuted: () => false, threadActivityItems: [], threadActivityFeedItems: [], + channelNotify: DEFAULT_CHANNEL_NOTIFICATION_SETTINGS, feedItemState: { doneSet: EMPTY_SET, markDone: () => {}, diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 2266862cac..eb5d803aa9 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -5,6 +5,7 @@ import { 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 +31,7 @@ export function useAppShellDesktopNotifications({ notificationSettings, openSearchHit, pubkey, + resolveChannelNotify, }: { channels: Channel[]; goChannel: (channelId: string) => Promise; @@ -39,11 +41,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 +61,8 @@ export function useAppShellDesktopNotifications({ (event: RelayEvent, channel: Channel) => { if ( !notificationSettings.desktopEnabled || - !notificationSettings.slotAlertsEnabled.dm + !notificationSettings.slotAlertsEnabled.dm || + !resolveChannelNotify(channel.id).desktop ) { return; } @@ -86,7 +96,8 @@ export function useAppShellDesktopNotifications({ (channelId: string, event: RelayEvent) => { if ( !notificationSettings.desktopEnabled || - !notificationSettings.slotAlertsEnabled.thread_reply + !notificationSettings.slotAlertsEnabled.thread_reply || + !resolveChannelNotify(channelId).desktop ) { return; } diff --git a/desktop/src/app/useChannelNotificationSettings.ts b/desktop/src/app/useChannelNotificationSettings.ts new file mode 100644 index 0000000000..75986bba74 --- /dev/null +++ b/desktop/src/app/useChannelNotificationSettings.ts @@ -0,0 +1,104 @@ +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; + 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(), + 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]), + ); + + return { + mutedChannelIds, + resolveChannelNotify: resolveChannel, + setChannelNotifyLevel, + muteChannelUntil: prefs.muteChannelUntil, + clearChannelTimedMute: prefs.clearTimedMute, + setChannelNotifyAdvanced: prefs.setChannelAdvanced, + muteChannel, + unmuteChannel, + }; +} diff --git a/desktop/src/features/channels/unreadChannelAggregation.test.mjs b/desktop/src/features/channels/unreadChannelAggregation.test.mjs new file mode 100644 index 0000000000..b78301b3d3 --- /dev/null +++ b/desktop/src/features/channels/unreadChannelAggregation.test.mjs @@ -0,0 +1,90 @@ +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, + 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 }), + ], + }); + 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 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..ad38c838ad 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -120,6 +120,97 @@ export function countUnreadHighPriorityObservedEvents( 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 mention-tier events, mirroring the + * sidebar escape hatch that keeps a hidden channel visible while it holds a + * mention. + */ +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; + + const highPriorityCount = countUnreadHighPriorityObservedEvents( + observedEvents, + readAtFor, + ); + if (isMuted && highPriorityCount === 0) continue; + + unread.add(channel.id); + counts.set( + channel.id, + isMuted + ? highPriorityCount + : countUnreadBadgeObservedEvents(observedEvents, readAtFor), + ); + unreadChannelNotificationCount += isMuted + ? highPriorityCount + : 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" || highPriorityCount > 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..fbb657b6ef 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); @@ -446,6 +465,7 @@ export function useUnreadChannels( normalizedPubkey, recordMentionedRoot, recordUnreadEvent, + resolveChannelNotify, ], ); @@ -558,6 +578,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 +697,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,7 +718,10 @@ 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, @@ -815,6 +840,7 @@ export function useUnreadChannels( normalizedRelayUrl, recordUnreadEvent, relayClient, + resolveChannelNotify, ]); // Unread = channels (excluding active) that have either been manually @@ -836,82 +862,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..733dae2e60 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. mention events — none + // 7. unread 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. unread events — none (marker covers everything) () => [], - // 7. mention events — none + // 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,167 @@ 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 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..bb98c67e21 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,27 @@ 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; + if (notifyPrefsEvents.length > 0) { + 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 +253,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 +277,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 +294,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 +320,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/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index ccc9544f94..b628a2024a 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,11 @@ export function useHomeFeedNotificationState( } if ( item.channelId && - mutedChannelIds?.has(item.channelId) && - item.category !== "mention" + !allowsFeedItemForChannel( + resolveChannelNotify(item.channelId), + item.category === "mention", + item.tags, + ) ) { continue; } @@ -477,8 +489,8 @@ export function useHomeFeedNotificationState( highPriorityChannelIds, isHomeActive, localUnreadFeedIds, - mutedChannelIds, readStateVersion, + resolveChannelNotify, seenFeedIds, settings.homeBadgeEnabled, ]); diff --git a/desktop/src/features/notifications/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs index 9d8683dad9..aa0c659fac 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs @@ -4,7 +4,6 @@ import test from "node:test"; import { isHighPriorityEventForUser, notifyDecisionForEvent, - shouldNotifyForEvent, } from "./shouldNotify.ts"; const PUBKEY = "a".repeat(64); @@ -347,10 +346,3 @@ test("ignored thread reply decision is all-false", () => { highPriority: false, }); }); - -test("shouldNotifyForEvent mirrors the decision's unread flag", () => { - const notified = makeEvent([]); - const ignored = makeEvent([rootTag(ROOT_ID), replyTag(PARENT_ID)]); - assert.equal(shouldNotifyForEvent(notified, PUBKEY, opts()), true); - assert.equal(shouldNotifyForEvent(ignored, PUBKEY, opts()), false); -}); diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 0a10ebd452..e61d20b00e 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -144,15 +144,30 @@ export function notifyDecisionForEvent( } /** - * Transitional boolean view of {@link notifyDecisionForEvent} for call sites - * that have not yet been split into the unread / alert tiers. + * 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. + * + * `@channel` / `@here` items obey the level and the broadcasts opt-out (NIP-CN + * N7) instead of riding the mention exemption; a direct mention pierces the + * mute exactly as it does in the ladder; anything else 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. */ -export function shouldNotifyForEvent( - event: RelayEvent, - currentPubkey: string, - options: NotifyOptions, +export function allowsFeedItemForChannel( + state: ResolvedChannelNotifyState, + isMentionCategory: boolean, + tags: string[][] | undefined, ): boolean { - return notifyDecisionForEvent(event, currentPubkey, options).unread; + if (eventNotifyMode(tags ?? []) !== null) { + return state.level !== "mute" && state.broadcasts; + } + if (isMentionCategory) return true; + return state.level !== "mute"; } /** diff --git a/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs b/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs index 860742823c..bd50622149 100644 --- a/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotifyChannelMutes.test.mjs @@ -2,10 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + allowsFeedItemForChannel, hasMentionForEvent, isHighPriorityEventForUser, notifyDecisionForEvent, - shouldNotifyForEvent, } from "./shouldNotify.ts"; import { resolveChannelNotifyState } from "./resolveChannelNotifyState.ts"; @@ -18,6 +18,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)}`, @@ -65,7 +68,7 @@ test("hasMentionForEvent: empty currentPubkey returns 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, @@ -79,7 +82,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, @@ -97,7 +100,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, @@ -115,7 +118,7 @@ test("broadcast reply in muted channel is suppressed (NIP-CN: mute beats broadca broadcastTag(), ]); assert.equal( - shouldNotifyForEvent(event, PUBKEY, { + unreadFor(event, PUBKEY, { participatedRootIds: EMPTY, followedRootIds: EMPTY, authoredRootIds: EMPTY, @@ -129,7 +132,7 @@ test("broadcast reply in muted channel is suppressed (NIP-CN: mute beats broadca 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, @@ -143,7 +146,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, @@ -161,7 +164,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, @@ -477,3 +480,56 @@ test("isHighPriorityEventForUser: legacy mutedChannelIds demotes a broadcast rep ); 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, []), true); + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mentions" }), false, []), + true, + ); + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), false, []), + false, + ); +}); + +test("allowsFeedItemForChannel: a direct mention pierces the mute", () => { + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), true, []), + true, + ); +}); + +test("allowsFeedItemForChannel: a notify-tag item obeys the level, not the mention exemption", () => { + const tags = [["notify", "channel"]]; + assert.equal(allowsFeedItemForChannel(feedState(), true, tags), true); + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mentions" }), true, tags), + true, + ); + assert.equal( + allowsFeedItemForChannel(feedState({ level: "mute" }), true, tags), + false, + ); +}); + +test("allowsFeedItemForChannel: the broadcasts opt-out drops notify-tag items", () => { + assert.equal( + allowsFeedItemForChannel(feedState({ broadcasts: false }), true, [ + ["notify", "here"], + ]), + false, + ); +}); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index e7b4d726a3..077553de48 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,21 @@ 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, + ) + ); + }) : []; for (const item of currentFeedItems) { @@ -216,7 +232,7 @@ export function useFeedDesktopNotifications( }, [ feed, channels, - mutedChannelIds, + resolveChannelNotify, normalizedPubkey, profiles, settings.desktopEnabled, 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, }; From c9cb6a59c7f0a9915183de0595ba68214b52acb6 Mon Sep 17 00:00:00 2001 From: LordMelkor Date: Mon, 27 Jul 2026 14:45:18 -0400 Subject: [PATCH 04/12] feat(desktop): add the NIP-CN notification UI surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of Slack-parity per-channel notification settings (#3160): the surfaces users actually touch, all reading the phase-1 resolver. - ChannelContextMenu: the binary Mute/Unmute pair becomes a "Notifications" submenu for channels — a radio group over the three levels, the two timed-mute presets ("Mute for 1 hour" / "Mute until tomorrow", reusing timePresets), a "Muted until