From ca3f996d6118f2ec56ff47723a4026d340dc61ae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 16 Jun 2026 22:13:15 -0400 Subject: [PATCH 1/4] fix(client): avoid silent message deferral without push --- src/app/pages/client/ClientNonUIFeatures.tsx | 9 +++++++++ src/app/pages/client/notificationRouting.test.ts | 9 +++++---- src/app/pages/client/notificationRouting.ts | 3 ++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 1e2d9b320..e49531292 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -368,6 +368,13 @@ function MessageNotifications() { 'showMessageContentInEncryptedNotifications' ); const [focusMode] = useSetting(settingsAtom, 'focusMode'); + const pushSubscription = useAtomValue(pushSubscriptionAtom); + const registration = useAtomValue(registrationAtom); + const pushReady = + usePushNotifications && + !!pushSubscription && + !!registration && + notificationPermission('granted'); const nicknames = useAtomValue(nicknamesAtom); const nicknamesRef = useRef(nicknames); @@ -531,6 +538,7 @@ function MessageNotifications() { if ( shouldDeferMessageNotificationToPush( usePushNotifications, + pushReady, document.visibilityState, document.hasFocus() ) @@ -726,6 +734,7 @@ function MessageNotifications() { showMessageContent, showEncryptedMessageContent, usePushNotifications, + pushReady, focusMode, playSound, setInAppBanner, diff --git a/src/app/pages/client/notificationRouting.test.ts b/src/app/pages/client/notificationRouting.test.ts index bb5e5a5d8..6db938805 100644 --- a/src/app/pages/client/notificationRouting.test.ts +++ b/src/app/pages/client/notificationRouting.test.ts @@ -19,9 +19,10 @@ describe('notification routing', () => { }); it('defers message delivery to push whenever the client is not actively focused', () => { - expect(shouldDeferMessageNotificationToPush(true, 'visible', true)).toBe(false); - expect(shouldDeferMessageNotificationToPush(true, 'visible', false)).toBe(true); - expect(shouldDeferMessageNotificationToPush(true, 'hidden', false)).toBe(true); - expect(shouldDeferMessageNotificationToPush(false, 'hidden', false)).toBe(false); + expect(shouldDeferMessageNotificationToPush(true, true, 'visible', true)).toBe(false); + expect(shouldDeferMessageNotificationToPush(true, true, 'visible', false)).toBe(true); + expect(shouldDeferMessageNotificationToPush(true, true, 'hidden', false)).toBe(true); + expect(shouldDeferMessageNotificationToPush(true, false, 'hidden', false)).toBe(false); + expect(shouldDeferMessageNotificationToPush(false, false, 'hidden', false)).toBe(false); }); }); diff --git a/src/app/pages/client/notificationRouting.ts b/src/app/pages/client/notificationRouting.ts index b464b05b8..a4ad986a0 100644 --- a/src/app/pages/client/notificationRouting.ts +++ b/src/app/pages/client/notificationRouting.ts @@ -14,8 +14,9 @@ export function shouldDeferInviteNotificationToPush( export function shouldDeferMessageNotificationToPush( usePushNotifications: boolean, + pushReady: boolean, visibilityState: DocumentVisibilityState, focused: boolean ): boolean { - return usePushNotifications && !isForegroundFocusedClient(visibilityState, focused); + return usePushNotifications && pushReady && !isForegroundFocusedClient(visibilityState, focused); } From 65c1c09e16bbd3882c3ea0a4a6255d10e7237aa8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 16 Jun 2026 22:17:19 -0400 Subject: [PATCH 2/4] docs(changeset): cover push readiness fallback --- .changeset/fix-pwa-push-resume-recovery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-pwa-push-resume-recovery.md b/.changeset/fix-pwa-push-resume-recovery.md index 40115419b..e02843bef 100644 --- a/.changeset/fix-pwa-push-resume-recovery.md +++ b/.changeset/fix-pwa-push-resume-recovery.md @@ -2,4 +2,4 @@ default: patch --- -Keep web push registered across visibility changes, and let the service worker suppress OS notifications only when a controlled page proves it is actively foregrounded. This also preserves the resumed-PWA media/session recovery work so push and authenticated fetches do not silently fail after the app is suspended or restored. +Keep web push registered across visibility changes, and only defer page notifications to push when a usable push transport is actually ready. This also preserves the resumed-PWA media/session recovery work so push and authenticated fetches do not silently fail after the app is suspended or restored. From 8c7a080786b7133060e271c2339151aa6b4b91b8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 16 Jun 2026 22:22:05 -0400 Subject: [PATCH 3/4] fix(sw): require recent foreground heartbeat for suppression --- .../fix-pr117-background-push-suppression.md | 5 ++ src/app/pages/client/ClientNonUIFeatures.tsx | 58 +++++++++++++++++++ src/sw.ts | 12 +++- 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-pr117-background-push-suppression.md diff --git a/.changeset/fix-pr117-background-push-suppression.md b/.changeset/fix-pr117-background-push-suppression.md new file mode 100644 index 000000000..d92f3f92c --- /dev/null +++ b/.changeset/fix-pr117-background-push-suppression.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Require a recent live foreground heartbeat before the service worker suppresses OS push notifications, so stale Safari or PWA pages cannot incorrectly silence background push delivery. diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index e49531292..333ff3629 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -138,6 +138,63 @@ function postToServiceWorkerSource(source: MessageEventSource | null, data: unkn return true; } +function ForegroundHeartbeatFeature() { + useEffect(() => { + if (!('serviceWorker' in navigator) || isTauri()) return undefined; + + let heartbeatIntervalId: number | undefined; + + const sendForegroundHeartbeat = () => { + if (document.visibilityState !== 'visible' || !document.hasFocus()) return; + postToServiceWorker({ type: 'foregroundHeartbeat' }); + }; + + const restartHeartbeat = () => { + if (heartbeatIntervalId !== undefined) { + window.clearInterval(heartbeatIntervalId); + heartbeatIntervalId = undefined; + } + + sendForegroundHeartbeat(); + if (document.visibilityState === 'visible' && document.hasFocus()) { + heartbeatIntervalId = window.setInterval(sendForegroundHeartbeat, 10_000); + } + }; + + const stopHeartbeat = () => { + if (heartbeatIntervalId !== undefined) { + window.clearInterval(heartbeatIntervalId); + heartbeatIntervalId = undefined; + } + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') restartHeartbeat(); + else stopHeartbeat(); + }; + + const handleFocus = () => restartHeartbeat(); + const handleBlur = () => stopHeartbeat(); + const handlePageShow = () => restartHeartbeat(); + + restartHeartbeat(); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); + window.addEventListener('blur', handleBlur); + window.addEventListener('pageshow', handlePageShow); + + return () => { + stopHeartbeat(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); + window.removeEventListener('blur', handleBlur); + window.removeEventListener('pageshow', handlePageShow); + }; + }, []); + + return null; +} + function navigateToServiceWorkerUrl(navigate: ReturnType, url: string): void { try { const target = new URL(url, window.location.origin); @@ -1379,6 +1436,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/sw.ts b/src/sw.ts index 7757c481a..47bdb3744 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -176,6 +176,8 @@ let preloadedSession: SessionInfo | undefined; const clientToSessionWaiters = new Map void>>(); const clientWithPendingSessionRequest = new Set(); +const clientForegroundHeartbeatAt = new Map(); +const FOREGROUND_HEARTBEAT_MAX_AGE_MS = 20_000; async function cleanupDeadClients() { const activeClients = await self.clients.matchAll(); @@ -186,6 +188,7 @@ async function cleanupDeadClients() { sessions.delete(id); clientToSessionWaiters.delete(id); clientWithPendingSessionRequest.delete(id); + clientForegroundHeartbeatAt.delete(id); } }); } @@ -1226,6 +1229,9 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { })() ); } + if (type === 'foregroundHeartbeat') { + clientForegroundHeartbeatAt.set(client.id, Date.now()); + } if (type === 'pushDecryptResult') { // Resolve a pending decryption request from handleMinimalPushPayload const { eventId } = data as { eventId?: string }; @@ -1254,8 +1260,12 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { focused: focused === true, clientId: client.id, }; + const lastForegroundHeartbeat = clientForegroundHeartbeatAt.get(client.id); + const hasRecentForegroundHeartbeat = + typeof lastForegroundHeartbeat === 'number' && + Date.now() - lastForegroundHeartbeat <= FOREGROUND_HEARTBEAT_MAX_AGE_MS; - if (shouldSuppressOsPushForForegroundState(result)) { + if (shouldSuppressOsPushForForegroundState(result) && hasRecentForegroundHeartbeat) { foregroundStatePendingMap.delete(requestId); pending.resolve(result); return; From a434f7cdd3af654178b8d7bbd62d6dafbe3e8fd7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 16 Jun 2026 22:41:23 -0400 Subject: [PATCH 4/4] fix(push): restore upstream visibility handling --- .../fix-pr117-background-push-suppression.md | 2 +- .../notifications/PushNotifications.test.ts | 286 ++++----------- .../notifications/PushNotifications.tsx | 338 ++++++------------ src/app/hooks/useAppVisibility.test.tsx | 196 ++-------- src/app/hooks/useAppVisibility.ts | 250 ++----------- src/app/pages/client/ClientNonUIFeatures.tsx | 224 ++---------- src/app/pages/client/ClientRoot.tsx | 2 +- src/sw.ts | 203 ++--------- 8 files changed, 280 insertions(+), 1221 deletions(-) diff --git a/.changeset/fix-pr117-background-push-suppression.md b/.changeset/fix-pr117-background-push-suppression.md index d92f3f92c..5aa3ab5cd 100644 --- a/.changeset/fix-pr117-background-push-suppression.md +++ b/.changeset/fix-pr117-background-push-suppression.md @@ -2,4 +2,4 @@ default: patch --- -Require a recent live foreground heartbeat before the service worker suppresses OS push notifications, so stale Safari or PWA pages cannot incorrectly silence background push delivery. +Revert the fork-specific foreground suppression and startup reconciliation changes so push registration, page visibility, and service-worker notification handling follow the upstream model again. diff --git a/src/app/features/settings/notifications/PushNotifications.test.ts b/src/app/features/settings/notifications/PushNotifications.test.ts index 9415b173c..3cad4888c 100644 --- a/src/app/features/settings/notifications/PushNotifications.test.ts +++ b/src/app/features/settings/notifications/PushNotifications.test.ts @@ -5,24 +5,9 @@ import type { ClientConfig } from '../../../hooks/useClientConfig'; import { disablePushNotifications, enablePushNotifications, - reconcilePushNotifications, + togglePusher, } from './PushNotifications'; -vi.mock('@sentry/react', () => ({ - metrics: { - count: vi.fn<() => void>(), - }, - startInactiveSpan: vi.fn<() => { setAttribute: () => void; end: () => void }>(() => ({ - setAttribute: vi.fn<() => void>(), - end: vi.fn<() => void>(), - })), - addBreadcrumb: vi.fn<() => void>(), -})); - -vi.mock('@tauri-apps/api/core', () => ({ - isTauri: () => false, -})); - const clientConfig: ClientConfig = { pushNotificationDetails: { webPushAppID: 'moe.sable.web', @@ -33,14 +18,16 @@ const clientConfig: ClientConfig = { function makeMatrixClient(): MatrixClient { return { - setPusher: vi.fn<() => Promise>().mockResolvedValue(undefined), - getPushers: vi - .fn<() => Promise<{ pushers: { app_id: string; pushkey: string }[] }>>() - .mockResolvedValue({ pushers: [] }), + baseUrl: 'https://matrix.example.com', + getAccessToken: vi.fn<() => string>().mockReturnValue('access-token'), getDevice: vi .fn<() => Promise<{ display_name?: string }>>() .mockResolvedValue({ display_name: 'Phone' }), getDeviceId: vi.fn<() => string>().mockReturnValue('DEVICEID'), + getPushers: vi + .fn<() => Promise<{ pushers: { app_id: string; pushkey: string }[] }>>() + .mockResolvedValue({ pushers: [] }), + setPusher: vi.fn<() => Promise>().mockResolvedValue(undefined), } as unknown as MatrixClient; } @@ -58,18 +45,6 @@ function makeSubscription(endpoint = 'https://push.example.com/sub') { } as unknown as PushSubscription; } -function makePusher(appId: string, pushkey: string) { - return { - app_display_name: 'Charm', - app_id: appId, - data: {}, - device_display_name: 'Phone', - kind: 'http', - lang: 'en', - pushkey, - }; -} - function installWebPush(subscription: PushSubscription | null): { controllerPostMessage: ReturnType; subscribe: ReturnType; @@ -77,9 +52,6 @@ function installWebPush(subscription: PushSubscription | null): { const controllerPostMessage = vi.fn<() => void>(); const subscribe = vi.fn<() => Promise>().mockResolvedValue(makeSubscription()); const registration = { - active: undefined, - waiting: undefined, - installing: undefined, pushManager: { getSubscription: vi .fn<() => Promise>() @@ -92,7 +64,6 @@ function installWebPush(subscription: PushSubscription | null): { configurable: true, value: { controller: { - state: 'activated', postMessage: controllerPostMessage, }, ready: Promise.resolve(registration), @@ -110,89 +81,61 @@ afterEach(() => { }); describe('web push notifications', () => { - it('reconciles an enabled push registration at startup when permission is already granted', async () => { + it('reuses an existing browser subscription through the service worker toggle path', async () => { const subscription = makeSubscription(); - installWebPush(subscription); + const { controllerPostMessage, subscribe } = installWebPush(subscription); const mx = makeMatrixClient(); - - vi.stubGlobal('Notification', { permission: 'granted' }); - - await expect( - reconcilePushNotifications(mx, clientConfig, [subscription.toJSON(), vi.fn<() => void>()]) - ).resolves.toBe(true); - - expect(mx.setPusher).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'http', - app_id: 'moe.sable.web', - pushkey: 'p256dh-key', - }) - ); - }); - - it('skips startup reconciliation when notification permission is not granted', async () => { - const subscription = makeSubscription(); - installWebPush(subscription); - const mx = makeMatrixClient(); - - vi.stubGlobal('Notification', { permission: 'default' }); - - await expect( - reconcilePushNotifications(mx, clientConfig, [subscription.toJSON(), vi.fn<() => void>()]) - ).resolves.toBe(false); - - expect(mx.setPusher).not.toHaveBeenCalled(); - }); - - it('updates the Matrix pusher directly and removes the legacy Sable pusher when reusing a browser subscription', async () => { - const subscription = makeSubscription(); - const { controllerPostMessage } = installWebPush(subscription); - const mx = makeMatrixClient(); - vi.mocked(mx.getPushers).mockResolvedValue({ - pushers: [ - makePusher('moe.sable.app.sygnal', 'old-sable-p256dh-key'), - makePusher('moe.sable.web', 'other-device-p256dh-key'), - ], - }); const setSubscription = vi.fn<() => void>(); await enablePushNotifications(mx, clientConfig, [subscription.toJSON(), setSubscription]); - expect(mx.setPusher).toHaveBeenCalledWith( - expect.objectContaining({ + expect(subscribe).not.toHaveBeenCalled(); + expect(setSubscription).not.toHaveBeenCalled(); + expect(controllerPostMessage).toHaveBeenCalledWith({ + url: 'https://matrix.example.com', + type: 'togglePush', + token: 'access-token', + pusherData: expect.objectContaining({ kind: 'http', app_id: 'moe.sable.web', pushkey: 'p256dh-key', - device_display_name: 'Phone', data: expect.objectContaining({ - url: 'https://push.example.com/_matrix/push/v1/notify', - format: 'event_id_only', endpoint: 'https://push.example.com/sub', p256dh: 'p256dh-key', auth: 'auth-key', }), - }) - ); - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.app.sygnal', - pushkey: 'old-sable-p256dh-key', + }), }); - expect(mx.setPusher).not.toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.app.sygnal', - pushkey: 'p256dh-key', + }); + + it('creates a new subscription and posts the pusher to the service worker', async () => { + const { controllerPostMessage, subscribe } = installWebPush(null); + const mx = makeMatrixClient(); + const setSubscription = vi.fn<() => void>(); + + await enablePushNotifications(mx, clientConfig, [null, setSubscription]); + + expect(subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: 'vapid-key', }); - expect(controllerPostMessage).not.toHaveBeenCalled(); - expect(setSubscription).toHaveBeenCalledWith(subscription); + expect(setSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: 'https://push.example.com/sub', + }) + ); + expect(controllerPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'togglePush', + token: 'access-token', + }) + ); }); - it('deletes current and legacy Matrix pushers directly when disabling web push', async () => { + it('posts a null pusher to disable web push', async () => { installWebPush(null); const mx = makeMatrixClient(); - vi.mocked(mx.getPushers).mockResolvedValue({ - pushers: [makePusher('moe.sable.app.sygnal', 'old-sable-p256dh-key')], - }); + const controllerPostMessage = vi.mocked(navigator.serviceWorker.controller!.postMessage); await disablePushNotifications(mx, clientConfig, [ { @@ -205,127 +148,46 @@ describe('web push notifications', () => { vi.fn<() => void>(), ]); - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.web', - pushkey: 'p256dh-key', - }); - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.app.sygnal', - pushkey: 'old-sable-p256dh-key', - }); - }); - - it('propagates failures when deleting the current web push pusher', async () => { - installWebPush(null); - const mx = makeMatrixClient(); - vi.mocked(mx.setPusher).mockImplementation((pusher) => { - if (pusher.app_id === 'moe.sable.web') { - return Promise.reject(new Error('homeserver unavailable')); - } - return Promise.resolve({}); + expect(controllerPostMessage).toHaveBeenCalledWith({ + url: 'https://matrix.example.com', + type: 'togglePush', + token: 'access-token', + pusherData: { + kind: null, + app_id: 'moe.sable.web', + pushkey: 'p256dh-key', + }, }); - - await expect( - disablePushNotifications(mx, clientConfig, [ - { - endpoint: 'https://push.example.com/sub', - keys: { - p256dh: 'p256dh-key', - auth: 'auth-key', - }, - }, - vi.fn<() => void>(), - ]) - ).rejects.toThrow('homeserver unavailable'); - - expect(mx.getPushers).not.toHaveBeenCalled(); }); - it('keeps legacy web pusher cleanup best-effort when deleting legacy pushers fails', async () => { + it('disables push when visible and enables it when hidden', async () => { installWebPush(null); const mx = makeMatrixClient(); - vi.mocked(mx.getPushers).mockResolvedValue({ - pushers: [makePusher('moe.sable.app.sygnal', 'old-sable-p256dh-key')], - }); - vi.mocked(mx.setPusher).mockImplementation((pusher) => { - if (pusher.app_id === 'moe.sable.app.sygnal') { - return Promise.reject(new Error('legacy pusher already gone')); - } - return Promise.resolve({}); - }); - - await expect( - disablePushNotifications(mx, clientConfig, [ - { - endpoint: 'https://push.example.com/sub', - keys: { - p256dh: 'p256dh-key', - auth: 'auth-key', - }, - }, - vi.fn<() => void>(), - ]) - ).resolves.toBeUndefined(); - - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.web', - pushkey: 'p256dh-key', - }); - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.app.sygnal', - pushkey: 'old-sable-p256dh-key', - }); - }); - - it('removes the legacy pusher before replacing an existing browser subscription', async () => { - const subscription = makeSubscription(); - const { subscribe } = installWebPush(subscription); - const mx = makeMatrixClient(); - vi.mocked(mx.getPushers).mockResolvedValue({ - pushers: [makePusher('moe.sable.app.sygnal', 'old-sable-p256dh-key')], - }); - - await enablePushNotifications(mx, clientConfig, [null, vi.fn<() => void>()]); - - expect(subscription.unsubscribe).toHaveBeenCalled(); - expect(subscribe).toHaveBeenCalled(); - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.web', - pushkey: 'p256dh-key', - }); - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.app.sygnal', - pushkey: 'old-sable-p256dh-key', - }); - }); - - it('removes the legacy pusher after creating a first browser subscription', async () => { - const { subscribe } = installWebPush(null); - const mx = makeMatrixClient(); - vi.mocked(mx.getPushers).mockResolvedValue({ - pushers: [makePusher('moe.sable.app.sygnal', 'old-sable-p256dh-key')], + const pushState: [ + PushSubscriptionJSON | null, + (subscription: PushSubscription | null) => void, + ] = [null, vi.fn<() => void>()]; + const enableSpy = vi.spyOn(navigator.serviceWorker.controller!, 'postMessage'); + + await togglePusher(mx, clientConfig, true, true, pushState); + await togglePusher(mx, clientConfig, false, true, pushState); + + expect(enableSpy).toHaveBeenNthCalledWith(1, { + url: 'https://matrix.example.com', + type: 'togglePush', + token: 'access-token', + pusherData: { + kind: null, + app_id: 'moe.sable.web', + pushkey: undefined, + }, }); - - await enablePushNotifications(mx, clientConfig, [null, vi.fn<() => void>()]); - - expect(subscribe).toHaveBeenCalled(); - expect(mx.setPusher).toHaveBeenCalledWith( + expect(enableSpy).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ - kind: 'http', - app_id: 'moe.sable.web', - pushkey: 'p256dh-key', + type: 'togglePush', + token: 'access-token', }) ); - expect(mx.setPusher).toHaveBeenCalledWith({ - kind: null, - app_id: 'moe.sable.app.sygnal', - pushkey: 'old-sable-p256dh-key', - }); }); }); diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 1582a4654..7f510b444 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -1,7 +1,5 @@ import type { MatrixClient } from '$types/matrix-sdk'; -import * as Sentry from '@sentry/react'; import { createDebugLogger } from '$utils/debugLogger'; -import { isTauri } from '@tauri-apps/api/core'; import type { ClientConfig } from '../../../hooks/useClientConfig'; const debugLog = createDebugLogger('PushNotifications'); @@ -11,115 +9,6 @@ type PushSubscriptionState = [ (subscription: PushSubscription | null) => void, ]; -type WebPushPusherData = Parameters[0]; - -const LEGACY_WEB_PUSH_APP_IDS = new Set(['moe.sable.app.sygnal']); - -const getCurrentWebPushAppIds = (clientConfig: ClientConfig): string[] => - [clientConfig.pushNotificationDetails?.webPushAppID].filter((appId): appId is string => !!appId); - -type WebPushPusherDeleteRequest = { - app_id: string; - pushkey: string; -}; - -const deleteWebPushPushers = async ( - mx: MatrixClient, - pushers: WebPushPusherDeleteRequest[], - settleFailures = false -): Promise => { - if (pushers.length === 0) return; - - const deletionPromises = pushers.map((pusher) => - mx.setPusher({ - kind: null, - app_id: pusher.app_id, - pushkey: pusher.pushkey, - } as unknown as Parameters[0]) - ); - - if (settleFailures) { - await Promise.allSettled(deletionPromises); - return; - } - - await Promise.all(deletionPromises); -}; - -const deleteWebPushPushersByPushkey = async ( - mx: MatrixClient, - appIds: string[], - pushkey?: string -): Promise => { - if (!pushkey) return; - - await deleteWebPushPushers( - mx, - appIds.map((appId) => ({ app_id: appId, pushkey })) - ); -}; - -const deleteLegacyWebPushPushers = async (mx: MatrixClient): Promise => { - try { - const response = await mx.getPushers(); - const legacyPushers = (response.pushers ?? []) - .filter((pusher) => LEGACY_WEB_PUSH_APP_IDS.has(pusher.app_id) && !!pusher.pushkey) - .map((pusher) => ({ app_id: pusher.app_id, pushkey: pusher.pushkey })); - - await deleteWebPushPushers(mx, legacyPushers, true); - } catch (error) { - debugLog.warn('notification', 'Failed to inspect legacy web pushers for cleanup', { - error: error instanceof Error ? error.message : String(error), - }); - } -}; - -async function buildWebPushPusherData( - mx: MatrixClient, - clientConfig: ClientConfig, - subscription: PushSubscriptionJSON, - deviceDisplayName: string -): Promise { - const { endpoint, keys } = subscription; - if (!endpoint || !keys?.p256dh || !keys.auth) { - throw new Error('Push subscription keys missing.'); - } - const appId = clientConfig.pushNotificationDetails?.webPushAppID; - const pushNotifyUrl = clientConfig.pushNotificationDetails?.pushNotifyUrl; - if (!appId || !pushNotifyUrl) { - throw new Error('Push notification config is incomplete.'); - } - - const declarativeWebPushFallback = - clientConfig.pushNotificationDetails?.declarativeWebPushFallback === true; - - return { - kind: 'http' as const, - app_id: appId, - pushkey: keys.p256dh, - app_display_name: 'Charm', - device_display_name: deviceDisplayName, - lang: navigator.language || 'en', - data: { - url: pushNotifyUrl, - format: 'event_id_only' as const, - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - ...(declarativeWebPushFallback ? { declarative_web_push: true } : {}), - }, - append: false, - } as unknown as WebPushPusherData; -} - -async function getDeviceDisplayName(mx: MatrixClient): Promise { - try { - return (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device'; - } catch { - return 'Unknown Device'; - } -} - export async function requestBrowserNotificationPermission(): Promise { if (!('Notification' in window)) { debugLog.warn('notification', 'Notification API not available in this browser'); @@ -138,33 +27,11 @@ export async function requestBrowserNotificationPermission(): Promise { - if (isTauri()) return false; - if ( - !('serviceWorker' in navigator) || - !('PushManager' in window) || - !('Notification' in window) - ) { - return false; - } - if (window.Notification.permission !== 'granted') return false; - - await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom); - return true; -} - export async function enablePushNotifications( mx: MatrixClient, clientConfig: ClientConfig, pushSubscriptionAtom: PushSubscriptionState ): Promise { - if (isTauri()) { - throw new Error('Push notifications are disabled in Tauri runtime.'); - } if (!('serviceWorker' in navigator) || !('PushManager' in window)) { debugLog.error( 'notification', @@ -172,119 +39,91 @@ export async function enablePushNotifications( ); throw new Error('Push messaging is not supported in this browser.'); } - - const span = Sentry.startInactiveSpan({ - name: 'push.register', - op: 'notification', - attributes: { - 'push.transport': 'webpush', - 'push.has_service_worker': !!navigator.serviceWorker.controller, - 'push.sw_state': navigator.serviceWorker.controller?.state ?? 'none', - 'push.has_application_server_key': !!clientConfig.pushNotificationDetails?.vapidPublicKey, - }, - }); - debugLog.info('notification', 'Enabling push notifications'); const [pushSubAtom, setPushSubscription] = pushSubscriptionAtom; const registration = await navigator.serviceWorker.ready; - const currentBrowserSub = await registration.pushManager.getSubscription(); - Sentry.addBreadcrumb({ - category: 'push', - message: 'Push registration attempt', - data: { - existingSubscription: !!currentBrowserSub, - permissionState: 'Notification' in window ? window.Notification.permission : 'unsupported', - swControllerState: navigator.serviceWorker.controller?.state ?? 'none', - }, - level: 'info', - }); - - try { - /* Self-Healing Check. Effectively checks if the browser has invalidated our subscription and recreates it + /* Self-Healing Check. Effectively checks if the browser has invalidated our subscription and recreates it only when necessary. This prevents us from needing an external call to get back the web push info. */ - if (currentBrowserSub && pushSubAtom && currentBrowserSub.endpoint === pushSubAtom.endpoint) { - debugLog.info('notification', 'Push subscription already exists and is valid - reusing', { + if (currentBrowserSub && pushSubAtom && currentBrowserSub.endpoint === pushSubAtom.endpoint) { + debugLog.info('notification', 'Push subscription already exists and is valid - reusing', { + endpoint: pushSubAtom.endpoint, + }); + const { keys } = pushSubAtom; + if (!keys?.p256dh || !keys.auth) return; + const pusherData = { + kind: 'http' as const, + app_id: clientConfig.pushNotificationDetails?.webPushAppID, + pushkey: keys.p256dh, + app_display_name: 'Cinny', + device_display_name: 'This Browser', + lang: navigator.language || 'en', + data: { + url: clientConfig.pushNotificationDetails?.pushNotifyUrl, + format: 'event_id_only' as const, endpoint: pushSubAtom.endpoint, - }); - setPushSubscription(currentBrowserSub); - const pusherData = await buildWebPushPusherData( - mx, - clientConfig, - currentBrowserSub.toJSON(), - await getDeviceDisplayName(mx) - ); - await mx.setPusher(pusherData); - await deleteLegacyWebPushPushers(mx); + p256dh: keys.p256dh, + auth: keys.auth, + }, + append: false, + }; + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); + return; + } - span.setAttribute('push.endpoint', pushSubAtom.endpoint); - span.setAttribute('push.success', true); - span.setAttribute('push.reused_subscription', true); - span.end(); - Sentry.metrics.count('sable.push.registration', 1, { - attributes: { outcome: 'reused', has_vapid: true }, - }); - return; - } + if (currentBrowserSub) { + debugLog.info('notification', 'Unsubscribing old push subscription'); + await currentBrowserSub.unsubscribe(); + } - if (currentBrowserSub) { - debugLog.info('notification', 'Unsubscribing old push subscription'); - await deleteWebPushPushersByPushkey( - mx, - getCurrentWebPushAppIds(clientConfig), - currentBrowserSub.toJSON().keys?.p256dh - ); - await deleteLegacyWebPushPushers(mx); - await currentBrowserSub.unsubscribe(); - } + debugLog.info('notification', 'Creating new push subscription'); + const newSubscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey, + }); - debugLog.info('notification', 'Creating new push subscription'); - const newSubscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey, - }); + debugLog.info('notification', 'Push subscription created successfully', { + endpoint: newSubscription.endpoint, + }); + setPushSubscription(newSubscription); - debugLog.info('notification', 'Push subscription created successfully', { + const subJson = newSubscription.toJSON(); + const { keys } = subJson; + if (!keys?.p256dh || !keys.auth) { + debugLog.error('notification', 'Push subscription missing required keys'); + throw new Error('Push subscription keys missing.'); + } + const pusherData = { + kind: 'http' as const, + app_id: clientConfig.pushNotificationDetails?.webPushAppID, + pushkey: keys.p256dh, + app_display_name: 'Cinny', + device_display_name: + (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device', + lang: navigator.language || 'en', + data: { + url: clientConfig.pushNotificationDetails?.pushNotifyUrl, + format: 'event_id_only' as const, endpoint: newSubscription.endpoint, - }); - setPushSubscription(newSubscription); - - const subJson = newSubscription.toJSON(); - const pusherData = await buildWebPushPusherData( - mx, - clientConfig, - subJson, - await getDeviceDisplayName(mx) - ); - await mx.setPusher(pusherData); - await deleteLegacyWebPushPushers(mx); + p256dh: keys.p256dh, + auth: keys.auth, + }, + append: false, + }; - span.setAttribute('push.endpoint', newSubscription.endpoint); - span.setAttribute('push.success', true); - span.end(); - Sentry.metrics.count('sable.push.registration', 1, { - attributes: { outcome: 'created', has_vapid: true }, - }); - } catch (err) { - span.setAttribute('push.success', false); - span.setAttribute('push.error', err instanceof Error ? err.message : String(err)); - span.end(); - Sentry.metrics.count('sable.push.registration', 1, { - attributes: { - outcome: 'failed', - error_type: err instanceof Error ? err.name : 'unknown', - }, - }); - Sentry.addBreadcrumb({ - category: 'push', - message: 'Push registration failed', - data: { error: err instanceof Error ? err.message : String(err) }, - level: 'error', - }); - throw err; - } + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); } /** @@ -296,15 +135,21 @@ export async function disablePushNotifications( clientConfig: ClientConfig, pushSubscriptionAtom: PushSubscriptionState ): Promise { - if (isTauri()) return; - debugLog.info('notification', 'Disabling push notifications'); const [pushSubAtom] = pushSubscriptionAtom; - const pushkey = pushSubAtom?.keys?.p256dh; - const appIds = getCurrentWebPushAppIds(clientConfig); - await deleteWebPushPushersByPushkey(mx, appIds, pushkey); - await deleteLegacyWebPushPushers(mx); + const pusherData = { + kind: null, + app_id: clientConfig.pushNotificationDetails?.webPushAppID, + pushkey: pushSubAtom?.keys?.p256dh, + }; + + navigator.serviceWorker.controller?.postMessage({ + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }); } export async function deRegisterAllPushers(mx: MatrixClient): Promise { @@ -323,3 +168,20 @@ export async function deRegisterAllPushers(mx: MatrixClient): Promise { await Promise.allSettled(deletionPromises); } + +export async function togglePusher( + mx: MatrixClient, + clientConfig: ClientConfig, + visible: boolean, + usePushNotifications: boolean, + pushSubscriptionAtom: PushSubscriptionState, + keepEnabledWhenVisible = false +): Promise { + if (usePushNotifications) { + if (visible && !keepEnabledWhenVisible) { + await disablePushNotifications(mx, clientConfig, pushSubscriptionAtom); + } else { + await enablePushNotifications(mx, clientConfig, pushSubscriptionAtom); + } + } +} diff --git a/src/app/hooks/useAppVisibility.test.tsx b/src/app/hooks/useAppVisibility.test.tsx index 0399de4ea..c43946146 100644 --- a/src/app/hooks/useAppVisibility.test.tsx +++ b/src/app/hooks/useAppVisibility.test.tsx @@ -1,47 +1,25 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MatrixClient } from '$types/matrix-sdk'; -import { SyncState } from '$types/matrix-sdk'; -import type { Session } from '$state/sessions'; import { appEvents } from '../utils/appEvents'; import { useAppVisibility } from './useAppVisibility'; const mocks = vi.hoisted(() => ({ - pushSessionToSW: vi.fn<() => void>(), - swPostMessage: vi.fn<() => void>(), -})); - -vi.mock('@sentry/react', () => ({ - addBreadcrumb: vi.fn<() => void>(), - metrics: { - count: vi.fn<() => void>(), - distribution: vi.fn<() => void>(), - }, + togglePusher: vi.fn<() => Promise>(), })); vi.mock('$utils/user-agent', () => ({ mobileOrTablet: () => false, })); -vi.mock('../../sw-session', () => ({ - pushSessionToSW: mocks.pushSessionToSW, +vi.mock('../features/settings/notifications/PushNotifications', () => ({ + togglePusher: mocks.togglePusher, })); vi.mock('./useClientConfig', () => ({ useClientConfig: () => ({}), - useExperimentVariant: () => ({ - inExperiment: false, - variant: undefined, - }), })); -const session: Session = { - baseUrl: 'https://matrix.example.com', - accessToken: 'access-token', - userId: '@alice:example.com', - deviceId: 'DEVICE', -}; - function setVisibilityState(visibilityState: DocumentVisibilityState): void { Object.defineProperty(document, 'visibilityState', { configurable: true, @@ -49,145 +27,40 @@ function setVisibilityState(visibilityState: DocumentVisibilityState): void { }); } -function setOnline(online: boolean): void { - Object.defineProperty(window.navigator, 'onLine', { - configurable: true, - value: online, - }); -} - -function makeClient(syncState: SyncState): MatrixClient & { - emitSyncState: (state: SyncState) => void; - retryImmediately: ReturnType boolean>>; -} { - let currentSyncState = syncState; - const retryImmediately = vi.fn<() => boolean>(() => true); - - return { - getSyncState: () => currentSyncState, - retryImmediately, - emitSyncState: (state: SyncState) => { - currentSyncState = state; - }, - } as unknown as MatrixClient & { - emitSyncState: (state: SyncState) => void; - retryImmediately: ReturnType boolean>>; - }; -} - describe('useAppVisibility', () => { beforeEach(() => { - vi.useFakeTimers(); setVisibilityState('visible'); - setOnline(true); - mocks.pushSessionToSW.mockClear(); - mocks.swPostMessage.mockClear(); - - Object.defineProperty(window.navigator, 'serviceWorker', { - configurable: true, - value: { - controller: null, - ready: Promise.resolve({ - active: { - state: 'activated', - postMessage: mocks.swPostMessage, - }, - }), - }, - }); + mocks.togglePusher.mockClear(); }); afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('does not abort a healthy sliding sync poll on focus', () => { - const mx = makeClient(SyncState.Syncing); - - renderHook(() => useAppVisibility(session)); - - act(() => { - window.dispatchEvent(new Event('focus')); - }); - - expect(mx.retryImmediately).not.toHaveBeenCalled(); - }); - - it('does not automatically retry when sync becomes degraded', () => { - const mx = makeClient(SyncState.Syncing); - - renderHook(() => useAppVisibility(session)); - - act(() => { - mx.emitSyncState(SyncState.Reconnecting); - }); - - expect(mx.retryImmediately).not.toHaveBeenCalled(); - - act(() => { - vi.advanceTimersByTime(20_000); - }); - - expect(mx.retryImmediately).not.toHaveBeenCalled(); - }); - - it('does not retry immediately on mount when sync is still connecting', () => { - const mx = makeClient(SyncState.Reconnecting); - - renderHook(() => useAppVisibility(session)); - - expect(mx.retryImmediately).not.toHaveBeenCalled(); - }); - - it('does not push the service worker session on focus or online events', () => { - renderHook(() => useAppVisibility(session)); - - act(() => { - window.dispatchEvent(new Event('focus')); - window.dispatchEvent(new Event('online')); - }); - - expect(mocks.pushSessionToSW).not.toHaveBeenCalled(); - }); - - it('emits an initial visible event for timeline refresh without retrying sync', () => { - const mx = makeClient(SyncState.Syncing); + it('emits visibility events through appEvents', () => { const visibilityHandler = vi.fn<(visible: boolean) => void>(); const unsubscribe = appEvents.onVisibilityChange(visibilityHandler); + const mx = {} as MatrixClient; - renderHook(() => useAppVisibility(session)); + renderHook(() => useAppVisibility(mx)); act(() => { - vi.advanceTimersByTime(100); + setVisibilityState('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + setVisibilityState('visible'); + document.dispatchEvent(new Event('visibilitychange')); }); - expect(visibilityHandler).toHaveBeenCalledWith(true); - expect(mx.retryImmediately).not.toHaveBeenCalled(); + expect(visibilityHandler).toHaveBeenNthCalledWith(1, false); + expect(visibilityHandler).toHaveBeenNthCalledWith(2, true); unsubscribe(); }); - it('emits visible on bfcache restore without retrying sync', () => { - const mx = makeClient(SyncState.Syncing); - const visibilityHandler = vi.fn<(visible: boolean) => void>(); - const unsubscribe = appEvents.onVisibilityChange(visibilityHandler); + it('toggles the pusher when visibility changes', () => { + const mx = {} as MatrixClient; - renderHook(() => useAppVisibility(session)); - visibilityHandler.mockClear(); - - act(() => { - window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); - }); - - expect(visibilityHandler).toHaveBeenCalledWith(true); - expect(mx.retryImmediately).not.toHaveBeenCalled(); - - unsubscribe(); - }); - - it('requests a service worker claim when the app becomes visible without a controller', async () => { - renderHook(() => useAppVisibility(session)); + renderHook(() => useAppVisibility(mx)); act(() => { setVisibilityState('hidden'); @@ -196,24 +69,23 @@ describe('useAppVisibility', () => { document.dispatchEvent(new Event('visibilitychange')); }); - await act(async () => { - await Promise.resolve(); - }); - - expect(mocks.swPostMessage).toHaveBeenCalledWith({ type: 'CLAIM_CLIENTS' }); - }); - - it('requests a service worker claim on bfcache restore without a controller', async () => { - renderHook(() => useAppVisibility(session)); - - act(() => { - window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); - }); - - await act(async () => { - await Promise.resolve(); - }); - - expect(mocks.swPostMessage).toHaveBeenCalledWith({ type: 'CLAIM_CLIENTS' }); + expect(mocks.togglePusher).toHaveBeenNthCalledWith( + 1, + mx, + {}, + false, + false, + expect.any(Array), + false + ); + expect(mocks.togglePusher).toHaveBeenNthCalledWith( + 2, + mx, + {}, + true, + false, + expect.any(Array), + false + ); }); }); diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index da5e9df7b..e4f16c07c 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,187 +1,32 @@ -import { useCallback, useEffect } from 'react'; -import * as Sentry from '@sentry/react'; -import type { Session } from '$state/sessions'; +import { useEffect } from 'react'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { useAtom } from 'jotai'; +import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig, useExperimentVariant } from './useClientConfig'; +import { useClientConfig } from './useClientConfig'; +import { useSetting } from '../state/hooks/settings'; +import { settingsAtom } from '../state/settings'; +import { pushSubscriptionAtom } from '../state/pushSubscription'; +import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; -import { mobileOrTablet } from '$utils/user-agent'; -import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; - -type SessionSyncReason = 'heartbeat'; -type ServiceWorkerClaimReason = 'pageshow_restore' | 'visible_foreground'; - -const requestServiceWorkerClaim = (reason: ServiceWorkerClaimReason) => { - if (!('serviceWorker' in navigator)) return; - if (navigator.serviceWorker.controller) return; - - Sentry.addBreadcrumb({ - category: 'service_worker.claim', - message: 'Requested service worker client claim', - level: 'warning', - data: { - reason, - visibilityState: document.visibilityState, - online: navigator.onLine, - }, - }); - Sentry.metrics.count('sable.sw.claim_requested', 1, { - attributes: { - reason, - visibility_state: document.visibilityState, - online: navigator.onLine, - }, - }); - - navigator.serviceWorker.ready - .then((registration) => { - const activeWorker = registration.active; - if (!activeWorker) return; - if (activeWorker.state !== 'activated') return; - // oxlint-disable-next-line unicorn/require-post-message-target-origin - activeWorker.postMessage({ type: 'CLAIM_CLIENTS' }); - }) - .catch((error) => { - Sentry.addBreadcrumb({ - category: 'service_worker.claim', - message: 'Service worker claim request failed', - level: 'warning', - data: { reason, error: error instanceof Error ? error.message : String(error) }, - }); - }); -}; - -export function useAppVisibility(activeSession?: Session) { +export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); - const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); - const hasDirectSessionSyncConfig = sessionSyncConfig !== undefined; - - const phase2VisibleHeartbeat = sessionSyncVariant.inExperiment - ? sessionSyncVariant.variant === 'session-sync-heartbeat' || - sessionSyncVariant.variant === 'session-sync-adaptive' - : hasDirectSessionSyncConfig - ? sessionSyncConfig?.phase2VisibleHeartbeat === true - : true; - const phase3AdaptiveBackoffJitter = sessionSyncVariant.inExperiment - ? sessionSyncVariant.variant === 'session-sync-adaptive' - : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; - - const heartbeatIntervalMs = Math.max( - 1000, - sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS - ); - - const pushSessionNow = useCallback( - (reason: SessionSyncReason): 'sent' | 'skipped' => { - const baseUrl = activeSession?.baseUrl; - const accessToken = activeSession?.accessToken; - const userId = activeSession?.userId; - const canPush = - typeof baseUrl === 'string' && - typeof accessToken === 'string' && - typeof userId === 'string' && - 'serviceWorker' in navigator && - !!navigator.serviceWorker.controller; - - if (!canPush) { - debugLog.warn('network', 'Skipped SW session sync', { - reason, - hasBaseUrl: !!baseUrl, - hasAccessToken: !!accessToken, - hasUserId: !!userId, - hasSwController: !!navigator.serviceWorker?.controller, - }); - return 'skipped'; - } - - pushSessionToSW(baseUrl, accessToken, userId); - Sentry.metrics.count('sable.sw.session_sync', 1, { - attributes: { - reason, - phase2_visible_heartbeat: phase2VisibleHeartbeat, - phase3_adaptive_backoff_jitter: phase3AdaptiveBackoffJitter, - }, - }); - debugLog.info('network', 'Pushed session to SW', { - reason, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - }); - return 'sent'; - }, - [ - activeSession?.accessToken, - activeSession?.baseUrl, - activeSession?.userId, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - ] - ); + const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); + const pushSubAtom = useAtom(pushSubscriptionAtom); + const isMobile = mobileOrTablet(); useEffect(() => { - let hiddenAt: number | undefined = - document.visibilityState === 'hidden' ? performance.now() : undefined; - const handleVisibilityChange = () => { const isVisible = document.visibilityState === 'visible'; - const now = performance.now(); - const hiddenDurationMs = isVisible && hiddenAt !== undefined ? now - hiddenAt : undefined; - if (!isVisible) hiddenAt = now; - if (isVisible) hiddenAt = undefined; - - Sentry.addBreadcrumb({ - category: 'app.visibility', - message: isVisible ? 'App became visible' : 'App became hidden', - level: 'info', - data: { - visibilityState: document.visibilityState, - hiddenDurationMs: hiddenDurationMs ? Math.round(hiddenDurationMs) : undefined, - online: navigator.onLine, - mobileOrTablet: mobileOrTablet(), - }, - }); - Sentry.metrics.count('sable.app.visibility_change', 1, { - attributes: { - visibility_state: document.visibilityState, - online: navigator.onLine, - mobile: mobileOrTablet(), - }, - }); - if (hiddenDurationMs !== undefined) { - Sentry.metrics.distribution('sable.app.hidden_duration_ms', hiddenDurationMs, { - attributes: { online: navigator.onLine, mobile: mobileOrTablet() }, - }); - } - debugLog.info( 'general', `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, - { - visibilityState: document.visibilityState, - hiddenDurationMs: hiddenDurationMs ? Math.round(hiddenDurationMs) : undefined, - online: navigator.onLine, - } + { visibilityState: document.visibilityState } ); appEvents.emitVisibilityChange(isVisible); - if (isVisible) { - if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) { - Sentry.addBreadcrumb({ - category: 'service_worker.claim', - message: 'Foreground resume without service worker controller', - level: 'warning', - data: { - reason: 'visible_foreground', - visibilityState: document.visibilityState, - online: navigator.onLine, - }, - }); - requestServiceWorkerClaim('visible_foreground'); - } - } if (!isVisible) { appEvents.emitVisibilityHidden(); } @@ -195,67 +40,12 @@ export function useAppVisibility(activeSession?: Session) { }, []); useEffect(() => { - const emitVisible = () => { - if (document.visibilityState === 'visible') { - appEvents.emitVisibilityChange(true); - } - }; - - const handlePageShow = (ev: PageTransitionEvent) => { - if (ev.persisted) { - Sentry.addBreadcrumb({ - category: 'app.visibility', - message: 'App restored from pageshow', - level: 'info', - data: { - persisted: ev.persisted, - visibilityState: document.visibilityState, - online: navigator.onLine, - }, - }); - Sentry.metrics.count('sable.app.pageshow', 1, { - attributes: { - persisted: ev.persisted, - visibility_state: document.visibilityState, - online: navigator.onLine, - }, - }); - requestServiceWorkerClaim('pageshow_restore'); - emitVisible(); - } - }; - - const timeoutId = window.setTimeout(emitVisible, 100); - window.addEventListener('pageshow', handlePageShow); - - return () => { - window.clearTimeout(timeoutId); - window.removeEventListener('pageshow', handlePageShow); - }; - }, []); - - useEffect(() => { - if (!phase2VisibleHeartbeat) return undefined; + if (!mx) return undefined; - let timeoutId: number | undefined; - const getDelayMs = (): number => { - if (!phase3AdaptiveBackoffJitter) return heartbeatIntervalMs; - - const jitter = 0.8 + Math.random() * 0.4; - return Math.max(1000, Math.round(heartbeatIntervalMs * jitter)); - }; - - const tick = () => { - if (document.visibilityState === 'visible' && navigator.onLine) { - pushSessionNow('heartbeat'); - } - - timeoutId = window.setTimeout(tick, getDelayMs()); - }; + const unsubscribe = appEvents.onVisibilityChange((isVisible) => { + void togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); + }); - timeoutId = window.setTimeout(tick, getDelayMs()); - return () => { - if (timeoutId !== undefined) window.clearTimeout(timeoutId); - }; - }, [heartbeatIntervalMs, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter, pushSessionNow]); + return unsubscribe; + }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 333ff3629..d28e5a8c7 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom, useStore } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; @@ -31,7 +31,6 @@ import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; import { usePreviousValue } from '$hooks/usePreviousValue'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useClientConfig } from '$hooks/useClientConfig'; import { getMemberDisplayName, getNotificationType, @@ -46,7 +45,6 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { registrationAtom } from '$state/serviceWorkerRegistration'; import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; -import { pushSubscriptionAtom } from '$state/pushSubscription'; import { buildRoomMessageNotification, resolveNotificationPreviewText, @@ -92,15 +90,10 @@ import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { useReminderSync } from '$features/bookmarks/useReminderSync'; import { getInboxBookmarksPath, getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; -import { - shouldDeferInviteNotificationToPush, - shouldDeferMessageNotificationToPush, -} from './notificationRouting'; import { NotificationTransportRuntime, type NotificationTransportRuntimeContext, } from '../../features/settings/notifications/NotificationTransportRuntime'; -import { reconcilePushNotifications } from '../../features/settings/notifications/PushNotifications'; import { normalizeNotificationTransportMode, resolvePreferredNotificationTransportProvider, @@ -138,63 +131,6 @@ function postToServiceWorkerSource(source: MessageEventSource | null, data: unkn return true; } -function ForegroundHeartbeatFeature() { - useEffect(() => { - if (!('serviceWorker' in navigator) || isTauri()) return undefined; - - let heartbeatIntervalId: number | undefined; - - const sendForegroundHeartbeat = () => { - if (document.visibilityState !== 'visible' || !document.hasFocus()) return; - postToServiceWorker({ type: 'foregroundHeartbeat' }); - }; - - const restartHeartbeat = () => { - if (heartbeatIntervalId !== undefined) { - window.clearInterval(heartbeatIntervalId); - heartbeatIntervalId = undefined; - } - - sendForegroundHeartbeat(); - if (document.visibilityState === 'visible' && document.hasFocus()) { - heartbeatIntervalId = window.setInterval(sendForegroundHeartbeat, 10_000); - } - }; - - const stopHeartbeat = () => { - if (heartbeatIntervalId !== undefined) { - window.clearInterval(heartbeatIntervalId); - heartbeatIntervalId = undefined; - } - }; - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') restartHeartbeat(); - else stopHeartbeat(); - }; - - const handleFocus = () => restartHeartbeat(); - const handleBlur = () => stopHeartbeat(); - const handlePageShow = () => restartHeartbeat(); - - restartHeartbeat(); - document.addEventListener('visibilitychange', handleVisibilityChange); - window.addEventListener('focus', handleFocus); - window.addEventListener('blur', handleBlur); - window.addEventListener('pageshow', handlePageShow); - - return () => { - stopHeartbeat(); - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('focus', handleFocus); - window.removeEventListener('blur', handleBlur); - window.removeEventListener('pageshow', handlePageShow); - }; - }, []); - - return null; -} - function navigateToServiceWorkerUrl(navigate: ReturnType, url: string): void { try { const target = new URL(url, window.location.origin); @@ -297,54 +233,16 @@ function FaviconUpdater() { return null; } -function WebPushStartupReconciler() { - const mx = useMatrixClient(); - const clientConfig = useClientConfig(); - const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); - const store = useStore(); - const setPushSubscription = useSetAtom(pushSubscriptionAtom); - const reconciledUserIdRef = useRef(null); - - useEffect(() => { - if (!usePushNotifications || isTauri()) return; - - const userId = mx.getUserId() ?? null; - if (!userId) return; - if (reconciledUserIdRef.current === userId) return; - - reconciledUserIdRef.current = userId; - void reconcilePushNotifications(mx, clientConfig, [ - store.get(pushSubscriptionAtom), - setPushSubscription, - ]).catch((error) => { - reconciledUserIdRef.current = null; - transportLog.warn('notification', 'Web push startup reconciliation failed', { - userId, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, [mx, clientConfig, store, setPushSubscription, usePushNotifications]); - - return null; -} - function InviteNotifications() { const audioRef = useRef(null); const invites = useAtomValue(allInvitesAtom); const perviousInviteLen = usePreviousValue(invites.length, 0); const mx = useMatrixClient(); - const pushSubscription = useAtomValue(pushSubscriptionAtom); - const registration = useAtomValue(registrationAtom); const navigate = useNavigate(); const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications'); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - const pushReady = - usePushNotifications && - !!pushSubscription && - !!registration && - notificationPermission('granted'); const notify = useCallback( (count: number) => { @@ -371,7 +269,8 @@ function InviteNotifications() { useEffect(() => { if (invites.length <= perviousInviteLen || mx.getSyncState() !== SyncState.Syncing) return; - if (shouldDeferInviteNotificationToPush(usePushNotifications, pushReady)) return; + // SW push (via Sygnal) handles invite notifications when the app is backgrounded. + if (document.visibilityState !== 'visible' && usePushNotifications) return; // OS notification for invites — desktop only. if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { @@ -392,7 +291,6 @@ function InviteNotifications() { showSystemNotifications, usePushNotifications, notificationSound, - pushReady, notify, playSound, ]); @@ -417,7 +315,6 @@ function MessageNotifications() { const appBaseUrl = useSettingsLinkBaseUrl(); const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications'); - const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); const [showEncryptedMessageContent] = useSetting( @@ -425,13 +322,6 @@ function MessageNotifications() { 'showMessageContentInEncryptedNotifications' ); const [focusMode] = useSetting(settingsAtom, 'focusMode'); - const pushSubscription = useAtomValue(pushSubscriptionAtom); - const registration = useAtomValue(registrationAtom); - const pushReady = - usePushNotifications && - !!pushSubscription && - !!registration && - notificationPermission('granted'); const nicknames = useAtomValue(nicknamesAtom); const nicknamesRef = useRef(nicknames); @@ -592,23 +482,10 @@ function MessageNotifications() { if (first) notifiedEventsRef.current.delete(first); } - if ( - shouldDeferMessageNotificationToPush( - usePushNotifications, - pushReady, - document.visibilityState, - document.hasFocus() - ) - ) { - // When background push is enabled, defer all non-foreground notification - // delivery to the SW path so the page does not duplicate or race it. - return; - } - - // On desktop: fire an OS notification when system notifications are - // enabled and permission is granted, but only for the actively focused - // foreground page. Background or unfocused delivery is deferred to the - // service worker push path above. + // On desktop: fire an OS notification whenever system notifications are + // enabled and permission is granted — regardless of whether the window is + // focused. When the window is also visible the in-app banner fires too, + // mirroring the behaviour of apps like Discord. // The whole block is wrapped in try/catch: window.Notification() can throw // in sandboxed environments, browsers with DnD active, or Electron — and // an uncaught exception here would abort the handler before setInAppBanner @@ -637,66 +514,24 @@ function MessageNotifications() { }), silent: !notificationSound || !isLoud, eventId, - data: { - type: mEvent.getType(), - room_id: room.roomId, - event_id: eventId, - user_id: mx.getUserId() ?? undefined, - }, }); + const noti = new window.Notification(osPayload.title, osPayload.options); const { roomId } = room; - const handleClick = () => { + noti.addEventListener('click', () => { window.focus(); setPending({ roomId, eventId, targetSessionId: mx.getUserId() ?? undefined, }); - }; - const showWindowNotification = () => { - try { - const noti = new window.Notification(osPayload.title, osPayload.options); - noti.addEventListener('click', () => { - handleClick(); - noti.close(); - }); - } catch { - // OS notification unavailable or blocked. - } - }; - if ('serviceWorker' in navigator) { - const readyOrTimeout = Promise.race([ - navigator.serviceWorker.ready, - new Promise((resolve) => { - setTimeout(() => resolve(undefined), 800); - }), - ]); - void readyOrTimeout - .then((reg) => - reg - ? reg.showNotification(osPayload.title, osPayload.options) - : showWindowNotification() - ) - .catch(showWindowNotification); - } else { - showWindowNotification(); - } + noti.close(); + }); } catch { // window.Notification unavailable or blocked (sandboxed context, DnD, etc.) } } - // Focus mode filter: apply before sound/banners. - // This allows focus mode to suppress both audio and visual notifications. - if (!shouldShowNotificationInFocusMode(focusMode, isDM, isHighlightByRule)) return; - - // In-app audio: play when notification sounds are enabled AND this notification is loud. - // Play sound before the visibility check so audio works even when tab is hidden/backgrounded. - if (notificationSound && isLoud) { - playSound(); - } - - // Everything below requires the page to be visible (in-app banners only). + // Everything below requires the page to be visible (in-app UI + audio). if (document.visibilityState !== 'visible') return; // Page is visible — show the themed in-app notification banner. @@ -790,8 +625,6 @@ function MessageNotifications() { showSystemNotifications, showMessageContent, showEncryptedMessageContent, - usePushNotifications, - pushReady, focusMode, playSound, setInAppBanner, @@ -1049,6 +882,23 @@ function SyncNotificationSettingsWithServiceWorker() { const [clearNotificationsOnRead] = useSetting(settingsAtom, 'clearNotificationsOnRead'); const [focusMode] = useSetting(settingsAtom, 'focusMode'); + useEffect(() => { + if (!('serviceWorker' in navigator)) return undefined; + + const postVisibility = () => { + const visible = document.visibilityState === 'visible'; + const payload = { type: 'setAppVisible', visible }; + + navigator.serviceWorker.controller?.postMessage(payload); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(payload)); + }; + + postVisibility(); + document.addEventListener('visibilitychange', postVisibility); + + return () => document.removeEventListener('visibilitychange', postVisibility); + }, []); + useEffect(() => { if (!('serviceWorker' in navigator) || isTauri()) return; // notificationSoundEnabled is intentionally excluded: push notification sound @@ -1163,20 +1013,6 @@ function HandleDecryptPushEvent() { const { data } = ev; if (!data) return; - if (data.type === 'getForegroundState') { - const { requestId } = data as { requestId?: unknown }; - if (typeof requestId !== 'string') return; - - const response = { - type: 'foregroundStateResult', - requestId, - visibilityState: document.visibilityState, - focused: document.hasFocus(), - }; - if (!postToServiceWorkerSource(ev.source, response)) postToServiceWorker(response); - return; - } - if (data.type !== 'decryptPushEvent') return; const { rawEvent } = data as { rawEvent: Record }; @@ -1436,8 +1272,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - - diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 7306d266d..1ea411606 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -313,7 +313,7 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncNicknames(mx); useLogoutListener(mx); - useAppVisibility(activeSession); + useAppVisibility(mx); const swUpdateAvailable = useSwUpdateAvailable(); const swSessionBaseUrl = activeSession?.baseUrl; diff --git a/src/sw.ts b/src/sw.ts index 47bdb3744..99ee87db1 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -8,16 +8,18 @@ import { createPushNotifications } from './sw/pushNotification'; import { buildDeclarativeNotificationOptions, getEncryptedMinimalPushFocusDecision, - isForegroundSuppressionExemptPushPayload, isDeclarativeWebPushPayload, isMinimalPushPayload, - shouldSuppressOsPushForForegroundState, } from './sw/pushRouting'; import { readPersistedSession } from './sw-session-persistence'; declare const self: ServiceWorkerGlobalScope; let notificationSoundEnabled = true; +// Tracks whether a page client has reported itself as visible. +// The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, +// so we use this explicit flag as a fallback. +let appIsVisible = false; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; @@ -49,8 +51,6 @@ const SW_MEDIA_CACHE = 'sable-media-sw-v2'; type PushTelemetryEvent = | 'received' - | 'confirmed_visible' - | 'suppressed_visible' | 'claim_clients' | 'stale_focus_ignored' | 'shown_os' @@ -176,8 +176,6 @@ let preloadedSession: SessionInfo | undefined; const clientToSessionWaiters = new Map void>>(); const clientWithPendingSessionRequest = new Set(); -const clientForegroundHeartbeatAt = new Map(); -const FOREGROUND_HEARTBEAT_MAX_AGE_MS = 20_000; async function cleanupDeadClients() { const activeClients = await self.clients.matchAll(); @@ -188,7 +186,6 @@ async function cleanupDeadClients() { sessions.delete(id); clientToSessionWaiters.delete(id); clientWithPendingSessionRequest.delete(id); - clientForegroundHeartbeatAt.delete(id); } }); } @@ -568,22 +565,6 @@ type DecryptionResult = { }; const decryptionPendingMap = new Map void>(); - -type ForegroundStateResult = { - requestId: string; - visibilityState?: string; - focused?: boolean; - clientId?: string; -}; - -type ForegroundStatePending = { - expectedResponseCount: number; - responseCount: number; - lastResult?: ForegroundStateResult; - resolve: (result: ForegroundStateResult | undefined) => void; -}; - -const foregroundStatePendingMap = new Map(); const SW_FETCH_RETRY_DELAYS_MS = [250, 750] as const; const sleep = (ms: number): Promise => @@ -822,90 +803,6 @@ async function requestDecryptionFromClient( return result; } -async function requestForegroundStateFromClients( - windowClients: readonly Client[], - timeoutMs = 700 -): Promise { - if (windowClients.length === 0) return undefined; - - const requestId = createRecordId('foreground'); - - const response = new Promise((resolve) => { - foregroundStatePendingMap.set(requestId, { - expectedResponseCount: 0, - responseCount: 0, - resolve, - }); - }); - - let posted = false; - let postedCount = 0; - windowClients.forEach((client) => { - try { - client.postMessage({ - type: 'getForegroundState', - requestId, - }); - posted = true; - postedCount += 1; - } catch (err) { - console.warn('[SW push] foreground state postMessage error', err); - } - }); - - if (!posted) { - foregroundStatePendingMap.delete(requestId); - return undefined; - } - - const pending = foregroundStatePendingMap.get(requestId); - if (pending) pending.expectedResponseCount = postedCount; - - const result = await Promise.race([response, sleep(timeoutMs).then(() => undefined)]); - foregroundStatePendingMap.delete(requestId); - return result; -} - -async function recordForegroundPushSuppression( - payloadType: string, - foregroundState: ForegroundStateResult, - focusedClientCount: number, - browserVisibleClientCount: number, - extraData: Record = {} -): Promise { - await recordPushTelemetry('confirmed_visible', { - payload_type: payloadType, - focused: foregroundState.focused === true, - ...extraData, - }); - await recordPushTelemetry('suppressed_visible', { - payload_type: payloadType, - focused_client_count: focusedClientCount, - browser_visible_client_count: browserVisibleClientCount, - foreground_focused: foregroundState.focused === true, - ...extraData, - }); - postSentryMetric('sable.push.suppressed_visible', 1, { - payload_type: payloadType, - focused_client_count: focusedClientCount, - browser_visible_client_count: browserVisibleClientCount, - foreground_focused: foregroundState.focused === true, - ...extraData, - }).catch(() => undefined); - await postSentryBreadcrumb( - 'notification.push', - 'Suppressed OS push notification for foreground client', - 'info', - { - payloadType, - focusedClientCount, - browserVisibleClientCount, - foregroundFocused: foregroundState.focused === true, - ...extraData, - } - ); -} - /** * Handle a minimal push payload (event_id_only format). * Fetches the event from the homeserver and shows a notification. @@ -914,8 +811,7 @@ async function recordForegroundPushSuppression( async function handleMinimalPushPayload( roomId: string, eventId: string, - windowClients: readonly Client[], - foregroundState: ForegroundStateResult | undefined + windowClients: readonly Client[] ): Promise { // On iOS the SW is killed and restarted for every push, clearing the in-memory sessions // Map. Fall back to the Cache Storage copy that was written when the user last opened @@ -981,21 +877,6 @@ async function handleMinimalPushPayload( } const eventType = rawEvent.type as string | undefined; - if ( - foregroundState && - shouldSuppressOsPushForForegroundState(foregroundState) && - !isForegroundSuppressionExemptPushPayload(rawEvent) - ) { - await recordForegroundPushSuppression( - 'minimal', - foregroundState, - focusedWindowClientCount(windowClients), - visibleWindowClientCount(windowClients), - { event_type: eventType ?? 'unknown' } - ); - return; - } - const sender = rawEvent.sender as string | undefined; // Fetch sender's member state — gives us both display name and avatar URL. const memberInfo = sender @@ -1202,6 +1083,11 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { ); event.waitUntil(cleanupDeadClients()); } + if (type === 'setAppVisible') { + if (typeof (data as { visible?: unknown }).visible === 'boolean') { + appIsVisible = (data as { visible: boolean }).visible; + } + } if (type === 'CLAIM_CLIENTS') { // Sent by the page on pageshow[persisted] or visibilitychange→visible when it // detects that its SW controller is stale (e.g. after iOS killed and restarted @@ -1229,9 +1115,6 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { })() ); } - if (type === 'foregroundHeartbeat') { - clientForegroundHeartbeatAt.set(client.id, Date.now()); - } if (type === 'pushDecryptResult') { // Resolve a pending decryption request from handleMinimalPushPayload const { eventId } = data as { eventId?: string }; @@ -1243,43 +1126,6 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } } } - if (type === 'foregroundStateResult') { - const { requestId, visibilityState, focused } = data as { - requestId?: unknown; - visibilityState?: unknown; - focused?: unknown; - }; - if (typeof requestId === 'string') { - const pending = foregroundStatePendingMap.get(requestId); - const normalizedVisibilityState = - typeof visibilityState === 'string' ? visibilityState : undefined; - if (pending) { - const result = { - requestId, - visibilityState: normalizedVisibilityState, - focused: focused === true, - clientId: client.id, - }; - const lastForegroundHeartbeat = clientForegroundHeartbeatAt.get(client.id); - const hasRecentForegroundHeartbeat = - typeof lastForegroundHeartbeat === 'number' && - Date.now() - lastForegroundHeartbeat <= FOREGROUND_HEARTBEAT_MAX_AGE_MS; - - if (shouldSuppressOsPushForForegroundState(result) && hasRecentForegroundHeartbeat) { - foregroundStatePendingMap.delete(requestId); - pending.resolve(result); - return; - } - - pending.responseCount += 1; - pending.lastResult = result; - if (pending.responseCount >= pending.expectedResponseCount) { - foregroundStatePendingMap.delete(requestId); - pending.resolve(pending.lastResult); - } - } - } - } if (type === 'drainPushTelemetry') { const { requestId } = data as { requestId?: unknown }; event.waitUntil( @@ -1781,9 +1627,12 @@ const onPushNotification = async (event: PushEvent) => { const focusedClientCount = focusedWindowClientCount(clients); const browserVisibleClientCount = visibleWindowClientCount(clients); + const hasVisibleClient = appIsVisible || browserVisibleClientCount > 0; console.debug( - '[SW push] focusedClientCount:', + '[SW push] appIsVisible:', + appIsVisible, + '| focusedClientCount:', focusedClientCount, '| browserVisibleClientCount:', browserVisibleClientCount, @@ -1794,6 +1643,12 @@ const onPushNotification = async (event: PushEvent) => { focused: c.focused, })) ); + console.debug('[SW push] hasVisibleClient:', hasVisibleClient); + + if (hasVisibleClient) { + console.debug('[SW push] suppressing OS notification — app is visible'); + return; + } const pushData = event.data.json(); const payloadType = pushTelemetryPayloadType(pushData); @@ -1861,22 +1716,6 @@ const onPushNotification = async (event: PushEvent) => { // Badging API absent (Firefox/Gecko) — continue to show the notification. } - const foregroundState = await requestForegroundStateFromClients(clients); - if ( - foregroundState && - shouldSuppressOsPushForForegroundState(foregroundState) && - !isMinimalPushPayload(pushData) && - !isForegroundSuppressionExemptPushPayload(pushData) - ) { - await recordForegroundPushSuppression( - payloadType, - foregroundState, - focusedClientCount, - browserVisibleClientCount - ); - return; - } - if (isDeclarativeWebPushPayload(pushData)) { const { title, options } = buildDeclarativeNotificationOptions(pushData); await self.registration.showNotification(title, options); @@ -1888,7 +1727,7 @@ const onPushNotification = async (event: PushEvent) => { // to relay decryption to an open app tab. if (isMinimalPushPayload(pushData)) { console.debug('[SW push] minimal payload detected — fetching event', pushData.event_id); - await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients, foregroundState); + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); return; }