From a73090f3965cdbec6068c064e1c5566676e0079c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 15 Jun 2026 14:02:16 +0300 Subject: [PATCH] Deduplicate desktop notifications --- packages/electron/main.mjs | 35 +++++++++++++++++++ .../src/components/auth/SessionAuthGate.tsx | 4 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index e5562eb7..ea9d19dc 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -892,6 +892,37 @@ const focusForegroundWindow = () => { // click events to silently stop firing after ~1 min. // See https://blog.bloomca.me/2025/02/22/electron-mac-notifications const activeNotifications = new Set(); +const nativeNotificationClaims = new Map(); +const NATIVE_NOTIFICATION_DEDUPE_TTL_MS = 5000; + +const getNativeNotificationClaimKey = (payload) => { + const tag = typeof payload?.tag === 'string' ? payload.tag.trim() : ''; + if (tag) return tag; + return [payload?.sessionId, payload?.kind, payload?.title, payload?.body] + .filter((value) => typeof value === 'string' && value.trim()) + .map((value) => value.trim()) + .join('|'); +}; + +const claimNativeNotification = (payload) => { + const key = getNativeNotificationClaimKey(payload); + if (!key) return true; + + const now = Date.now(); + for (const [claimKey, claimedAt] of nativeNotificationClaims) { + if (now - claimedAt > NATIVE_NOTIFICATION_DEDUPE_TTL_MS) { + nativeNotificationClaims.delete(claimKey); + } + } + + const claimedAt = nativeNotificationClaims.get(key) ?? 0; + if (now - claimedAt < NATIVE_NOTIFICATION_DEDUPE_TTL_MS) { + return false; + } + + nativeNotificationClaims.set(key, now); + return true; +}; const maybeShowNativeNotification = (rawInput) => { const payload = normalizeNotificationInput(rawInput); @@ -905,6 +936,10 @@ const maybeShowNativeNotification = (rawInput) => { return; } + if (!claimNativeNotification(payload)) { + return; + } + const title = typeof payload.title === 'string' && payload.title.trim() ? payload.title.trim() : 'OpenChamber'; diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 5a7c252b..9f4e23b1 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -12,7 +12,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; import { authenticateWithPasskey, @@ -181,7 +181,7 @@ const applyDesktopClientToken = async (clientToken: string): Promise => { if (!clientToken) return; const apiBaseUrl = getRuntimeApiBaseUrl(); await persistDesktopClientToken(apiBaseUrl, clientToken); - switchRuntimeEndpoint({ apiBaseUrl, clientToken, runtimeKey: getRuntimeKey() }); + switchRuntimeEndpoint({ apiBaseUrl, clientToken }); }; const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {