From aa455974062c6f6816232f818acc12e9e73830eb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 8 May 2026 08:50:07 -0400 Subject: [PATCH 1/4] fix(notifications): prevent push dropout after SW restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes for notifications stopping after a while: 1. sw.ts hasVisibleClient used OR logic (appIsVisible || matchAll visible): On iOS the SW is killed between pushes so appIsVisible resets to false on restart. With OR logic, a stale matchAll() result with visibilityState='visible' still set hasVisibleClient=true, silently suppressing every notification after the first. Fix: switch to AND logic so BOTH appIsVisible AND a visible client are required to suppress. A cold-start SW (appIsVisible=false) never suppresses, regardless of stale matchAll() data. 2. useAppVisibility.ts was passing isMobile as keepEnabledWhenVisible, meaning on desktop the pusher was deleted from the homeserver whenever the tab was visible. If the async re-enable in enablePushNotifications didn't complete before the page was torn down, the homeserver was left with no pusher — so no more push notifications until a manual background/foreground cycle. Fix: always pass true for keepEnabledWhenVisible so the pusher stays registered permanently. The SW's hasVisibleClient check handles OS-notification suppression in the foreground; the homeserver never needs to be without a pusher. --- src/app/hooks/useAppVisibility.ts | 11 +++++++---- src/sw.ts | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index b56f564ca..cc35418f1 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -7,7 +7,6 @@ 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'; const debugLog = createDebugLogger('AppVisibility'); @@ -16,7 +15,6 @@ export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); - const isMobile = mobileOrTablet(); useEffect(() => { const handleVisibilityChange = () => { @@ -43,12 +41,17 @@ export function useAppVisibility(mx: MatrixClient | undefined) { if (!mx) return undefined; const handleVisibilityForNotifications = (isVisible: boolean) => { - togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); + // Always keep the pusher registered regardless of visibility — the SW's + // hasVisibleClient check handles OS-notification suppression when the app + // is in the foreground, so we never need to delete the pusher. Keeping + // it permanently avoids the enable/disable race that can leave the + // homeserver without a valid pusher after rapid tab-focus changes. + togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, true); }; appEvents.onVisibilityChange = handleVisibilityForNotifications; return () => { appEvents.onVisibilityChange = null; }; - }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + }, [mx, clientConfig, usePushNotifications, pushSubAtom]); } diff --git a/src/sw.ts b/src/sw.ts index 78255b701..8021724a6 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -763,10 +763,21 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. - // Combine clients.matchAll() visibility with the explicit appIsVisible flag - // because iOS Safari PWA often returns empty or stale results from matchAll(). + // + // Require BOTH the explicit appIsVisible flag AND a visible client from + // matchAll() before suppressing. appIsVisible resets to false every time the + // SW starts fresh; on iOS the browser kills the SW between pushes, so on the + // next push appIsVisible is always false — we never suppress on a cold SW + // restart, which prevents the "notifications stop after a while" bug where + // stale matchAll() data (visibilityState stuck at 'visible') would cause all + // subsequent notifications to be silently dropped. + // + // When matchAll() returns zero clients (iOS Safari PWA fully-suspended quirk), + // clients.some() returns false — do NOT suppress. Better to show a duplicate + // (handled gracefully by the in-app banner) than to silently drop a + // notification while the app is backgrounded. const hasVisibleClient = - appIsVisible || clients.some((client) => client.visibilityState === 'visible'); + appIsVisible && clients.some((client) => client.visibilityState === 'visible'); console.debug( '[SW push] appIsVisible:', appIsVisible, From d931840954bb7302eedac0877f384c753aae7b8a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 4 May 2026 10:23:42 -0400 Subject: [PATCH 2/4] fix(sw): add userId to pushSessionToSW calls, add heartbeat + foreground resync - Pass userId in both pushSessionToSW calls in ClientRoot (was missing) - On tab foreground, immediately resync session to SW - 10-minute interval heartbeat keeps persisted session fresh (prevents iOS from invalidating session while app is backgrounded) --- src/app/pages/client/ClientRoot.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 70ba9221e..72fa5ef3f 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -205,7 +205,7 @@ export function ClientRoot({ children }: ClientRootProps) { log.log('initClient for', activeSession.userId); const newMx = await initClient(activeSession); loadedUserIdRef.current = activeSession.userId; - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); return newMx; }, [activeSession, activeSessionId, setActiveSessionId]) ); @@ -234,7 +234,7 @@ export function ClientRoot({ children }: ClientRootProps) { activeSession.userId, '— reloading client' ); - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); if (mx?.clientRunning) { stopClient(mx); } @@ -259,6 +259,28 @@ export function ClientRoot({ children }: ClientRootProps) { useLogoutListener(mx); useAppVisibility(mx); + // Keep the SW session warm so media fetches and push notifications work + // reliably after iOS kills and restarts the SW in the background. + // - Immediate resync whenever the tab comes back to the foreground. + // - Periodic heartbeat (10 min) keeps the persisted session up to date + // while the app is running. + const swSessionBaseUrl = activeSession?.baseUrl; + const swSessionAccessToken = activeSession?.accessToken; + const swSessionUserId = activeSession?.userId; + useEffect(() => { + if (!swSessionBaseUrl || !swSessionAccessToken) return undefined; + const resync = () => pushSessionToSW(swSessionBaseUrl, swSessionAccessToken, swSessionUserId); + const handleVisibility = () => { + if (document.visibilityState === 'visible') resync(); + }; + document.addEventListener('visibilitychange', handleVisibility); + const timer = setInterval(resync, 10 * 60 * 1000); + return () => { + document.removeEventListener('visibilitychange', handleVisibility); + clearInterval(timer); + }; + }, [swSessionBaseUrl, swSessionAccessToken, swSessionUserId]); + useEffect( () => () => { if (mx?.clientRunning) { From d1e96d9b1041be66a8497ac2caec7e65ad5c7753 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 5 May 2026 07:56:45 -0400 Subject: [PATCH 3/4] fix(push): rename app_display_name from Cinny to Sable in pusher registration --- src/app/features/settings/notifications/PushNotifications.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 7f510b444..be9e627fb 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -57,7 +57,7 @@ export async function enablePushNotifications( kind: 'http' as const, app_id: clientConfig.pushNotificationDetails?.webPushAppID, pushkey: keys.p256dh, - app_display_name: 'Cinny', + app_display_name: 'Sable', device_display_name: 'This Browser', lang: navigator.language || 'en', data: { @@ -104,7 +104,7 @@ export async function enablePushNotifications( kind: 'http' as const, app_id: clientConfig.pushNotificationDetails?.webPushAppID, pushkey: keys.p256dh, - app_display_name: 'Cinny', + app_display_name: 'Sable', device_display_name: (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device', lang: navigator.language || 'en', From 901a391e9a4e2b0cc24ad44b8c00235221115a91 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 11 May 2026 09:06:22 -0400 Subject: [PATCH 4/4] fix(sw): unregister service workers in clearLoginData for clean reload on mobile --- src/client/initMatrix.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 9e0496ee3..b35c8a4a7 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -802,5 +802,13 @@ export const clearLoginData = async () => { if (name) window.indexedDB.deleteDatabase(name); }); window.localStorage.clear(); + + // Unregister all service workers so the next load starts fresh. + // Especially important on iOS/mobile where stale SWs can persist. + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + window.location.reload(); };