From 736c5eb30d867164125fb2e496dd87ef44a50297 Mon Sep 17 00:00:00 2001 From: mdschoff Date: Mon, 31 Aug 2026 12:40:39 -0500 Subject: [PATCH] fix(desktop): notify for the selected channel when the window is unfocused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DM and thread-reply desktop notifications are suppressed for the currently selected channel, but the check only compared channel ids — it ignored whether the window was actually focused. Leaving a DM conversation open and minimizing the app silently dropped every notification for that conversation, the one the user is most likely waiting on. Gate the suppression on isAppFocused(): a channel selected in a blurred or hidden window is not being viewed, so notifications for it fire again. The notifyForActiveChannel opt-in still bypasses suppression entirely. The decision is extracted into a pure isSuppressedAsActiveChannel() predicate shared by both the DM and thread-reply paths, with regression tests. Signed-off-by: mdschoff --- .../activeChannelSuppression.test.mjs | 28 +++++++++++++++++++ .../channels/activeChannelSuppression.ts | 20 +++++++++++++ .../channels/useLiveChannelUpdates.ts | 27 ++++++++++++++---- 3 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 desktop/src/features/channels/activeChannelSuppression.test.mjs create mode 100644 desktop/src/features/channels/activeChannelSuppression.ts diff --git a/desktop/src/features/channels/activeChannelSuppression.test.mjs b/desktop/src/features/channels/activeChannelSuppression.test.mjs new file mode 100644 index 00000000000..389a5810466 --- /dev/null +++ b/desktop/src/features/channels/activeChannelSuppression.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isSuppressedAsActiveChannel } from "./activeChannelSuppression.ts"; + +test("suppresses the active channel while the app is focused", () => { + assert.equal(isSuppressedAsActiveChannel("ch1", "ch1", true, false), true); +}); + +test("does not suppress the active channel while the app is unfocused", () => { + // Regression: a DM channel left selected in a minimized/backgrounded + // window must still notify — the user isn't reading it. + assert.equal(isSuppressedAsActiveChannel("ch1", "ch1", false, false), false); +}); + +test("does not suppress other channels regardless of focus", () => { + assert.equal(isSuppressedAsActiveChannel("ch2", "ch1", true, false), false); + assert.equal(isSuppressedAsActiveChannel("ch2", "ch1", false, false), false); +}); + +test("does not suppress when no channel is active", () => { + assert.equal(isSuppressedAsActiveChannel("ch1", null, true, false), false); +}); + +test("notifyForActiveChannel opt-in disables suppression entirely", () => { + assert.equal(isSuppressedAsActiveChannel("ch1", "ch1", true, true), false); + assert.equal(isSuppressedAsActiveChannel("ch1", "ch1", false, true), false); +}); diff --git a/desktop/src/features/channels/activeChannelSuppression.ts b/desktop/src/features/channels/activeChannelSuppression.ts new file mode 100644 index 00000000000..e3ef551fe2f --- /dev/null +++ b/desktop/src/features/channels/activeChannelSuppression.ts @@ -0,0 +1,20 @@ +/** + * Decides whether a notification for `channelId` is suppressed because the + * user is actively viewing that channel. + * + * "Viewing" requires the app window to be focused: a channel that is merely + * selected in a minimized or backgrounded window is not being read, so + * notifications for it must still fire. `notifyForActiveChannel` opts out of + * suppression entirely. + */ +export function isSuppressedAsActiveChannel( + channelId: string, + activeChannelId: string | null, + appFocused: boolean, + notifyForActiveChannel: boolean, +): boolean { + if (notifyForActiveChannel) { + return false; + } + return channelId === activeChannelId && appFocused; +} diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 7598e8db3e5..746d59738a9 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -11,6 +11,7 @@ import { } from "@/features/messages/lib/threading"; import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; import { relayClient } from "@/shared/api/relayClient"; +import { isAppFocused } from "@/shared/lib/useDocumentVisible"; import { CHANNEL_EVENT_KINDS, CHANNEL_MESSAGE_EVENT_KINDS, @@ -21,6 +22,7 @@ import { type TrailingDebounce, } from "@/shared/lib/trailingDebounce"; +import { isSuppressedAsActiveChannel } from "./activeChannelSuppression"; import { isDmNotifiableKind } from "./isDmNotifiableKind"; import { refreshChannelsWhenIdle } from "./refreshChannelsWhenIdle"; @@ -28,7 +30,7 @@ export type UseLiveChannelUpdatesOptions = { currentPubkey?: string; /** * When true, DM notifications also fire for the channel the user is - * currently viewing (normally suppressed). + * currently viewing (normally suppressed while the app window is focused). */ notifyForActiveChannel?: boolean; onDmMessage?: (event: RelayEvent, channel: Channel) => void; @@ -54,8 +56,8 @@ export type UseLiveChannelUpdatesOptions = { /** * Fired for replies in threads the user authored, participated in, or * follows (non-DM channels only — the DM path owns those). Follows the DM - * active-channel rule: suppressed for the channel being viewed unless - * notifyForActiveChannel opts in. + * active-channel rule: suppressed for the channel being viewed in a focused + * window unless notifyForActiveChannel opts in. */ onThreadReplyDesktopNotification?: ( channelId: string, @@ -220,8 +222,16 @@ export function useLiveChannelUpdates( } // Don't fire a notification for the channel the user is already viewing, - // unless the notify-while-viewing setting opts in. - if (channelId === activeChannelId && !options.notifyForActiveChannel) { + // unless the notify-while-viewing setting opts in. A selected channel in + // an unfocused window doesn't count as viewed. + if ( + isSuppressedAsActiveChannel( + channelId, + activeChannelId, + isAppFocused(), + options.notifyForActiveChannel ?? false, + ) + ) { return; } @@ -315,7 +325,12 @@ export function useLiveChannelUpdates( if (shouldNotify && isThreadedReply) { if ( !dmChannelMap.has(channelId) && - (channelId !== activeChannelId || options.notifyForActiveChannel) + !isSuppressedAsActiveChannel( + channelId, + activeChannelId, + isAppFocused(), + options.notifyForActiveChannel ?? false, + ) ) { options.onThreadReplyDesktopNotification?.(channelId, event); }