From bf507e49e68cfecfb11e618cce5fbd2c2fcb2449 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 17 Jun 2026 00:05:15 -0400 Subject: [PATCH] fix(client): harden PWA push and resume recovery --- .changeset/fix-pwa-push-resume-audit.md | 5 + docs/MANUAL_QA_PWA_PUSH_RESUME_MATRIX.md | 255 ++++++++++++++++++ .../notifications/PushNotifications.test.ts | 66 ++++- .../notifications/PushNotifications.tsx | 58 +++- src/app/hooks/useAppVisibility.test.tsx | 90 +++++++ src/app/hooks/useAppVisibility.ts | 54 +++- src/app/hooks/useNotificationJumper.ts | 78 ++++++ .../pages/client/BackgroundNotifications.tsx | 33 ++- src/app/pages/client/ClientNonUIFeatures.tsx | 127 +++++++-- src/app/pages/client/ClientRoot.tsx | 2 +- src/app/pages/client/ToRoomEvent.tsx | 26 +- src/app/pages/pathUtils.test.ts | 16 +- src/app/pages/pathUtils.ts | 11 + src/app/state/sessions.ts | 2 + src/launch-context-persistence.test.ts | 29 ++ src/launch-context-persistence.ts | 60 +++++ src/serviceWorkerBootstrap.test.ts | 15 ++ src/serviceWorkerBootstrap.ts | 38 +++ src/sw.ts | 26 +- 19 files changed, 939 insertions(+), 52 deletions(-) create mode 100644 .changeset/fix-pwa-push-resume-audit.md create mode 100644 docs/MANUAL_QA_PWA_PUSH_RESUME_MATRIX.md create mode 100644 src/launch-context-persistence.test.ts create mode 100644 src/launch-context-persistence.ts diff --git a/.changeset/fix-pwa-push-resume-audit.md b/.changeset/fix-pwa-push-resume-audit.md new file mode 100644 index 000000000..a2d4ef292 --- /dev/null +++ b/.changeset/fix-pwa-push-resume-audit.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Harden PWA push and resume recovery by restoring lazy service-worker reclaim on foreground return, re-arming web push on startup, routing room notification restores through the canonical `/to/...` deep-link path, and adding telemetry that distinguishes warm resume from cold launch after notification clicks. diff --git a/docs/MANUAL_QA_PWA_PUSH_RESUME_MATRIX.md b/docs/MANUAL_QA_PWA_PUSH_RESUME_MATRIX.md new file mode 100644 index 000000000..ab62d93b0 --- /dev/null +++ b/docs/MANUAL_QA_PWA_PUSH_RESUME_MATRIX.md @@ -0,0 +1,255 @@ +# Manual QA Matrix: PWA Push, Resume, and Cold Launch Recovery + +Manual verification checklist for the current Charm PWA/mobile recovery work. + +This matrix is intended to validate five problem areas: + +1. background push delivery +2. app visibility and foreground/background recovery +3. content loading while backgrounded +4. service worker restart survival +5. long-idle reopen without broken restore or forced spring-boarding + +## Scope + +Prioritize these environments: + +- iPhone Home Screen PWA +- Safari tab on iPhone +- Safari desktop PWA if available +- one desktop browser baseline for comparison + +Use at least one account with: + +- a DM +- a normal room +- an older event target that is not already loaded + +If possible, keep Sentry breadcrumbs/metrics or console logs available during the run. + +## Key Telemetry + +Expected metrics/breadcrumb families for the current implementation: + +- `sable.notification.clicked` +- `sable.notification.to_route` +- `sable.notification.jump_started` +- `sable.notification.jump_completed` +- `sable.notification.restore_ms` +- `sable.app.resume` +- `sable.app.launch_context` +- `sable.app.launch_context_age_ms` +- `notification.click` breadcrumbs +- `notification.restore` breadcrumbs +- `notification.push` breadcrumbs +- `service_worker.push` breadcrumbs + +## Scenario 1: Warm Notification Tap + +Environment: + +- app already open +- app backgrounded briefly +- receive a notification for a room message + +Steps: + +1. Open the PWA and leave it signed in. +2. Background it for less than 1 minute. +3. Send a message from another account/device. +4. Tap the delivered notification. + +Expected UI result: + +- app returns without a login screen +- correct account is active +- target room opens +- target event context is loaded if an event id is present + +Expected telemetry: + +- `sable.notification.clicked` +- `sable.notification.to_route` +- `sable.notification.jump_started` +- `sable.notification.jump_completed` +- `sable.notification.restore_ms` +- no `sable.app.launch_context` event if bootstrap did not re-run + +## Scenario 2: Cold Launch From Notification + +Environment: + +- app fully terminated by OS or manually swiped away +- receive a notification for a room message + +Steps: + +1. Force-close the PWA or leave it unused until iOS discards it. +2. Send a message from another account/device. +3. Tap the notification. + +Expected UI result: + +- app cold-launches +- correct account becomes active +- `/to/...` restore flow lands in the target room/event +- no stuck splash screen or dead-end landing page + +Expected telemetry: + +- `sable.notification.clicked` +- `sable.app.launch_context` +- `sable.app.launch_context_age_ms` +- `sable.notification.to_route` +- `sable.notification.jump_started` +- `sable.notification.jump_completed` + +Notes: + +- This is the scenario that distinguishes cold launch from warm resume. +- `sable.app.launch_context` should only appear when bootstrap consumed the persisted click marker. + +## Scenario 3: BFCache / Persisted Pageshow Restore + +Environment: + +- app foregrounded, then backgrounded +- return without using a notification + +Steps: + +1. Open the app to a room. +2. Background it briefly. +3. Return through the app switcher. + +Expected UI result: + +- room context remains intact +- no visible full reload unless iOS actually discarded the app +- no incorrect account switch + +Expected telemetry: + +- `sable.app.resume` with `trigger=pageshow_persisted` when BFCache restore occurs +- or `sable.app.resume` with `trigger=visibilitychange` when it is a normal visible resume +- no `sable.app.launch_context` + +## Scenario 4: Long Idle Reopen Without Notification + +Environment: + +- app left unused for at least 1 hour, ideally overnight + +Steps: + +1. Open the app and note the current room. +2. Leave it unused for a long period. +3. Reopen it directly from the Home Screen, not via a notification. + +Expected UI result: + +- app either restores warm state or performs a clean cold launch +- session remains valid +- sync reconnects without getting stuck +- user is not dumped into an unrelated room or broken blank state + +Expected telemetry: + +- `sable.app.resume` if the app survived +- or a normal bootstrap with no `sable.app.launch_context` if it was a plain cold open +- no notification restore events unless a notification initiated the open + +## Scenario 5: Visible-App Push Suppression + +Environment: + +- app visible and focused in foreground + +Steps: + +1. Open a room and keep the app focused. +2. Send a message from another account/device. + +Expected UI result: + +- no duplicate OS notification while app is visibly foregrounded +- in-app banner or direct timeline update still occurs as appropriate + +Expected telemetry: + +- `notification.push` breadcrumbs may still exist +- room restore funnel metrics should not fire unless the user taps a notification + +## Scenario 6: Background Push After SW Restart + +Environment: + +- app backgrounded long enough that iOS likely restarts the service worker + +Steps: + +1. Open the app once so session data is available to the SW. +2. Background the app for a while. +3. Send a message from another account/device. + +Expected UI result: + +- notification still appears, even if the SW had to restart +- tapping it still reaches the room/event + +Expected telemetry: + +- `notification.push` and `service_worker.push` breadcrumbs +- `sable.notification.clicked` +- if the tap causes a true cold bootstrap, also `sable.app.launch_context` + +## Scenario 7: Cross-Account Background Notification + +Environment: + +- at least two signed-in sessions +- active session differs from the notified session + +Steps: + +1. Keep account A active. +2. Send a message to account B. +3. Tap the notification for account B. + +Expected UI result: + +- account B becomes active +- restore lands in account B’s room +- no intermediate wrong-account render that gets stuck + +Expected telemetry: + +- `notification.restore` breadcrumbs that may show waiting for target session or Matrix client switch +- `sable.notification.jump_completed` after account switch settles + +## Failure Notes To Capture + +If any scenario fails, note: + +- platform and browser mode +- whether the app was warm, BFCache-restored, or clearly cold-launched +- whether the OS notification appeared +- whether tapping notification opened the right account +- whether `/to/...` route was visibly entered +- whether jump completed or stalled +- the latest breadcrumbs for: + - `notification.click` + - `notification.restore` + - `notification.push` + - `app.launch` + - `app.visibility` + +## Exit Criteria + +This batch is behaving acceptably when: + +- warm notification taps reliably restore the correct room +- cold notification launches produce `sable.app.launch_context` +- long-idle reopens do not conflate plain cold launch with notification-driven launch +- visible-app suppression avoids duplicate OS notifications +- cross-account notification taps consistently switch to the correct session before jumping diff --git a/src/app/features/settings/notifications/PushNotifications.test.ts b/src/app/features/settings/notifications/PushNotifications.test.ts index 3cad4888c..3dfbaef28 100644 --- a/src/app/features/settings/notifications/PushNotifications.test.ts +++ b/src/app/features/settings/notifications/PushNotifications.test.ts @@ -5,6 +5,7 @@ import type { ClientConfig } from '../../../hooks/useClientConfig'; import { disablePushNotifications, enablePushNotifications, + reconcilePushNotifications, togglePusher, } from './PushNotifications'; @@ -47,11 +48,16 @@ function makeSubscription(endpoint = 'https://push.example.com/sub') { function installWebPush(subscription: PushSubscription | null): { controllerPostMessage: ReturnType; + activePostMessage: ReturnType; subscribe: ReturnType; } { const controllerPostMessage = vi.fn<() => void>(); + const activePostMessage = vi.fn<() => void>(); const subscribe = vi.fn<() => Promise>().mockResolvedValue(makeSubscription()); const registration = { + active: { + postMessage: activePostMessage, + }, pushManager: { getSubscription: vi .fn<() => Promise>() @@ -71,7 +77,7 @@ function installWebPush(subscription: PushSubscription | null): { }); vi.stubGlobal('PushManager', vi.fn()); - return { controllerPostMessage, subscribe }; + return { controllerPostMessage, activePostMessage, subscribe }; } afterEach(() => { @@ -83,7 +89,7 @@ afterEach(() => { describe('web push notifications', () => { it('reuses an existing browser subscription through the service worker toggle path', async () => { const subscription = makeSubscription(); - const { controllerPostMessage, subscribe } = installWebPush(subscription); + const { controllerPostMessage, activePostMessage, subscribe } = installWebPush(subscription); const mx = makeMatrixClient(); const setSubscription = vi.fn<() => void>(); @@ -106,10 +112,15 @@ describe('web push notifications', () => { }), }), }); + expect(activePostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'togglePush', + }) + ); }); it('creates a new subscription and posts the pusher to the service worker', async () => { - const { controllerPostMessage, subscribe } = installWebPush(null); + const { controllerPostMessage, activePostMessage, subscribe } = installWebPush(null); const mx = makeMatrixClient(); const setSubscription = vi.fn<() => void>(); @@ -130,6 +141,11 @@ describe('web push notifications', () => { token: 'access-token', }) ); + expect(activePostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'togglePush', + }) + ); }); it('posts a null pusher to disable web push', async () => { @@ -190,4 +206,48 @@ describe('web push notifications', () => { }) ); }); + + it('reconciles startup push state for a visible mobile session', async () => { + const { controllerPostMessage } = installWebPush(null); + const mx = makeMatrixClient(); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + + await reconcilePushNotifications(mx, clientConfig, true, [null, vi.fn<() => void>()], true); + + expect(controllerPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'togglePush', + token: 'access-token', + pusherData: expect.objectContaining({ + kind: 'http', + }), + }) + ); + }); + + it('posts through the active worker when no controller exists', async () => { + const { activePostMessage } = installWebPush(null); + const ready = navigator.serviceWorker.ready; + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + controller: undefined, + ready, + }, + }); + const mx = makeMatrixClient(); + + await enablePushNotifications(mx, clientConfig, [null, vi.fn<() => void>()]); + + expect(activePostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'togglePush', + token: 'access-token', + }) + ); + }); }); diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 7f510b444..8487d4c84 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -9,6 +9,35 @@ type PushSubscriptionState = [ (subscription: PushSubscription | null) => void, ]; +function postTogglePushMessage(data: { + url: string; + token: string | null | undefined; + pusherData: unknown; +}): void { + if (!('serviceWorker' in navigator)) return; + + const message = { + type: 'togglePush' as const, + ...data, + }; + const posted = new Set(); + const postToWorker = (worker: ServiceWorker | null | undefined) => { + if (!worker || posted.has(worker)) return; + posted.add(worker); + // oxlint-disable-next-line unicorn/require-post-message-target-origin + worker.postMessage(message); + }; + + postToWorker(navigator.serviceWorker.controller); + navigator.serviceWorker.ready + .then((registration) => { + postToWorker(registration.active); + postToWorker(registration.waiting); + postToWorker(registration.installing); + }) + .catch(() => undefined); +} + export async function requestBrowserNotificationPermission(): Promise { if (!('Notification' in window)) { debugLog.warn('notification', 'Notification API not available in this browser'); @@ -69,9 +98,8 @@ export async function enablePushNotifications( }, append: false, }; - navigator.serviceWorker.controller?.postMessage({ + postTogglePushMessage({ url: mx.baseUrl, - type: 'togglePush', pusherData, token: mx.getAccessToken(), }); @@ -118,9 +146,8 @@ export async function enablePushNotifications( append: false, }; - navigator.serviceWorker.controller?.postMessage({ + postTogglePushMessage({ url: mx.baseUrl, - type: 'togglePush', pusherData, token: mx.getAccessToken(), }); @@ -144,9 +171,8 @@ export async function disablePushNotifications( pushkey: pushSubAtom?.keys?.p256dh, }; - navigator.serviceWorker.controller?.postMessage({ + postTogglePushMessage({ url: mx.baseUrl, - type: 'togglePush', pusherData, token: mx.getAccessToken(), }); @@ -185,3 +211,23 @@ export async function togglePusher( } } } + +export async function reconcilePushNotifications( + mx: MatrixClient, + clientConfig: ClientConfig, + usePushNotifications: boolean, + pushSubscriptionAtom: PushSubscriptionState, + keepEnabledWhenVisible = false +): Promise { + if (!usePushNotifications) return; + + const isVisible = document.visibilityState === 'visible'; + await togglePusher( + mx, + clientConfig, + isVisible, + usePushNotifications, + pushSubscriptionAtom, + keepEnabledWhenVisible + ); +} diff --git a/src/app/hooks/useAppVisibility.test.tsx b/src/app/hooks/useAppVisibility.test.tsx index c43946146..dae104d56 100644 --- a/src/app/hooks/useAppVisibility.test.tsx +++ b/src/app/hooks/useAppVisibility.test.tsx @@ -6,6 +6,7 @@ import { useAppVisibility } from './useAppVisibility'; const mocks = vi.hoisted(() => ({ togglePusher: vi.fn<() => Promise>(), + pushSessionToSW: vi.fn<(baseUrl?: string, accessToken?: string, userId?: string) => void>(), })); vi.mock('$utils/user-agent', () => ({ @@ -16,6 +17,10 @@ vi.mock('../features/settings/notifications/PushNotifications', () => ({ togglePusher: mocks.togglePusher, })); +vi.mock('../../sw-session', () => ({ + pushSessionToSW: mocks.pushSessionToSW, +})); + vi.mock('./useClientConfig', () => ({ useClientConfig: () => ({}), })); @@ -31,6 +36,7 @@ describe('useAppVisibility', () => { beforeEach(() => { setVisibilityState('visible'); mocks.togglePusher.mockClear(); + mocks.pushSessionToSW.mockClear(); }); afterEach(() => { @@ -88,4 +94,88 @@ describe('useAppVisibility', () => { false ); }); + + it('requests a lazy service worker claim and refreshes the session on visible resume', async () => { + const postMessage = vi.fn<(message: unknown) => void>(); + const activeWorker = { + state: 'activated', + postMessage, + } as unknown as ServiceWorker; + const ready = Promise.resolve({ + active: activeWorker, + } satisfies Partial); + + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + controller: undefined, + ready, + }, + }); + + const mx = {} as MatrixClient; + const activeSession = { + baseUrl: 'https://example.com', + accessToken: 'token', + userId: '@user:example.com', + }; + + renderHook(() => useAppVisibility(mx, activeSession as never)); + + await act(async () => { + setVisibilityState('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + setVisibilityState('visible'); + document.dispatchEvent(new Event('visibilitychange')); + await ready; + }); + + expect(postMessage).toHaveBeenCalledWith({ type: 'CLAIM_CLIENTS' }); + expect(mocks.pushSessionToSW).toHaveBeenCalledWith( + activeSession.baseUrl, + activeSession.accessToken, + activeSession.userId + ); + }); + + it('requests a lazy service worker claim on persisted pageshow restore', async () => { + const postMessage = vi.fn<(message: unknown) => void>(); + const activeWorker = { + state: 'activated', + postMessage, + } as unknown as ServiceWorker; + const ready = Promise.resolve({ + active: activeWorker, + } satisfies Partial); + + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + controller: undefined, + ready, + }, + }); + + const visibilityHandler = vi.fn<(visible: boolean) => void>(); + const unsubscribe = appEvents.onVisibilityChange(visibilityHandler); + + const mx = {} as MatrixClient; + + renderHook(() => + useAppVisibility(mx, { + baseUrl: 'https://example.com', + accessToken: 'token', + userId: '@user:example.com', + } as never) + ); + + await act(async () => { + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + await ready; + }); + + expect(postMessage).toHaveBeenCalledWith({ type: 'CLAIM_CLIENTS' }); + expect(visibilityHandler).toHaveBeenCalledWith(true); + unsubscribe(); + }); }); diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index e4f16c07c..b12c8a33d 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import type { MatrixClient } from '$types/matrix-sdk'; +import type { Session } from '$state/sessions'; import { useAtom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; import { useClientConfig } from './useClientConfig'; @@ -9,10 +11,32 @@ import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; +import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -export function useAppVisibility(mx: MatrixClient | undefined) { +const requestServiceWorkerClaim = () => { + if (!('serviceWorker' in navigator)) return; + if (navigator.serviceWorker.controller) return; + if (document.visibilityState !== 'visible') return; + + navigator.serviceWorker.ready + .then((registration) => { + const activeWorker = registration.active; + if (!activeWorker || activeWorker.state !== 'activated') return; + // oxlint-disable-next-line unicorn/require-post-message-target-origin + activeWorker.postMessage({ type: 'CLAIM_CLIENTS' }); + }) + .catch(() => undefined); +}; + +const refreshServiceWorkerSession = (activeSession?: Session) => { + if (!activeSession) return; + if (document.visibilityState !== 'visible') return; + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); +}; + +export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); @@ -26,18 +50,44 @@ export function useAppVisibility(mx: MatrixClient | undefined) { `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); + if (isVisible) { + Sentry.metrics.count('sable.app.resume', 1, { + attributes: { trigger: 'visibilitychange' }, + }); + requestServiceWorkerClaim(); + refreshServiceWorkerSession(activeSession); + } appEvents.emitVisibilityChange(isVisible); if (!isVisible) { appEvents.emitVisibilityHidden(); } }; + const handlePageShow = (event: PageTransitionEvent) => { + if (!event.persisted) return; + if (document.visibilityState !== 'visible') return; + Sentry.addBreadcrumb({ + category: 'app.visibility', + message: 'App restored from pageshow', + level: 'info', + data: { persisted: event.persisted }, + }); + Sentry.metrics.count('sable.app.resume', 1, { + attributes: { trigger: 'pageshow_persisted' }, + }); + requestServiceWorkerClaim(); + refreshServiceWorkerSession(activeSession); + appEvents.emitVisibilityChange(true); + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pageshow', handlePageShow); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pageshow', handlePageShow); }; - }, []); + }, [activeSession]); useEffect(() => { if (!mx) return undefined; diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 62c9a74ab..4f8f6a8bd 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { SyncState, ClientEvent } from '$types/matrix-sdk'; +import * as Sentry from '@sentry/react'; import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions'; import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; @@ -38,6 +39,16 @@ export function NotificationJumper() { const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; if (pending.targetSessionId && pending.targetSessionId !== activeSessionId) { + Sentry.addBreadcrumb({ + category: 'notification.restore', + message: 'Waiting for target session before notification jump', + level: 'info', + data: { + targetSessionId: pending.targetSessionId, + activeSessionId, + source: pending.source, + }, + }); log.log('waiting for target session atom...', { targetSessionId: pending.targetSessionId, activeSessionId, @@ -47,6 +58,16 @@ export function NotificationJumper() { // The mx client context may lag one render behind the atom — wait until it catches up. if (pending.targetSessionId && mx.getUserId() !== pending.targetSessionId) { + Sentry.addBreadcrumb({ + category: 'notification.restore', + message: 'Waiting for Matrix client session switch before notification jump', + level: 'info', + data: { + targetSessionId: pending.targetSessionId, + currentUserId: mx.getUserId(), + source: pending.source, + }, + }); log.log('waiting for mx client to switch to target session...', { targetSessionId: pending.targetSessionId, currentUserId: mx.getUserId(), @@ -61,6 +82,22 @@ export function NotificationJumper() { if (isSyncing && isJoined) { log.log('jumping to:', pending.roomId, pending.eventId); jumpingRef.current = true; + Sentry.addBreadcrumb({ + category: 'notification.restore', + message: 'Starting notification room jump', + level: 'info', + data: { + roomId: pending.roomId, + hasEventId: !!pending.eventId, + source: pending.source, + }, + }); + Sentry.metrics.count('sable.notification.jump_started', 1, { + attributes: { + has_event_id: !!pending.eventId, + source: pending.source ?? 'unknown', + }, + }); // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. // First replace the current history entry with the section overview so that @@ -119,9 +156,50 @@ export function NotificationJumper() { navigate(targetSectionPath, { replace: true }); navigate(targetRoomPath); } + const restoreLatencyMs = + typeof pending.requestedAt === 'number' ? Date.now() - pending.requestedAt : undefined; + Sentry.addBreadcrumb({ + category: 'notification.restore', + message: 'Completed notification room jump', + level: 'info', + data: { + roomId: pending.roomId, + hasEventId: !!pending.eventId, + source: pending.source, + restoreLatencyMs, + alreadyInRoom, + }, + }); + Sentry.metrics.count('sable.notification.jump_completed', 1, { + attributes: { + has_event_id: !!pending.eventId, + source: pending.source ?? 'unknown', + already_in_room: alreadyInRoom, + }, + }); + if (restoreLatencyMs !== undefined) { + Sentry.metrics.distribution('sable.notification.restore_ms', restoreLatencyMs, { + attributes: { + source: pending.source ?? 'unknown', + already_in_room: alreadyInRoom, + }, + }); + } setPending(null); // jumpingRef stays true until pending changes — see effect below. } else { + Sentry.addBreadcrumb({ + category: 'notification.restore', + message: 'Waiting for room data before notification jump', + level: 'info', + data: { + roomId: pending.roomId, + isSyncing, + hasRoom: !!room, + membership: room?.getMyMembership(), + source: pending.source, + }, + }); log.log('still waiting for room data...', { isSyncing, hasRoom: !!room, diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 6e5fb241c..912e45c18 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { ClientEvent, @@ -16,7 +17,6 @@ import { isTauri } from '@tauri-apps/api/core'; import { sessionsAtom, activeSessionIdAtom, - pendingNotificationAtom, backgroundUnreadCountsAtom, inAppBannerAtom, type Session, @@ -48,6 +48,7 @@ import { startClient, stopClient } from '$client/initMatrix'; import { useClientConfig } from '$hooks/useClientConfig'; import { mobileOrTablet } from '$utils/user-agent'; import { shouldShowNotificationInFocusMode } from '$utils/focusMode'; +import { getToRoomEventPath } from '../pathUtils'; const log = createLogger('BackgroundNotifications'); const debugLog = createDebugLogger('BackgroundNotifications'); @@ -155,9 +156,10 @@ const waitForSync = (mx: MatrixClient): Promise => }); export function BackgroundNotifications() { + const navigate = useNavigate(); const clientConfig = useClientConfig(); const sessions = useAtomValue(sessionsAtom); - const [activeSessionId, setActiveSessionId] = useAtom(activeSessionIdAtom); + const [activeSessionId] = useAtom(activeSessionIdAtom); const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); @@ -167,8 +169,11 @@ export function BackgroundNotifications() { 'showMessageContentInEncryptedNotifications' ); const [focusMode] = useSetting(settingsAtom, 'focusMode'); + const isMobile = mobileOrTablet(); + const [isVisible, setIsVisible] = useState(document.visibilityState === 'visible'); - const shouldRunBackgroundNotifications = showNotifications || usePushNotifications; + const shouldRunBackgroundNotifications = + (showNotifications || usePushNotifications) && (!isMobile || isVisible); const nicknames = useAtomValue(nicknamesAtom); const nicknamesRef = useRef(nicknames); nicknamesRef.current = nicknames; @@ -185,7 +190,6 @@ export function BackgroundNotifications() { const clientsRef = useRef(new Map()); const startingClientsRef = useRef(new Set()); const notifiedEventsRef = useRef(new Set()); - const setPending = useSetAtom(pendingNotificationAtom); const setBackgroundUnreads = useSetAtom(backgroundUnreadCountsAtom); const setInAppBanner = useSetAtom(inAppBannerAtom); const setBackgroundUnreadsRef = useRef(setBackgroundUnreads); @@ -207,6 +211,15 @@ export function BackgroundNotifications() { const inactiveSessionsRef = useRef(inactiveSessions); inactiveSessionsRef.current = inactiveSessions; + useEffect(() => { + const handleVisibilityChange = () => { + setIsVisible(document.visibilityState === 'visible'); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, []); + interface NotifyOptions { title: string; body?: string; @@ -597,12 +610,7 @@ export function BackgroundNotifications() { const notifOnClick = () => { window.focus(); - setActiveSessionId(session.userId); - setPending({ - roomId: room.roomId, - eventId, - targetSessionId: session.userId, - }); + navigate(getToRoomEventPath(session.userId, room.roomId, eventId)); }; // Show in-app banner when app is visible, mobile, and in-app notifications enabled @@ -741,8 +749,7 @@ export function BackgroundNotifications() { clientConfig.slidingSync, inactiveSessions, shouldRunBackgroundNotifications, - setActiveSessionId, - setPending, + navigate, setBackgroundUnreads, setInAppBanner, ]); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index d28e5a8c7..ba40aa654 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; @@ -31,6 +31,7 @@ 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, @@ -44,7 +45,8 @@ import { useInboxNotificationsSelected } from '$hooks/router/useInbox'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { registrationAtom } from '$state/serviceWorkerRegistration'; -import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; +import { inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; +import { pushSubscriptionAtom } from '$state/pushSubscription'; import { buildRoomMessageNotification, resolveNotificationPreviewText, @@ -88,12 +90,13 @@ import { usePresenceSyncEffect } from '$hooks/usePresenceSync'; import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle'; import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { useReminderSync } from '$features/bookmarks/useReminderSync'; -import { getInboxBookmarksPath, getInboxInvitesPath } from '../pathUtils'; +import { getInboxBookmarksPath, getInboxInvitesPath, getToRoomEventPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; import { NotificationTransportRuntime, type NotificationTransportRuntimeContext, } from '../../features/settings/notifications/NotificationTransportRuntime'; +import { reconcilePushNotifications } from '../../features/settings/notifications/PushNotifications'; import { normalizeNotificationTransportMode, resolvePreferredNotificationTransportProvider, @@ -144,6 +147,55 @@ function navigateToServiceWorkerUrl(navigate: ReturnType, ur window.location.assign(url); } +function navigateToRoomNotificationTarget( + navigate: ReturnType, + userId: string | undefined, + roomId: string, + eventId?: string +): void { + if (!userId) return; + navigate(getToRoomEventPath(userId, roomId, eventId)); +} + +function WebPushStartupReconciler() { + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); + const pushSubscription = useAtom(pushSubscriptionAtom); + const reconciledKeyRef = useRef(null); + const keepEnabledWhenVisible = mobileOrTablet(); + + useEffect(() => { + if (!usePushNotifications || isTauri()) return; + + const userId = mx.getUserId() ?? null; + if (!userId) return; + const reconcileKey = [ + userId, + document.visibilityState, + keepEnabledWhenVisible ? 'keep-visible' : 'disable-visible', + ].join(':'); + if (reconciledKeyRef.current === reconcileKey) return; + + reconciledKeyRef.current = reconcileKey; + void reconcilePushNotifications( + mx, + clientConfig, + usePushNotifications, + pushSubscription, + keepEnabledWhenVisible + ).catch((error) => { + reconciledKeyRef.current = null; + transportLog.warn('notification', 'Web push startup reconciliation failed', { + userId, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, [mx, clientConfig, pushSubscription, usePushNotifications, keepEnabledWhenVisible]); + + return null; +} + function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -330,9 +382,9 @@ function MessageNotifications() { const mDirectsRef = useRef(mDirects); mDirectsRef.current = mDirects; - const setPending = useSetAtom(pendingNotificationAtom); const setInAppBanner = useSetAtom(inAppBannerAtom); const notificationSelected = useInboxNotificationsSelected(); + const navigate = useNavigate(); const playSound = useCallback(() => { const audioElement = audioRef.current; @@ -519,11 +571,12 @@ function MessageNotifications() { const { roomId } = room; noti.addEventListener('click', () => { window.focus(); - setPending({ + navigateToRoomNotificationTarget( + navigate, + mx.getUserId() ?? undefined, roomId, - eventId, - targetSessionId: mx.getUserId() ?? undefined, - }); + eventId + ); noti.close(); }); } catch { @@ -601,11 +654,7 @@ function MessageNotifications() { icon: roomAvatar, onClick: () => { window.focus(); - setPending({ - roomId, - eventId: capturedEventId, - targetSessionId: capturedUserId, - }); + navigateToRoomNotificationTarget(navigate, capturedUserId, roomId, capturedEventId); }, }); } @@ -628,7 +677,7 @@ function MessageNotifications() { focusMode, playSound, setInAppBanner, - setPending, + navigate, appBaseUrl, useAuthentication, ]); @@ -820,7 +869,6 @@ type ClientNonUIFeaturesProps = { }; export function HandleNotificationClick() { - const setPending = useSetAtom(pendingNotificationAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); const navigate = useNavigate(); @@ -863,12 +911,12 @@ export function HandleNotificationClick() { if (navigateUrl) navigateToServiceWorkerUrl(navigate, navigateUrl); return; } - setPending({ roomId, eventId, targetSessionId: userId }); + navigateToRoomNotificationTarget(navigate, userId, roomId, eventId); }; navigator.serviceWorker.addEventListener('message', handleMessage); return () => navigator.serviceWorker.removeEventListener('message', handleMessage); - }, [setPending, setActiveSessionId, navigate]); + }, [setActiveSessionId, navigate]); return null; } @@ -885,6 +933,8 @@ function SyncNotificationSettingsWithServiceWorker() { useEffect(() => { if (!('serviceWorker' in navigator)) return undefined; + let heartbeatIntervalId: number | undefined; + const postVisibility = () => { const visible = document.visibilityState === 'visible'; const payload = { type: 'setAppVisible', visible }; @@ -893,10 +943,46 @@ function SyncNotificationSettingsWithServiceWorker() { navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(payload)); }; - postVisibility(); - document.addEventListener('visibilitychange', postVisibility); + const stopHeartbeat = () => { + if (heartbeatIntervalId !== undefined) { + window.clearInterval(heartbeatIntervalId); + heartbeatIntervalId = undefined; + } + }; + + const restartHeartbeat = () => { + stopHeartbeat(); + postVisibility(); + if (document.visibilityState === 'visible' && document.hasFocus()) { + heartbeatIntervalId = window.setInterval(postVisibility, 10_000); + } + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') restartHeartbeat(); + else { + postVisibility(); + stopHeartbeat(); + } + }; - return () => document.removeEventListener('visibilitychange', postVisibility); + const handleFocus = () => restartHeartbeat(); + const handleBlur = () => postVisibility(); + 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); + }; }, []); useEffect(() => { @@ -1272,6 +1358,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1ea411606..0867e8109 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(mx); + useAppVisibility(mx, activeSession); const swUpdateAvailable = useSwUpdateAvailable(); const swSessionBaseUrl = activeSession?.baseUrl; diff --git a/src/app/pages/client/ToRoomEvent.tsx b/src/app/pages/client/ToRoomEvent.tsx index 3073b44e9..b64ec8e12 100644 --- a/src/app/pages/client/ToRoomEvent.tsx +++ b/src/app/pages/client/ToRoomEvent.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { useSetAtom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; // ToRoomEvent handles /to/:user_id/:room_id/:event_id? — the canonical deep-link @@ -22,10 +23,33 @@ export function ToRoomEvent() { useEffect(() => { if (!roomId) return; + Sentry.addBreadcrumb({ + category: 'notification.restore', + message: 'Entered /to notification restore route', + level: 'info', + data: { + hasUserId: !!userId, + hasRoomId: !!roomId, + hasEventId: !!eventId, + }, + }); + Sentry.metrics.count('sable.notification.to_route', 1, { + attributes: { + has_user_id: !!userId, + has_room_id: !!roomId, + has_event_id: !!eventId, + }, + }); // Switch to the target account first so the notification jumper navigates // under the correct session. if (userId) setActiveSessionId(userId); - setPending({ roomId, eventId, targetSessionId: userId }); + setPending({ + roomId, + eventId, + targetSessionId: userId, + requestedAt: Date.now(), + source: 'to_room_event', + }); // Replace /to/… in history so the back button doesn't return to this route. window.history.replaceState({}, '', '/'); }, [userId, roomId, eventId, setActiveSessionId, setPending]); diff --git a/src/app/pages/pathUtils.test.ts b/src/app/pages/pathUtils.test.ts index 141ee990e..17e7e0589 100644 --- a/src/app/pages/pathUtils.test.ts +++ b/src/app/pages/pathUtils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getSettingsPath } from './pathUtils'; +import { getSettingsPath, getToRoomEventPath } from './pathUtils'; describe('getSettingsPath', () => { it('returns the settings root path', () => { @@ -13,3 +13,17 @@ describe('getSettingsPath', () => { ); }); }); + +describe('getToRoomEventPath', () => { + it('builds the canonical notification deep-link path', () => { + expect(getToRoomEventPath('@alice:example.com', '!room:example.com', '$event123')).toBe( + '/to/%40alice%3Aexample.com/!room%3Aexample.com/%24event123' + ); + }); + + it('omits the event segment when no event id is provided', () => { + expect(getToRoomEventPath('@alice:example.com', '!room:example.com')).toBe( + '/to/%40alice%3Aexample.com/!room%3Aexample.com' + ); + }); +}); diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 964bbcf5d..e08d1f7be 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -31,6 +31,7 @@ import { SPACE_PATH, SPACE_ROOM_PATH, SPACE_SEARCH_PATH, + TO_ROOM_EVENT_PATH, CREATE_PATH, } from './paths'; @@ -152,6 +153,16 @@ export const getHomeCreatePath = (): string => HOME_CREATE_PATH; export const getHomeJoinPath = (): string => HOME_JOIN_PATH; export const getHomeSearchPath = (): string => HOME_SEARCH_PATH; export const getHomeBookmarksPath = (): string => HOME_BOOKMARKS_PATH; +export const getToRoomEventPath = (userId: string, roomId: string, eventId?: string): string => { + const params = { + user_id: encodeURIComponent(userId), + room_id: encodeURIComponent(roomId), + event_id: eventId ? encodeURIComponent(eventId) : null, + }; + + return generatePath(TO_ROOM_EVENT_PATH, params); +}; + export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string => { const params = { roomIdOrAlias: encodeURIComponent(roomIdOrAlias), diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index d2ae0ec5a..a72289ce8 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -172,6 +172,8 @@ export type PendingNotification = { roomId: string; eventId?: string; targetSessionId?: string; + requestedAt?: number; + source?: 'to_room_event'; }; export const pendingNotificationAtom = atom(null); diff --git a/src/launch-context-persistence.test.ts b/src/launch-context-persistence.test.ts new file mode 100644 index 000000000..58fcce1ac --- /dev/null +++ b/src/launch-context-persistence.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { readPersistedLaunchContext } from './launch-context-persistence'; + +describe('readPersistedLaunchContext', () => { + it('parses persisted notification click launch context', () => { + expect( + readPersistedLaunchContext({ + source: 'notification_click', + clickedAt: 123, + userId: '@alice:example.org', + roomId: '!room:example.org', + eventId: '$event', + targetUrl: 'https://example.org/to/%40alice%3Aexample.org/!room%3Aexample.org/%24event', + }) + ).toEqual({ + source: 'notification_click', + clickedAt: 123, + userId: '@alice:example.org', + roomId: '!room:example.org', + eventId: '$event', + targetUrl: 'https://example.org/to/%40alice%3Aexample.org/!room%3Aexample.org/%24event', + }); + }); + + it('rejects malformed launch context records', () => { + expect(readPersistedLaunchContext({ source: 'notification_click' })).toBeUndefined(); + expect(readPersistedLaunchContext({ source: 'other', clickedAt: 123 })).toBeUndefined(); + }); +}); diff --git a/src/launch-context-persistence.ts b/src/launch-context-persistence.ts new file mode 100644 index 000000000..ab57a7e97 --- /dev/null +++ b/src/launch-context-persistence.ts @@ -0,0 +1,60 @@ +export type PersistedLaunchContext = { + source: 'notification_click'; + clickedAt: number; + userId?: string; + roomId?: string; + eventId?: string; + targetUrl?: string; +}; + +const LAUNCH_CONTEXT_CACHE = 'sable-launch-context-v1'; +const LAUNCH_CONTEXT_URL = '/launch-context-meta'; + +export function readPersistedLaunchContext(value: unknown): PersistedLaunchContext | undefined { + if (typeof value !== 'object' || value === null) return undefined; + + const context = value as { + source?: unknown; + clickedAt?: unknown; + userId?: unknown; + roomId?: unknown; + eventId?: unknown; + targetUrl?: unknown; + }; + + if (context.source !== 'notification_click' || typeof context.clickedAt !== 'number') { + return undefined; + } + + return { + source: context.source, + clickedAt: context.clickedAt, + userId: typeof context.userId === 'string' ? context.userId : undefined, + roomId: typeof context.roomId === 'string' ? context.roomId : undefined, + eventId: typeof context.eventId === 'string' ? context.eventId : undefined, + targetUrl: typeof context.targetUrl === 'string' ? context.targetUrl : undefined, + }; +} + +export async function persistLaunchContext(context: PersistedLaunchContext): Promise { + if (!('caches' in globalThis)) return; + + const cache = await globalThis.caches.open(LAUNCH_CONTEXT_CACHE); + await cache.put( + LAUNCH_CONTEXT_URL, + new Response(JSON.stringify(context), { + headers: { 'Content-Type': 'application/json' }, + }) + ); +} + +export async function consumeLaunchContext(): Promise { + if (!('caches' in globalThis)) return undefined; + + const cache = await globalThis.caches.open(LAUNCH_CONTEXT_CACHE); + const response = await cache.match(LAUNCH_CONTEXT_URL); + if (!response) return undefined; + + await cache.delete(LAUNCH_CONTEXT_URL); + return readPersistedLaunchContext(await response.json()); +} diff --git a/src/serviceWorkerBootstrap.test.ts b/src/serviceWorkerBootstrap.test.ts index cfb2233ac..0e78ead68 100644 --- a/src/serviceWorkerBootstrap.test.ts +++ b/src/serviceWorkerBootstrap.test.ts @@ -8,6 +8,7 @@ const { mockAddEventListener, mockReady, mockPushSessionToSW, + mockConsumeLaunchContext, mockWarn, } = vi.hoisted(() => ({ mockHasServiceWorker: vi.fn(), @@ -19,6 +20,7 @@ const { mockAddEventListener: vi.fn(), mockReady: Promise.resolve(undefined), mockPushSessionToSW: vi.fn(), + mockConsumeLaunchContext: vi.fn().mockResolvedValue(undefined), mockWarn: vi.fn(), })); @@ -30,6 +32,10 @@ vi.mock('./sw-session', () => ({ pushSessionToSW: mockPushSessionToSW, })); +vi.mock('./launch-context-persistence', () => ({ + consumeLaunchContext: mockConsumeLaunchContext, +})); + vi.mock('./app/state/sessions', () => ({ getFallbackSession: vi.fn(() => undefined), MATRIX_SESSIONS_KEY: 'matrix-sessions', @@ -91,6 +97,15 @@ describe('registerAppServiceWorker', () => { expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function)); }); + it('consumes any persisted launch context during bootstrap', async () => { + mockHasServiceWorker.mockReturnValue(true); + + registerAppServiceWorker(); + await Promise.resolve(); + + expect(mockConsumeLaunchContext).toHaveBeenCalledTimes(1); + }); + it('pushes the active session immediately when a controller already exists', () => { mockHasServiceWorker.mockReturnValue(true); diff --git a/src/serviceWorkerBootstrap.ts b/src/serviceWorkerBootstrap.ts index 26996adf3..351051ead 100644 --- a/src/serviceWorkerBootstrap.ts +++ b/src/serviceWorkerBootstrap.ts @@ -6,6 +6,7 @@ import { getFallbackSession, MATRIX_SESSIONS_KEY, ACTIVE_SESSION_KEY } from './a import { getLocalStorageItem } from './app/state/utils/atomWithLocalStorage'; import { hasServiceWorker } from './app/utils/platform'; import { pushSessionToSW } from './sw-session'; +import { consumeLaunchContext } from './launch-context-persistence'; const log = createLogger('service-worker-bootstrap'); const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt'; @@ -70,6 +71,43 @@ export function registerAppServiceWorker() { sendSessionToSW(); + void consumeLaunchContext() + .then((launchContext) => { + if (!launchContext) return; + const launchAgeMs = Date.now() - launchContext.clickedAt; + Sentry.addBreadcrumb({ + category: 'app.launch', + message: 'Consumed persisted launch context', + level: 'info', + data: { + source: launchContext.source, + launchAgeMs, + hasUserId: !!launchContext.userId, + hasRoomId: !!launchContext.roomId, + hasEventId: !!launchContext.eventId, + }, + }); + Sentry.metrics.count('sable.app.launch_context', 1, { + attributes: { + source: launchContext.source, + has_user_id: !!launchContext.userId, + has_room_id: !!launchContext.roomId, + has_event_id: !!launchContext.eventId, + }, + }); + Sentry.metrics.distribution('sable.app.launch_context_age_ms', launchAgeMs, { + attributes: { source: launchContext.source }, + }); + }) + .catch((err) => { + Sentry.addBreadcrumb({ + category: 'app.launch', + message: 'Failed to consume persisted launch context', + level: 'warning', + data: { error: err instanceof Error ? err.message : String(err) }, + }); + }); + Sentry.addBreadcrumb({ category: 'service_worker', message: 'Registering app service worker', diff --git a/src/sw.ts b/src/sw.ts index 99ee87db1..cbb950f5d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -11,6 +11,7 @@ import { isDeclarativeWebPushPayload, isMinimalPushPayload, } from './sw/pushRouting'; +import { persistLaunchContext } from './launch-context-persistence'; import { readPersistedSession } from './sw-session-persistence'; declare const self: ServiceWorkerGlobalScope; @@ -20,10 +21,12 @@ let notificationSoundEnabled = true; // The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, // so we use this explicit flag as a fallback. let appIsVisible = false; +let appVisibleHeartbeatAt = 0; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; let focusMode: 'off' | 'focus' | 'dnd' = 'off'; +const APP_VISIBLE_HEARTBEAT_MAX_AGE_MS = 20_000; const { handlePushNotificationPushData } = createPushNotifications( self, () => ({ @@ -1086,6 +1089,7 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; + appVisibleHeartbeatAt = appIsVisible ? Date.now() : 0; } } if (type === 'CLAIM_CLIENTS') { @@ -1627,11 +1631,15 @@ const onPushNotification = async (event: PushEvent) => { const focusedClientCount = focusedWindowClientCount(clients); const browserVisibleClientCount = visibleWindowClientCount(clients); - const hasVisibleClient = appIsVisible || browserVisibleClientCount > 0; + const hasRecentAppVisibilityHeartbeat = + appIsVisible && Date.now() - appVisibleHeartbeatAt <= APP_VISIBLE_HEARTBEAT_MAX_AGE_MS; + const hasVisibleClient = hasRecentAppVisibilityHeartbeat || browserVisibleClientCount > 0; console.debug( '[SW push] appIsVisible:', appIsVisible, + '| hasRecentAppVisibilityHeartbeat:', + hasRecentAppVisibilityHeartbeat, '| focusedClientCount:', focusedClientCount, '| browserVisibleClientCount:', @@ -1827,6 +1835,15 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { event.waitUntil( (async () => { + await persistLaunchContext({ + source: 'notification_click', + clickedAt: Date.now(), + userId: pushUserId, + roomId: pushRoomId, + eventId: pushEventId, + targetUrl, + }).catch(() => undefined); + const clientList = (await self.clients.matchAll({ type: 'window', includeUncontrolled: true, @@ -1844,7 +1861,7 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { for (const wc of clientList) { console.debug('[SW notificationclick] postMessage to existing client:', wc.url); try { - if (pushNavigate && !pushRoomId && typeof wc.navigate === 'function') { + if (typeof wc.navigate === 'function') { // oxlint-disable-next-line no-await-in-loop await wc.navigate(targetUrl); // oxlint-disable-next-line no-await-in-loop @@ -1852,9 +1869,8 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { return; } // Post notification data directly to the running app so its - // ServiceWorkerClickHandler can call setActiveSessionId + setPending - // (same path as the pill-style in-app banner) without navigating to - // the /to/ route first. + // notification handler can route to the canonical deep-link when + // navigate() is unavailable on the existing client. wc.postMessage({ type: 'notificationClick', userId: pushUserId,