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', 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/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) { 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(); }; 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,