From fafea7bd02325460c263f67294e5f92f8a47289e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:30:45 -0400 Subject: [PATCH 01/24] fix(sw): increase session TTL to 24h and add requestSessionWithTimeout fallback Matrix access tokens are long-lived and only invalidated on logout or server revocation. The previous 60s TTL caused iOS push handlers (which restart the SW per push) to reject cached sessions as stale, resulting in generic 'New Message' notifications. Also adds a requestSessionWithTimeout fallback in handleMinimalPushPayload that asks live window clients for a fresh session when neither the in-memory map nor the persisted cache contains a usable session. --- src/sw.ts | 226 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 190 insertions(+), 36 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index bd09cd8d3..2e73f7004 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -69,9 +69,12 @@ async function loadPersistedSettings() { async function persistSession(session: SessionInfo): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); + const sessionWithTimestamp = { ...session, persistedAt: Date.now() }; await cache.put( SW_SESSION_URL, - new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify(sessionWithTimestamp), { + headers: { 'Content-Type': 'application/json' }, + }) ); } catch { // Ignore — caches may be unavailable in some environments. @@ -91,13 +94,32 @@ async function loadPersistedSession(): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); const response = await cache.match(SW_SESSION_URL); - if (!response) return undefined; - const s = await response.json(); - if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + if (response) { + const s = await response.json(); + + // Reject persisted sessions older than 24 hours. Matrix access tokens are + // long-lived and are only invalidated on explicit logout or device revocation — + // not by the passage of time. A short TTL (e.g. 60 s) was too aggressive: it + // caused the SW to show generic "New Message" notifications whenever the app + // was backgrounded for more than a minute, because the cached session was + // rejected and requestSession had no live window client to reach. + // If the token truly is revoked the fetches in handleMinimalPushPayload will + // receive a 401 and gracefully fall back to a generic notification anyway. + const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; + const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + if (age > MAX_SESSION_AGE_MS) { + console.debug('[SW] loadPersistedSession: session expired', { + age, + accessToken: s.accessToken.slice(0, 8), + }); + return undefined; + } + return { accessToken: s.accessToken, baseUrl: s.baseUrl, userId: typeof s.userId === 'string' ? s.userId : undefined, + persistedAt: s.persistedAt, }; } return undefined; @@ -111,6 +133,8 @@ type SessionInfo = { baseUrl: string; /** Matrix user ID of the account, used to identify which account a push belongs to. */ userId?: string; + /** Timestamp when this session was persisted to cache, used to expire stale tokens. */ + persistedAt?: number; }; /** @@ -414,7 +438,16 @@ async function handleMinimalPushPayload( // 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 // the app (same pattern as settings persistence). - const session = getAnyStoredSession() ?? (await loadPersistedSession()); + // Last resort: if neither the in-memory map nor the cache has a session, ask any live + // window client for a fresh token (the app may be backgrounded but still alive in memory). + let session = getAnyStoredSession() ?? (await loadPersistedSession()); + if (!session && windowClients.length > 0) { + console.debug('[SW push] no cached session, requesting from window clients'); + const result = await Promise.race( + Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) + ); + session = result ?? undefined; + } if (!session) { // No session anywhere — app was never opened since install, or the user logged out. @@ -555,6 +588,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setSession') { setSession(client.id, accessToken, baseUrl, userId); + // Keep the SW alive until the cache write completes. persistSession is + // called fire-and-forget inside setSession; without waitUntil the browser + // can kill the SW before caches.put resolves, leaving the persisted session + // stale on the next restart and causing intermittent 401s on media fetches. + const persisted = sessions.get(client.id); + event.waitUntil( + (persisted ? persistSession(persisted) : clearPersistedSession()).catch(() => undefined) + ); event.waitUntil(cleanupDeadClients()); } if (type === 'pushDecryptResult') { @@ -604,12 +645,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const MEDIA_PATHS = [ '/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail', + '/_matrix/client/v1/media/preview_url', + '/_matrix/client/v3/media/download', + '/_matrix/client/v3/media/thumbnail', + '/_matrix/client/v3/media/preview_url', + '/_matrix/client/r0/media/download', + '/_matrix/client/r0/media/thumbnail', + '/_matrix/client/r0/media/preview_url', + '/_matrix/client/unstable/org.matrix.msc3916/media/download', + '/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail', + '/_matrix/client/unstable/org.matrix.msc3916/media/preview_url', // Legacy unauthenticated endpoints — servers that require auth return 404/403 // for these when no token is present, so intercept and add auth here too. '/_matrix/media/v3/download', '/_matrix/media/v3/thumbnail', + '/_matrix/media/v3/preview_url', '/_matrix/media/r0/download', '/_matrix/media/r0/thumbnail', + '/_matrix/media/r0/preview_url', ]; function mediaPath(url: string): boolean { @@ -628,6 +681,39 @@ function validMediaRequest(url: string, baseUrl: string): boolean { }); } +function getMatchingSessions(url: string): SessionInfo[] { + return [...sessions.values()].filter((s) => validMediaRequest(url, s.baseUrl)); +} + +function isAuthFailureStatus(status: number): boolean { + return status === 401 || status === 403; +} + +async function getLiveWindowSessions(url: string, clientId: string): Promise { + const collected: SessionInfo[] = []; + const seen = new Set(); + const add = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seen.has(key)) return; + seen.add(key); + collected.push(session); + }; + + if (clientId) { + add(await requestSessionWithTimeout(clientId, 1500)); + return collected; + } + + const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const liveSessions = await Promise.all( + windowClients.map((client) => requestSessionWithTimeout(client.id, 750)) + ); + liveSessions.forEach((session) => add(session)); + + return collected; +} + function fetchConfig(token: string): RequestInit { return { headers: { @@ -637,6 +723,67 @@ function fetchConfig(token: string): RequestInit { }; } +/** + * Fetch a media URL, retrying once with the most-current in-memory session on 401. + * + * There is a timing window between when the SDK refreshes its access token + * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW() + * postMessage is processed by the SW. Media requests that land in this window + * are sent with the stale token and receive 401. By the time the retry runs, + * the setSession message will normally have been processed and sessions will + * hold the new token. + * + * A second timing window exists at startup: preloadedSession may hold a stale + * token but the live setSession from the page hasn't arrived yet. In that case + * the in-memory check yields no fresher token, so we ask the live client tab + * directly (requestSessionWithTimeout) before giving up. + */ +async function fetchMediaWithRetry( + url: string, + token: string, + redirect: RequestRedirect, + clientId: string +): Promise { + let response = await fetch(url, { ...fetchConfig(token), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + + const attemptedTokens = new Set([token]); + const retrySessions: SessionInfo[] = []; + const seenSessions = new Set(); + + const addRetrySession = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seenSessions.has(key)) return; + seenSessions.add(key); + retrySessions.push(session); + }; + + if (clientId) addRetrySession(sessions.get(clientId)); + getMatchingSessions(url).forEach((session) => addRetrySession(session)); + addRetrySession(preloadedSession); + addRetrySession(await loadPersistedSession()); + (await getLiveWindowSessions(url, clientId)).forEach((session) => addRetrySession(session)); + + // Try each plausible token once. This handles token-refresh races and ambiguous + // multi-account sessions on the same homeserver, including no-clientId requests. + // Sequential await is intentional: we want to try one token at a time until one succeeds. + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < retrySessions.length; i += 1) { + const candidate = retrySessions[i]; + if (!candidate || attemptedTokens.has(candidate.accessToken)) { + // skip this candidate + } else { + attemptedTokens.add(candidate.accessToken); + response = await fetch(url, { ...fetchConfig(candidate.accessToken), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + } + } + /* eslint-enable no-await-in-loop */ + + return response; +} + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -667,37 +814,24 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); - return; - } - - // Since widgets like element call have their own client ids, - // we need this logic. We just go through the sessions list and get a session - // with the right base url. Media requests to a homeserver simply are fine with any account - // on the homeserver authenticating it, so this is fine. But it can be technically wrong. - // If you have two tabs for different users on the same homeserver, it might authenticate - // as the wrong one. - // Thus any logic in the future which cares about which user is authenticating the request - // might break this. Also, again, it is technically wrong. - // Also checks preloadedSession — populated from cache at SW activate — for the window - // between SW restart and the first live setSession arriving from the page. - const byBaseUrl = - [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ?? - (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl) - ? preloadedSession - : undefined); - if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithRetry(url, session.accessToken, redirect, clientId)); return; } // No clientId: the fetch came from a context not associated with a specific - // window (e.g. a prerender). Fall back to the persisted session directly. + // window (e.g. a prerender). Fall back to persisted/unique-by-baseUrl sessions. if (!clientId) { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, ''); + } + const matching = getMatchingSessions(url); + if (matching.length === 1) { + return fetchMediaWithRetry(url, matching[0].accessToken, redirect, ''); + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, ''); } return fetch(event.request); }) @@ -705,17 +839,30 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } + // Synchronous fast-path: check in-memory sessions by baseUrl and the + // preloaded session before paying the 3-second requestSessionWithTimeout + // cost. This restores the old byBaseUrl behaviour while keeping retry logic. + const syncByBaseUrl = getMatchingSessions(url); + if (syncByBaseUrl.length === 1) { + event.respondWith(fetchMediaWithRetry(url, syncByBaseUrl[0].accessToken, redirect, clientId)); + return; + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + event.respondWith(fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId)); + return; + } + event.respondWith( requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithRetry(url, s.accessToken, redirect, clientId); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, clientId); } console.warn( '[SW fetch] No valid session for media request', @@ -749,10 +896,19 @@ 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(). + // + // When clients.matchAll() returns ≥1 client, trust its visibilityState + // directly. iOS can suspend the JS thread before postMessage({ visible: + // false }) is processed, leaving appIsVisible stuck at true. matchAll() + // still reports the backgrounded client as 'hidden', so it is the + // authoritative and most reliable signal. + // + // When matchAll() returns zero clients (a separate iOS Safari PWA quirk), + // visibility is unknowable — 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'); + clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; console.debug( '[SW push] appIsVisible:', appIsVisible, @@ -902,7 +1058,5 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { ); }); -if (self.__WB_MANIFEST) { - precacheAndRoute(self.__WB_MANIFEST); -} +precacheAndRoute(self.__WB_MANIFEST); cleanupOutdatedCaches(); From 00e90951c23230f0af93b0ab8cb7aef2ffe0ef94 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:58:21 -0400 Subject: [PATCH 02/24] fix(sw): reset heartbeat backoff on foreground sync; warm preloadedSession from push handler When phase3AdaptiveBackoffJitter is enabled, successful foreground/focus session pushes (phase1ForegroundResync) now reset heartbeatFailuresRef to 0. Previously a period of SW controller absence (e.g. SW update) could inflate the heartbeat interval to its maximum (30 min) even after the SW became healthy again, reducing session-refresh frequency below the intended 10-minute rate. Also captures the loadPersistedSession() result in onPushNotification and assigns it to preloadedSession, avoiding a redundant second cache read in handleMinimalPushPayload when the SW is restarted by iOS for a push event. --- src/app/hooks/useAppVisibility.ts | 213 +++++++++++++++++++++++++++++- src/sw.ts | 8 +- 2 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 7fd5f2325..ed2d69cfb 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,23 +1,112 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; +import { Session } from '$state/sessions'; import { useAtom } from 'jotai'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig } from './useClientConfig'; +import { useClientConfig, useExperimentVariant } 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 { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -export function useAppVisibility(mx: MatrixClient | undefined) { +const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; +const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; +const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; + +export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = mobileOrTablet(); + const sessionSyncConfig = clientConfig.sessionSync; + const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); + + // Derive phase flags from experiment variant; fall back to direct config when not in experiment. + const inSessionSync = sessionSyncVariant.inExperiment; + const syncVariant = sessionSyncVariant.variant; + const phase1ForegroundResync = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase1ForegroundResync === true; + const phase2VisibleHeartbeat = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase2VisibleHeartbeat === true; + const phase3AdaptiveBackoffJitter = inSessionSync + ? syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; + + const foregroundDebounceMs = Math.max( + 0, + sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS + ); + const heartbeatIntervalMs = Math.max( + 1000, + sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS + ); + const resumeHeartbeatSuppressMs = Math.max( + 0, + sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS + ); + const heartbeatMaxBackoffMs = Math.max( + heartbeatIntervalMs, + sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS + ); + + const lastForegroundPushAtRef = useRef(0); + const suppressHeartbeatUntilRef = useRef(0); + const heartbeatFailuresRef = useRef(0); + + const pushSessionNow = useCallback( + (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { + const baseUrl = activeSession?.baseUrl; + const accessToken = activeSession?.accessToken; + const userId = activeSession?.userId; + const canPush = + !!mx && + 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, + hasClient: !!mx, + hasBaseUrl: !!baseUrl, + hasAccessToken: !!accessToken, + hasUserId: !!userId, + hasSwController: !!navigator.serviceWorker?.controller, + }); + return 'skipped'; + } + + pushSessionToSW(baseUrl, accessToken, userId); + debugLog.info('network', 'Pushed session to SW', { + reason, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + }); + return 'sent'; + }, + [ + activeSession?.accessToken, + activeSession?.baseUrl, + activeSession?.userId, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + ] + ); + useEffect(() => { const handleVisibilityChange = () => { const isVisible = document.visibilityState === 'visible'; @@ -29,15 +118,66 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { appEvents.onVisibilityHidden?.(); + return; + } + + // Always kick the sync loop on foreground regardless of phase flags — + // the SDK may be sitting in exponential backoff after iOS froze the tab. + mx?.retryImmediately(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('foreground') === 'sent') { + // A successful push proves the SW controller is up — reset adaptive backoff + // so the heartbeat returns to its normal interval immediately rather than + // staying on an inflated delay left over from a prior SW absence period. + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } + } + }; + + const handleFocus = () => { + if (document.visibilityState !== 'visible') return; + + // Always kick the sync loop on focus for the same reason as above. + mx?.retryImmediately(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('focus') === 'sent') { + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } } }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); }; - }, []); + }, [ + foregroundDebounceMs, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + resumeHeartbeatSuppressMs, + ]); useEffect(() => { if (!mx) return; @@ -52,4 +192,69 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange = null; }; }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + + useEffect(() => { + if (!phase2VisibleHeartbeat) return undefined; + + // Reset adaptive backoff/suppression so a config or session change starts fresh. + heartbeatFailuresRef.current = 0; + suppressHeartbeatUntilRef.current = 0; + + let timeoutId: number | undefined; + + const getDelayMs = (): number => { + let delay = heartbeatIntervalMs; + + if (phase3AdaptiveBackoffJitter) { + const failures = heartbeatFailuresRef.current; + const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); + delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); + + // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. + const jitter = 0.8 + Math.random() * 0.4; + delay = Math.max(1000, Math.round(delay * jitter)); + } + + return delay; + }; + + const tick = () => { + const now = Date.now(); + + if (document.visibilityState !== 'visible' || !navigator.onLine) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + const result = pushSessionNow('heartbeat'); + if (phase3AdaptiveBackoffJitter) { + if (result === 'sent') { + heartbeatFailuresRef.current = 0; + } else { + // 'skipped' means prerequisites (SW controller, session) aren't ready. + // Treat as a transient failure so backoff grows until the SW is ready. + heartbeatFailuresRef.current += 1; + } + } + + timeoutId = window.setTimeout(tick, getDelayMs()); + }; + + timeoutId = window.setTimeout(tick, getDelayMs()); + + return () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }; + }, [ + heartbeatIntervalMs, + heartbeatMaxBackoffMs, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + ]); } diff --git a/src/sw.ts b/src/sw.ts index 2e73f7004..d8b5a8697 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -888,11 +888,17 @@ const onPushNotification = async (event: PushEvent) => { // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. - const [, , clients] = await Promise.all([ + // Capture the persisted session result into preloadedSession so that + // getAnyStoredSession() returns it in handleMinimalPushPayload without a + // second cache read. + const [, persistedSession, clients] = await Promise.all([ loadPersistedSettings(), loadPersistedSession(), self.clients.matchAll({ type: 'window', includeUncontrolled: true }), ]); + if (persistedSession && !preloadedSession) { + preloadedSession = persistedSession; + } // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. From 8672ff939d4ebcab0939e7519666e6061fb0ded8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 12:10:48 -0400 Subject: [PATCH 03/24] chore: add changeset for sw-push-session-recovery --- .changeset/sw-push-session-recovery.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sw-push-session-recovery.md diff --git a/.changeset/sw-push-session-recovery.md b/.changeset/sw-push-session-recovery.md new file mode 100644 index 000000000..625947009 --- /dev/null +++ b/.changeset/sw-push-session-recovery.md @@ -0,0 +1,5 @@ +--- +'@sable/client': patch +--- + +fix(sw): improve push session recovery by increasing TTL, adding timeout fallback, and resetting heartbeat backoff on foreground sync From d18e0dfc38d944212059739a0a48094245fdd8af Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 14:15:12 -0400 Subject: [PATCH 04/24] fix(notifications): replace stale visibility flags with live client ping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When iOS backgrounds the PWA, the WKWebView JS thread can be frozen before visibilitychange fires. This leaves appIsVisible stuck at true and clients.matchAll() returning a stale 'visible' state — both signals stale simultaneously — causing the dual AND gate to wrongly suppress push notifications for backgrounded apps. Replace the stale-flag check with checkLiveVisibility(): ping each window client via postMessage and require a response within 500 ms to confirm the app is genuinely in the foreground. A frozen/backgrounded page cannot respond, so the timeout causes checkLiveVisibility to return false and the notification is shown correctly. The encrypted-event path already uses this pattern (requestDecryptionFromClient acts as the live check) and is unaffected. Also added the matching checkVisibility/visibilityCheckResult message pair to HandleDecryptPushEvent so the page can respond to the new ping. --- src/app/pages/client/ClientNonUIFeatures.tsx | 17 +++- src/sw.ts | 84 ++++++++++++++++---- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..f273ea916 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -765,7 +765,22 @@ function HandleDecryptPushEvent() { const handleMessage = async (ev: MessageEvent) => { const { data } = ev; - if (!data || data.type !== 'decryptPushEvent') return; + if (!data) return; + + // Respond to live visibility pings from the SW push handler. + // Using a live round-trip avoids false suppression when the page JS was + // frozen before visibilitychange could fire (an iOS Safari PWA quirk). + if (data.type === 'checkVisibility') { + const { id } = data as { id: string }; + navigator.serviceWorker.controller?.postMessage({ + type: 'visibilityCheckResult', + id, + visible: document.visibilityState === 'visible', + }); + return; + } + + if (data.type !== 'decryptPushEvent') return; const { rawEvent } = data as { rawEvent: Record }; const eventId = rawEvent.event_id as string; diff --git a/src/sw.ts b/src/sw.ts index d8b5a8697..7fc6d5521 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -254,6 +254,50 @@ type DecryptionResult = { /** Pending decryption requests keyed by event_id. */ const decryptionPendingMap = new Map void>(); +/** Pending visibility check requests keyed by check ID. */ +const visibilityCheckPendingMap = new Map void>(); + +/** + * Ping each window client in sequence and return true if any confirm they are + * currently visible (document.visibilityState === 'visible'). If a client + * fails to respond within 500 ms its JS thread is likely frozen (iOS + * backgrounded-before-visibilitychange race) — treat that as not visible. + * + * This is more reliable than stale in-memory flags (appIsVisible) or + * clients.matchAll() visibilityState, both of which can be simultaneously + * stale when iOS freezes the WKWebView before any events fire. + */ +async function checkLiveVisibility(clients: readonly Client[]): Promise { + if (clients.length === 0) return false; + + return Array.from(clients).reduce>(async (prevPromise, client, idx) => { + const prev = await prevPromise; + if (prev) return true; + + const checkId = `vis-${Date.now()}-${idx}`; + + const promise = new Promise((resolve) => { + visibilityCheckPendingMap.set(checkId, resolve); + }); + + const timeout = new Promise((resolve) => { + setTimeout(() => { + visibilityCheckPendingMap.delete(checkId); + resolve(false); + }, 500); + }); + + try { + client.postMessage({ type: 'checkVisibility', id: checkId }); + } catch { + visibilityCheckPendingMap.delete(checkId); + return false; + } + + return Promise.race([promise, timeout]); + }, Promise.resolve(false)); +} + /** * Fetch a single raw Matrix event from the homeserver. * Returns undefined on error (e.g. network failure, auth error, redacted event). @@ -609,6 +653,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } } } + if (type === 'visibilityCheckResult') { + const { id, visible } = data as { id: string; visible: boolean }; + const resolve = visibilityCheckPendingMap.get(id); + if (resolve) { + visibilityCheckPendingMap.delete(id); + resolve(!!visible); + } + } if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; @@ -903,27 +955,29 @@ 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. // - // When clients.matchAll() returns ≥1 client, trust its visibilityState - // directly. iOS can suspend the JS thread before postMessage({ visible: - // false }) is processed, leaving appIsVisible stuck at true. matchAll() - // still reports the backgrounded client as 'hidden', so it is the - // authoritative and most reliable signal. + // We do a live visibility ping rather than relying on stale in-memory state: + // + // • stale appIsVisible: if iOS freezes the WKWebView JS thread before the + // visibilitychange event fires, the page never sends setAppVisible=false, + // leaving appIsVisible stuck at true. + // + // • stale matchAll() visibilityState: iOS can also fail to update the + // client's visibilityState in the SW's perspective before the push arrives, + // so both signals can be simultaneously stale. // - // When matchAll() returns zero clients (a separate iOS Safari PWA quirk), - // visibility is unknowable — 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 = - clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; + // Pinging the client directly resolves this: a frozen/backgrounded page + // cannot respond within the timeout, so checkLiveVisibility returns false + // and the notification is shown correctly. console.debug( - '[SW push] appIsVisible:', + '[SW push] appIsVisible (diagnostic):', appIsVisible, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); - console.debug('[SW push] hasVisibleClient:', hasVisibleClient); - if (hasVisibleClient) { - console.debug('[SW push] suppressing OS notification — app is visible'); + const appLiveVisible = await checkLiveVisibility(clients); + console.debug('[SW push] live visibility check:', appLiveVisible); + if (appLiveVisible) { + console.debug('[SW push] suppressing OS notification — app confirmed visible'); return; } From 15f5707179c40195f536841f94467274c7d8ac32 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 16:37:00 -0400 Subject: [PATCH 05/24] fix(notifications): use matchAll visibilityState instead of live ping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The postMessage round-trip ping (checkLiveVisibility) introduced a new race: iOS can background the app without immediately freezing the JS thread, so the page can still respond 'visible' in the brief window before the freeze — causing the notification to be suppressed. client.visibilityState from clients.matchAll() is updated by the browser engine when the OS signals a visibility transition, independently of the page JS thread, making it immune to this race. When matchAll() returns zero clients (an iOS Safari PWA quirk) we default to showing the notification rather than silently dropping it. Removes checkLiveVisibility(), visibilityCheckPendingMap, the visibilityCheckResult message handler, and the checkVisibility handler in ClientNonUIFeatures. --- src/app/pages/client/ClientNonUIFeatures.tsx | 13 ---- src/sw.ts | 81 ++++---------------- 2 files changed, 13 insertions(+), 81 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f273ea916..d59c3178e 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -767,19 +767,6 @@ function HandleDecryptPushEvent() { const { data } = ev; if (!data) return; - // Respond to live visibility pings from the SW push handler. - // Using a live round-trip avoids false suppression when the page JS was - // frozen before visibilitychange could fire (an iOS Safari PWA quirk). - if (data.type === 'checkVisibility') { - const { id } = data as { id: string }; - navigator.serviceWorker.controller?.postMessage({ - type: 'visibilityCheckResult', - id, - visible: document.visibilityState === 'visible', - }); - return; - } - if (data.type !== 'decryptPushEvent') return; const { rawEvent } = data as { rawEvent: Record }; diff --git a/src/sw.ts b/src/sw.ts index 7fc6d5521..0ebfbc271 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -254,50 +254,6 @@ type DecryptionResult = { /** Pending decryption requests keyed by event_id. */ const decryptionPendingMap = new Map void>(); -/** Pending visibility check requests keyed by check ID. */ -const visibilityCheckPendingMap = new Map void>(); - -/** - * Ping each window client in sequence and return true if any confirm they are - * currently visible (document.visibilityState === 'visible'). If a client - * fails to respond within 500 ms its JS thread is likely frozen (iOS - * backgrounded-before-visibilitychange race) — treat that as not visible. - * - * This is more reliable than stale in-memory flags (appIsVisible) or - * clients.matchAll() visibilityState, both of which can be simultaneously - * stale when iOS freezes the WKWebView before any events fire. - */ -async function checkLiveVisibility(clients: readonly Client[]): Promise { - if (clients.length === 0) return false; - - return Array.from(clients).reduce>(async (prevPromise, client, idx) => { - const prev = await prevPromise; - if (prev) return true; - - const checkId = `vis-${Date.now()}-${idx}`; - - const promise = new Promise((resolve) => { - visibilityCheckPendingMap.set(checkId, resolve); - }); - - const timeout = new Promise((resolve) => { - setTimeout(() => { - visibilityCheckPendingMap.delete(checkId); - resolve(false); - }, 500); - }); - - try { - client.postMessage({ type: 'checkVisibility', id: checkId }); - } catch { - visibilityCheckPendingMap.delete(checkId); - return false; - } - - return Promise.race([promise, timeout]); - }, Promise.resolve(false)); -} - /** * Fetch a single raw Matrix event from the homeserver. * Returns undefined on error (e.g. network failure, auth error, redacted event). @@ -653,14 +609,6 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } } } - if (type === 'visibilityCheckResult') { - const { id, visible } = data as { id: string; visible: boolean }; - const resolve = visibilityCheckPendingMap.get(id); - if (resolve) { - visibilityCheckPendingMap.delete(id); - resolve(!!visible); - } - } if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; @@ -955,29 +903,26 @@ 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. // - // We do a live visibility ping rather than relying on stale in-memory state: - // - // • stale appIsVisible: if iOS freezes the WKWebView JS thread before the - // visibilitychange event fires, the page never sends setAppVisible=false, - // leaving appIsVisible stuck at true. - // - // • stale matchAll() visibilityState: iOS can also fail to update the - // client's visibilityState in the SW's perspective before the push arrives, - // so both signals can be simultaneously stale. + // Trust client.visibilityState from matchAll() directly: it is updated by the + // browser engine when the OS signals a visibility transition, independent of + // the page JS thread. The earlier postMessage ping approach was unreliable + // because iOS can background the app without freezing the JS thread immediately, + // allowing the page to respond "visible" in the brief window before the freeze. // - // Pinging the client directly resolves this: a frozen/backgrounded page - // cannot respond within the timeout, so checkLiveVisibility returns false - // and the notification is shown correctly. + // When matchAll() returns zero clients (an iOS Safari PWA quirk where the + // controlled client list is empty), we cannot determine visibility — default + // to showing the notification rather than silently dropping it. + const hasVisibleClient = + clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; console.debug( '[SW push] appIsVisible (diagnostic):', appIsVisible, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); - const appLiveVisible = await checkLiveVisibility(clients); - console.debug('[SW push] live visibility check:', appLiveVisible); - if (appLiveVisible) { - console.debug('[SW push] suppressing OS notification — app confirmed visible'); + console.debug('[SW push] hasVisibleClient:', hasVisibleClient); + if (hasVisibleClient) { + console.debug('[SW push] suppressing OS notification — app is visible'); return; } From 3c5d0870f0cd10cd294120ebd4eef49028d4ba30 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:36:42 -0400 Subject: [PATCH 06/24] feat(types): add experiment config, sessionSync types and useExperimentVariant to useClientConfig --- src/app/hooks/useClientConfig.ts | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e523f15a7..9538ca0bf 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -5,6 +5,31 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + +export type SessionSyncConfig = { + phase1ForegroundResync?: boolean; + phase2VisibleHeartbeat?: boolean; + phase3AdaptiveBackoffJitter?: boolean; + foregroundDebounceMs?: number; + heartbeatIntervalMs?: number; + resumeHeartbeatSuppressMs?: number; + heartbeatMaxBackoffMs?: number; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -14,6 +39,10 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + + sessionSync?: SessionSyncConfig; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -55,6 +84,72 @@ export function useClientConfig(): ClientConfig { return config; } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + const enabled = experiment?.enabled === true; + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex], + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; From 90f8716208cd6ae9555e3edd5b70ecb7e8d0c5bc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 02:11:09 -0400 Subject: [PATCH 07/24] fix(sw): require both visibility signals before suppressing push --- src/sw.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 0ebfbc271..8b3e62a20 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -903,24 +903,24 @@ 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. // - // Trust client.visibilityState from matchAll() directly: it is updated by the - // browser engine when the OS signals a visibility transition, independent of - // the page JS thread. The earlier postMessage ping approach was unreliable - // because iOS can background the app without freezing the JS thread immediately, - // allowing the page to respond "visible" in the brief window before the freeze. + // On iOS PWA, either signal can be stale around app background/lock transitions: + // - clients.matchAll() visibilityState can briefly lag. + // - setAppVisible can lag if the page is frozen before posting. // - // When matchAll() returns zero clients (an iOS Safari PWA quirk where the - // controlled client list is empty), we cannot determine visibility — default - // to showing the notification rather than silently dropping it. - const hasVisibleClient = + // Suppress only when both signals agree the app is visible. Disagreement is + // treated as background/unknown so we prefer showing a notification over + // accidentally dropping one. + const hasVisibleClientFromMatchAll = clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; + const hasVisibleClient = hasVisibleClientFromMatchAll && appIsVisible; console.debug( '[SW push] appIsVisible (diagnostic):', appIsVisible, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); - console.debug('[SW push] hasVisibleClient:', hasVisibleClient); + console.debug('[SW push] hasVisibleClientFromMatchAll:', hasVisibleClientFromMatchAll); + console.debug('[SW push] hasVisibleClient (combined):', hasVisibleClient); if (hasVisibleClient) { console.debug('[SW push] suppressing OS notification — app is visible'); return; From 5b3a0fb521d5ea4ed42188ec50752671aa546f78 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 08:26:35 -0400 Subject: [PATCH 08/24] fix(sw): expire appIsVisible after 45 s; use hasFocus + heartbeat to renew --- src/app/pages/client/ClientNonUIFeatures.tsx | 95 +++++++++++++++++--- src/sw.ts | 53 ++++++++--- 2 files changed, 124 insertions(+), 24 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index d59c3178e..80b3e4125 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 { useAtomValue, useSetAtom, useAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -21,7 +21,9 @@ import NotificationSound from '$public/sound/notification.ogg'; import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle'; import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; @@ -56,6 +58,7 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; +import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -336,7 +339,12 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { + if ( + !room || + isHistoricalEvent || + room.isSpaceRoom() || + !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined) + ) { return; } @@ -513,8 +521,12 @@ function MessageNotifications() { }); } - // In-app audio: play when notification sounds are enabled AND this notification is loud. - if (notificationSound && isLoud) { + // In-app audio: play when the app is in the foreground (has focus) and + // notification sounds are enabled for this notification type. + // Gating on hasFocus() rather than just visibilityState prevents a race + // where the page is still 'visible' for a brief window after the user + // backgrounds the app on mobile — hasFocus() flips false first. + if (notificationSound && isLoud && document.hasFocus()) { playSound(); } }; @@ -638,16 +650,48 @@ function SyncNotificationSettingsWithServiceWorker() { if (!('serviceWorker' in navigator)) return undefined; const postVisibility = () => { - const visible = document.visibilityState === 'visible'; + // Require both visibilityState === 'visible' AND document.hasFocus(). + // visibilityState alone misses desktop window minimize: Chrome/Edge do + // not reliably fire visibilitychange when a PWA window is minimized, so + // the state can stay 'visible' indefinitely. hasFocus() is false as soon + // as the window loses focus (minimize, or another window on top), which + // means the SW receives false promptly via the blur listener below. + const visible = document.visibilityState === 'visible' && document.hasFocus(); const msg = { type: 'setAppVisible', visible }; navigator.serviceWorker.controller?.postMessage(msg); navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; + const postHidden = () => { + // pagehide fires more reliably than visibilitychange on iOS Safari PWA + // when the user locks the screen or backgrounds the app quickly, making + // it less likely that the SW is left with a stale appIsVisible=true. + const msg = { type: 'setAppVisible', visible: false }; + navigator.serviceWorker.controller?.postMessage(msg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); + }; + + // Heartbeat: renew appIsVisible=true in the SW every 30 s while the app + // stays focused and visible. The SW expires the signal after 45 s, so the + // heartbeat ensures a genuinely open app is never incorrectly suppressed, + // while a frozen or backgrounded page lets the signal expire naturally. + const heartbeatId = setInterval(postVisibility, 30_000); + // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); - return () => document.removeEventListener('visibilitychange', postVisibility); + // blur fires when the window loses focus (minimize, another window on top). + // focus fires when the window regains focus. + window.addEventListener('focus', postVisibility); + window.addEventListener('blur', postHidden); + window.addEventListener('pagehide', postHidden); + return () => { + clearInterval(heartbeatId); + document.removeEventListener('visibilitychange', postVisibility); + window.removeEventListener('focus', postVisibility); + window.removeEventListener('blur', postHidden); + window.removeEventListener('pagehide', postHidden); + }; }, []); useEffect(() => { @@ -830,14 +874,39 @@ function HandleDecryptPushEvent() { function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const [autoIdled] = useAtom(presenceAutoIdledAtom); + const clientConfig = useClientConfig(); + const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0; + + usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { + // When auto-idled, broadcast as unavailable regardless of the configured mode. + const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); + // Effective broadcast state: honour effectiveMode when presence is on, otherwise offline. + // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg. + const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode; + const effectiveState = sendPresence ? activePresence : 'offline'; + const broadcasting = effectiveState !== 'offline'; + // Classic sync: set_presence query param on every /sync poll. // Passing undefined restores the default (online); Offline suppresses broadcasting. - mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); - // Sliding sync: enable/disable the presence extension on the next poll. + mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline); + // Sliding sync: keep the extension enabled so we always receive others' presence. + // Only disable it when the master sendPresence toggle is off (full privacy mode). getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); - }, [mx, sendPresence]); + // Explicitly PUT /presence/{userId}/status so the server knows the exact state: + // - MSC4186 servers that have no presence extension see this immediately. + // - When 'offline' (Invisible mode), we appear offline to others but still receive + // their presence events because the extension is still enabled above. + mx.setPresence({ + presence: effectiveState, + status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', + }).catch(() => { + // Server doesn't support presence — ignore. + }); + }, [mx, sendPresence, presenceMode, autoIdled]); return null; } @@ -847,11 +916,17 @@ function SettingsSyncFeature() { return null; } +function BookmarksFeature() { + useInitBookmarks(); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> + diff --git a/src/sw.ts b/src/sw.ts index 8b3e62a20..a38f23304 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -8,10 +8,19 @@ export type {}; 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. +// Tracks whether a page client has reported itself as visible via postMessage. +// Used alongside clients.matchAll() to require both signals to agree before +// suppressing push notifications — prevents over-suppression during the brief +// window after backgrounding where matchAll()'s visibilityState may lag behind +// the page's own visibilitychange event. +// +// appIsVisibleAt records the last time appIsVisible was set to true. The signal +// is treated as stale after APP_VISIBLE_TTL_MS — the page renews it via a +// heartbeat every 30 s so a genuinely open app is always fresh, while a frozen +// or backgrounded page naturally lets it expire. let appIsVisible = false; +let appIsVisibleAt = 0; +const APP_VISIBLE_TTL_MS = 45_000; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; @@ -512,8 +521,10 @@ async function handleMinimalPushPayload( ? await requestDecryptionFromClient(windowClients, rawEvent) : undefined; - // If the relay responded and the app is currently visible, the in-app UI is already - // displaying the message — skip the OS notification entirely. + // If the relay responded and indicates the app is currently visible, the + // in-app UI is already displaying the message — skip the OS notification. + // result.visibilityState comes from a live postMessage round-trip, so it + // reflects the page's actual current state (not stale in-memory flags). if (result?.visibilityState === 'visible') return; if (result?.success) { @@ -612,6 +623,7 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; + if (appIsVisible) appIsVisibleAt = Date.now(); } } if (type === 'setNotificationSettings') { @@ -903,26 +915,39 @@ 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. // - // On iOS PWA, either signal can be stale around app background/lock transitions: - // - clients.matchAll() visibilityState can briefly lag. - // - setAppVisible can lag if the page is frozen before posting. + // Visibility is determined by two independent signals that must both agree: // - // Suppress only when both signals agree the app is visible. Disagreement is - // treated as background/unknown so we prefer showing a notification over - // accidentally dropping one. + // 1. clients.matchAll() visibilityState — direct SW view of page state. + // Can lag briefly on iOS after backgrounding. + // + // 2. appIsVisible + freshness — page-reported signal via postMessage. + // The page sets this true when focused+visible and renews it every 30 s + // (heartbeat). The SW treats it stale after APP_VISIBLE_TTL_MS (45 s) so + // a frozen/backgrounded page that can't send the 'false' message is + // self-healing. Also covers desktop minimize: Chrome/Edge don't always + // fire visibilitychange on minimize, but the window reliably loses focus + // (blur), which the page uses to report false immediately. + // + // Disagreement between the two signals is treated as background/unknown — + // prefer showing a notification over accidentally dropping one. const hasVisibleClientFromMatchAll = clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; - const hasVisibleClient = hasVisibleClientFromMatchAll && appIsVisible; + const appVisibleAndFresh = appIsVisible && Date.now() - appIsVisibleAt < APP_VISIBLE_TTL_MS; + const hasVisibleClient = hasVisibleClientFromMatchAll && appVisibleAndFresh; console.debug( - '[SW push] appIsVisible (diagnostic):', + '[SW push] appIsVisible:', appIsVisible, + '| fresh:', + appVisibleAndFresh, + '| age ms:', + appIsVisibleAt ? Date.now() - appIsVisibleAt : 'never', '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); console.debug('[SW push] hasVisibleClientFromMatchAll:', hasVisibleClientFromMatchAll); console.debug('[SW push] hasVisibleClient (combined):', hasVisibleClient); if (hasVisibleClient) { - console.debug('[SW push] suppressing OS notification — app is visible'); + console.debug('[SW push] suppressing OS notification — app is visible and fresh'); return; } From f79b75e00bb963d1e16dada3db23c7d9f0977748 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 08:37:50 -0400 Subject: [PATCH 09/24] revert(sw): remove appIsVisible signaling; rely solely on clients.matchAll() visibilityState --- src/app/pages/client/ClientNonUIFeatures.tsx | 48 ------------------ src/sw.ts | 51 ++------------------ 2 files changed, 4 insertions(+), 95 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 80b3e4125..c3626da87 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -646,54 +646,6 @@ function SyncNotificationSettingsWithServiceWorker() { ); const [clearNotificationsOnRead] = useSetting(settingsAtom, 'clearNotificationsOnRead'); - useEffect(() => { - if (!('serviceWorker' in navigator)) return undefined; - - const postVisibility = () => { - // Require both visibilityState === 'visible' AND document.hasFocus(). - // visibilityState alone misses desktop window minimize: Chrome/Edge do - // not reliably fire visibilitychange when a PWA window is minimized, so - // the state can stay 'visible' indefinitely. hasFocus() is false as soon - // as the window loses focus (minimize, or another window on top), which - // means the SW receives false promptly via the blur listener below. - const visible = document.visibilityState === 'visible' && document.hasFocus(); - const msg = { type: 'setAppVisible', visible }; - navigator.serviceWorker.controller?.postMessage(msg); - navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); - }; - - const postHidden = () => { - // pagehide fires more reliably than visibilitychange on iOS Safari PWA - // when the user locks the screen or backgrounds the app quickly, making - // it less likely that the SW is left with a stale appIsVisible=true. - const msg = { type: 'setAppVisible', visible: false }; - navigator.serviceWorker.controller?.postMessage(msg); - navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); - }; - - // Heartbeat: renew appIsVisible=true in the SW every 30 s while the app - // stays focused and visible. The SW expires the signal after 45 s, so the - // heartbeat ensures a genuinely open app is never incorrectly suppressed, - // while a frozen or backgrounded page lets the signal expire naturally. - const heartbeatId = setInterval(postVisibility, 30_000); - - // Report initial visibility immediately, then track changes. - postVisibility(); - document.addEventListener('visibilitychange', postVisibility); - // blur fires when the window loses focus (minimize, another window on top). - // focus fires when the window regains focus. - window.addEventListener('focus', postVisibility); - window.addEventListener('blur', postHidden); - window.addEventListener('pagehide', postHidden); - return () => { - clearInterval(heartbeatId); - document.removeEventListener('visibilitychange', postVisibility); - window.removeEventListener('focus', postVisibility); - window.removeEventListener('blur', postHidden); - window.removeEventListener('pagehide', postHidden); - }; - }, []); - useEffect(() => { if (!('serviceWorker' in navigator)) return; // notificationSoundEnabled is intentionally excluded: push notification sound diff --git a/src/sw.ts b/src/sw.ts index a38f23304..1bc3c9093 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -8,19 +8,6 @@ export type {}; declare const self: ServiceWorkerGlobalScope; let notificationSoundEnabled = true; -// Tracks whether a page client has reported itself as visible via postMessage. -// Used alongside clients.matchAll() to require both signals to agree before -// suppressing push notifications — prevents over-suppression during the brief -// window after backgrounding where matchAll()'s visibilityState may lag behind -// the page's own visibilitychange event. -// -// appIsVisibleAt records the last time appIsVisible was set to true. The signal -// is treated as stale after APP_VISIBLE_TTL_MS — the page renews it via a -// heartbeat every 30 s so a genuinely open app is always fresh, while a frozen -// or backgrounded page naturally lets it expire. -let appIsVisible = false; -let appIsVisibleAt = 0; -const APP_VISIBLE_TTL_MS = 45_000; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; @@ -620,12 +607,6 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } } } - if (type === 'setAppVisible') { - if (typeof (data as { visible?: unknown }).visible === 'boolean') { - appIsVisible = (data as { visible: boolean }).visible; - if (appIsVisible) appIsVisibleAt = Date.now(); - } - } if (type === 'setNotificationSettings') { if ( typeof (data as { notificationSoundEnabled?: unknown }).notificationSoundEnabled === 'boolean' @@ -914,40 +895,16 @@ 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. - // - // Visibility is determined by two independent signals that must both agree: - // - // 1. clients.matchAll() visibilityState — direct SW view of page state. - // Can lag briefly on iOS after backgrounding. - // - // 2. appIsVisible + freshness — page-reported signal via postMessage. - // The page sets this true when focused+visible and renews it every 30 s - // (heartbeat). The SW treats it stale after APP_VISIBLE_TTL_MS (45 s) so - // a frozen/backgrounded page that can't send the 'false' message is - // self-healing. Also covers desktop minimize: Chrome/Edge don't always - // fire visibilitychange on minimize, but the window reliably loses focus - // (blur), which the page uses to report false immediately. - // - // Disagreement between the two signals is treated as background/unknown — - // prefer showing a notification over accidentally dropping one. - const hasVisibleClientFromMatchAll = + const hasVisibleClient = clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; - const appVisibleAndFresh = appIsVisible && Date.now() - appIsVisibleAt < APP_VISIBLE_TTL_MS; - const hasVisibleClient = hasVisibleClientFromMatchAll && appVisibleAndFresh; console.debug( - '[SW push] appIsVisible:', - appIsVisible, - '| fresh:', - appVisibleAndFresh, - '| age ms:', - appIsVisibleAt ? Date.now() - appIsVisibleAt : 'never', + '[SW push] hasVisibleClient:', + hasVisibleClient, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); - console.debug('[SW push] hasVisibleClientFromMatchAll:', hasVisibleClientFromMatchAll); - console.debug('[SW push] hasVisibleClient (combined):', hasVisibleClient); if (hasVisibleClient) { - console.debug('[SW push] suppressing OS notification — app is visible and fresh'); + console.debug('[SW push] suppressing OS notification — app is visible'); return; } From b8d56ac3155d52f8df7ecfbcb175af2e5543b13a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 09:36:42 -0400 Subject: [PATCH 10/24] fix(notifications): restore appIsVisible flag and setAppVisible SW handler Restores the dual-signal visibility check in the service worker (appIsVisible flag OR clients.matchAll visibilityState) and the setAppVisible message handler. Also restores the visibilitychange listener in ClientNonUIFeatures that posts visibility state to the SW. These were removed in f79b75e0 which broke background notification delivery, particularly on iOS Safari where clients.matchAll() can return stale results after SW suspension. --- src/app/pages/client/ClientNonUIFeatures.tsx | 16 ++++++++++++++++ src/sw.ts | 19 ++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c3626da87..c516593d1 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -646,6 +646,22 @@ function SyncNotificationSettingsWithServiceWorker() { ); const [clearNotificationsOnRead] = useSetting(settingsAtom, 'clearNotificationsOnRead'); + useEffect(() => { + if (!('serviceWorker' in navigator)) return undefined; + + const postVisibility = () => { + const visible = document.visibilityState === 'visible'; + const msg = { type: 'setAppVisible', visible }; + navigator.serviceWorker.controller?.postMessage(msg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); + }; + + // Report initial visibility immediately, then track changes. + postVisibility(); + document.addEventListener('visibilitychange', postVisibility); + return () => document.removeEventListener('visibilitychange', postVisibility); + }, []); + useEffect(() => { if (!('serviceWorker' in navigator)) return; // notificationSoundEnabled is intentionally excluded: push notification sound diff --git a/src/sw.ts b/src/sw.ts index 1bc3c9093..9cf683814 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -11,6 +11,11 @@ let notificationSoundEnabled = true; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; + +/** Explicit visibility flag posted by the app via setAppVisible messages. + * Combines with clients.matchAll() in the push handler because iOS Safari PWA + * often returns empty or stale results from matchAll(). */ +let appIsVisible = false; const { handlePushNotificationPushData } = createPushNotifications(self, () => ({ showMessageContent, showEncryptedMessageContent, @@ -607,6 +612,11 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } } } + if (type === 'setAppVisible') { + if (typeof (data as { visible?: unknown }).visible === 'boolean') { + appIsVisible = (data as { visible: boolean }).visible; + } + } if (type === 'setNotificationSettings') { if ( typeof (data as { notificationSoundEnabled?: unknown }).notificationSoundEnabled === 'boolean' @@ -895,14 +905,17 @@ 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(). const hasVisibleClient = - clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; + appIsVisible || clients.some((client) => client.visibilityState === 'visible'); console.debug( - '[SW push] hasVisibleClient:', - hasVisibleClient, + '[SW push] appIsVisible:', + appIsVisible, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); + console.debug('[SW push] hasVisibleClient:', hasVisibleClient); if (hasVisibleClient) { console.debug('[SW push] suppressing OS notification — app is visible'); return; From a0a6140a0aa2aa12eaeeafbd9dcdf44d2d4392f1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 09:52:44 -0400 Subject: [PATCH 11/24] fix: convert appEvents to multi-subscriber pattern and cancel retry timers 1. appEvents.ts: Replace single-callback onVisibilityChange/onVisibilityHidden slots with Set-based multi-subscriber pattern. Subscriptions return an unsubscribe function, preventing silent overwrites. 2. useAppVisibility.ts: Update to use emitVisibilityChange/emitVisibilityHidden for dispatching and onVisibilityChange() subscription for togglePusher. 3. BackgroundNotifications.tsx: Track retry setTimeout IDs in a Set and cancel them on effect cleanup, preventing orphaned background clients on unmount. --- src/app/hooks/useAppVisibility.ts | 11 +++----- .../pages/client/BackgroundNotifications.tsx | 7 ++++- src/app/utils/appEvents.ts | 28 +++++++++++++++++-- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index ed2d69cfb..ea487635a 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -115,9 +115,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); - appEvents.onVisibilityChange?.(isVisible); + appEvents.emitVisibilityChange(isVisible); if (!isVisible) { - appEvents.onVisibilityHidden?.(); + appEvents.emitVisibilityHidden(); return; } @@ -186,11 +186,8 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); }; - appEvents.onVisibilityChange = handleVisibilityForNotifications; - // eslint-disable-next-line consistent-return - return () => { - appEvents.onVisibilityChange = null; - }; + const unsubscribe = appEvents.onVisibilityChange(handleVisibilityForNotifications); + return unsubscribe; }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); useEffect(() => { diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 395718223..17aabc595 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -171,6 +171,7 @@ export function BackgroundNotifications() { const { current } = clientsRef; const activeIds = new Set(inactiveSessions.map((s) => s.userId)); + const pendingRetryTimers = new Set>(); async function sendNotification(opts: NotifyOptions): Promise { // Prefer ServiceWorkerRegistration.showNotification so that taps are handled @@ -522,7 +523,8 @@ export function BackgroundNotifications() { // Retry with exponential backoff, up to 5 attempts (5s, 10s, 20s, 40s, 60s cap). if (attempt < 5) { const retryDelay = Math.min(5_000 * 2 ** attempt, 60_000); - setTimeout(() => { + const timerId = setTimeout(() => { + pendingRetryTimers.delete(timerId); const latestSession = inactiveSessionsRef.current.find( (s) => s.userId === session.userId ); @@ -530,6 +532,7 @@ export function BackgroundNotifications() { startSession(latestSession, attempt + 1); } }, retryDelay); + pendingRetryTimers.add(timerId); } }); }; @@ -539,6 +542,8 @@ export function BackgroundNotifications() { }); return () => { + pendingRetryTimers.forEach((id) => clearTimeout(id)); + pendingRetryTimers.clear(); // Reading ref.current in cleanup is intentional - we want cleanup functions // that were registered during async startBackgroundClient operations // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2834c5b6f..f96c016cb 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,5 +1,29 @@ +type VisibilityChangeHandler = (isVisible: boolean) => void; +type VisibilityHiddenHandler = () => void; + +const visibilityChangeHandlers = new Set(); +const visibilityHiddenHandlers = new Set(); + export const appEvents = { - onVisibilityHidden: null as (() => void) | null, + onVisibilityHidden(handler: VisibilityHiddenHandler): () => void { + visibilityHiddenHandlers.add(handler); + return () => { + visibilityHiddenHandlers.delete(handler); + }; + }, + + emitVisibilityHidden(): void { + visibilityHiddenHandlers.forEach((h) => h()); + }, + + onVisibilityChange(handler: VisibilityChangeHandler): () => void { + visibilityChangeHandlers.add(handler); + return () => { + visibilityChangeHandlers.delete(handler); + }; + }, - onVisibilityChange: null as ((isVisible: boolean) => void) | null, + emitVisibilityChange(isVisible: boolean): void { + visibilityChangeHandlers.forEach((h) => h(isVisible)); + }, }; From 350a54ae8cfc8f2c34150a30e6a73b22ce3b315f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 10:52:13 -0400 Subject: [PATCH 12/24] fix: address PR #671 review comments + add controllerchange handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sw.ts: add type validation in loadPersistedSession before accessing fields - sw.ts: remove access token leak from debug log - sw.ts: replace Promise.race with Promise.all+find in handleMinimalPushPayload to avoid returning undefined from first fast-failing client - sw.ts: fix misleading comment about preloadedSession/getAnyStoredSession - sw.ts: add ?? [] fallback for precacheAndRoute(self.__WB_MANIFEST) - ClientRoot: pass activeSession to useAppVisibility - index.tsx: add controllerchange listener to re-push session when SW updates via skipWaiting — fixes notifications stopping after SW replacement --- src/app/hooks/useAppVisibility.ts | 2 +- src/app/pages/client/ClientRoot.tsx | 2 +- src/index.tsx | 8 ++++++++ src/sw.ts | 20 +++++++++++--------- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index ea487635a..244fb0833 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -180,7 +180,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S ]); useEffect(() => { - if (!mx) return; + if (!mx) return undefined; const handleVisibilityForNotifications = (isVisible: boolean) => { togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1a653e950..0d41d5ecf 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -254,7 +254,7 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncNicknames(mx); useLogoutListener(mx); - useAppVisibility(mx); + useAppVisibility(mx, activeSession); useEffect( () => () => { diff --git a/src/index.tsx b/src/index.tsx index 4f2e57245..c342852b5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -93,6 +93,14 @@ if ('serviceWorker' in navigator) { log.warn('SW ready failed:', err); }); + // When the SW updates (skipWaiting + clients.claim), the old SW is killed and + // the new one has an empty sessions Map. Re-push the session immediately so + // push notifications and authenticated media fetches keep working. + navigator.serviceWorker.addEventListener('controllerchange', () => { + log.log('SW controller changed — re-sending session'); + sendSessionToSW(); + }); + navigator.serviceWorker.addEventListener('message', (ev) => { const { data } = ev; if (!data || typeof data !== 'object') return; diff --git a/src/sw.ts b/src/sw.ts index 9cf683814..fa6c70428 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -106,13 +106,15 @@ async function loadPersistedSession(): Promise { // rejected and requestSession had no live window client to reach. // If the token truly is revoked the fetches in handleMinimalPushPayload will // receive a 401 and gracefully fall back to a generic notification anyway. + if (typeof s.accessToken !== 'string' || typeof s.baseUrl !== 'string') { + console.debug('[SW] loadPersistedSession: invalid cached session (missing fields)'); + return undefined; + } + const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours if (age > MAX_SESSION_AGE_MS) { - console.debug('[SW] loadPersistedSession: session expired', { - age, - accessToken: s.accessToken.slice(0, 8), - }); + console.debug('[SW] loadPersistedSession: session expired', { age }); return undefined; } @@ -444,10 +446,10 @@ async function handleMinimalPushPayload( let session = getAnyStoredSession() ?? (await loadPersistedSession()); if (!session && windowClients.length > 0) { console.debug('[SW push] no cached session, requesting from window clients'); - const result = await Promise.race( + const results = await Promise.all( Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) ); - session = result ?? undefined; + session = results.find((r) => r != null) ?? undefined; } if (!session) { @@ -892,8 +894,8 @@ const onPushNotification = async (event: PushEvent) => { // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. // Capture the persisted session result into preloadedSession so that - // getAnyStoredSession() returns it in handleMinimalPushPayload without a - // second cache read. + // handleMinimalPushPayload and media fetch handlers can use it as a + // fallback without a second cache read. const [, persistedSession, clients] = await Promise.all([ loadPersistedSettings(), loadPersistedSession(), @@ -1058,5 +1060,5 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { ); }); -precacheAndRoute(self.__WB_MANIFEST); +precacheAndRoute(self.__WB_MANIFEST ?? []); cleanupOutdatedCaches(); From a78e989cd5d937b9a5b98f3a72fe89b60d92e0e1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 18:24:34 -0400 Subject: [PATCH 13/24] fix(sw): replace stale visibility flags with live client ping iOS PWA freezes the page thread before visibilitychange fires, leaving appIsVisible stuck at true and suppressing push notifications. Replace the unreliable OR of appIsVisible / matchAll().visibilityState with a live checkVisibility round-trip: the SW posts a ping to every window client and only suppresses if a client confirms visible within 500 ms. Frozen or killed pages cannot respond, so the timeout resolves false and the OS notification fires correctly. --- src/app/pages/client/ClientNonUIFeatures.tsx | 18 ++++- src/sw.ts | 73 +++++++++++++++++--- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c516593d1..3239ca39b 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -656,10 +656,26 @@ function SyncNotificationSettingsWithServiceWorker() { navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; + // Respond to live visibility pings from the SW push handler. + const handleSWMessage = (ev: MessageEvent) => { + if (ev.data?.type === 'checkVisibility' && typeof ev.data.seq === 'number') { + const visible = document.visibilityState === 'visible'; + navigator.serviceWorker.controller?.postMessage({ + type: 'visibilityCheckResult', + seq: ev.data.seq, + visible, + }); + } + }; + // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); - return () => document.removeEventListener('visibilitychange', postVisibility); + navigator.serviceWorker.addEventListener('message', handleSWMessage); + return () => { + document.removeEventListener('visibilitychange', postVisibility); + navigator.serviceWorker.removeEventListener('message', handleSWMessage); + }; }, []); useEffect(() => { diff --git a/src/sw.ts b/src/sw.ts index fa6c70428..98e01fbaa 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -13,9 +13,52 @@ let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; /** Explicit visibility flag posted by the app via setAppVisible messages. - * Combines with clients.matchAll() in the push handler because iOS Safari PWA - * often returns empty or stale results from matchAll(). */ + * Used only as a fast-path hint; the push handler verifies with a live ping. */ let appIsVisible = false; + +// --------------------------------------------------------------------------- +// Live visibility check — actively probes window clients so the push handler +// doesn't rely on stale in-memory flags or matchAll() data (both are unreliable +// on iOS Safari PWA, which can freeze the page before the setAppVisible message +// is delivered). +// --------------------------------------------------------------------------- +const visibilityCheckPendingMap = new Map void>(); +let visibilityCheckSeq = 0; + +/** + * Post a checkVisibility message to every window client and resolve `true` if + * any client confirms it is currently visible within `timeoutMs`. + */ +async function checkLiveVisibility( + windowClients: readonly Client[], + timeoutMs = 500 +): Promise { + if (windowClients.length === 0) return false; + + visibilityCheckSeq += 1; + const seq = visibilityCheckSeq; + + const promise = new Promise((resolve) => { + visibilityCheckPendingMap.set(seq, resolve); + + setTimeout(() => { + if (visibilityCheckPendingMap.delete(seq)) { + resolve(false); + } + }, timeoutMs); + }); + + Array.from(windowClients).forEach((client) => { + try { + client.postMessage({ type: 'checkVisibility', seq }); + } catch { + // Client may have been killed — ignore. + } + }); + + return promise; +} + const { handlePushNotificationPushData } = createPushNotifications(self, () => ({ showMessageContent, showEncryptedMessageContent, @@ -614,6 +657,16 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } } } + if (type === 'visibilityCheckResult') { + const { seq, visible } = data as { seq?: number; visible?: boolean }; + if (typeof seq === 'number' && visible === true) { + const resolve = visibilityCheckPendingMap.get(seq); + if (resolve) { + visibilityCheckPendingMap.delete(seq); + resolve(true); + } + } + } if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; @@ -906,18 +959,20 @@ 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(). - const hasVisibleClient = - appIsVisible || clients.some((client) => client.visibilityState === 'visible'); + // notification handles the alert instead. + // Neither the in-memory appIsVisible flag nor clients.matchAll() visibilityState + // are reliable on iOS Safari PWA: iOS can freeze the page before the setAppVisible + // message is delivered, and matchAll() can return stale visibility states. + // Instead, actively ping window clients and wait up to 500 ms for a response. + const hasVisibleClient = await checkLiveVisibility(clients); console.debug( - '[SW push] appIsVisible:', + '[SW push] liveVisibility:', + hasVisibleClient, + '| appIsVisible (hint):', appIsVisible, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); - console.debug('[SW push] hasVisibleClient:', hasVisibleClient); if (hasVisibleClient) { console.debug('[SW push] suppressing OS notification — app is visible'); return; From 9c6baad151c54bdb09d7d9c43ebe6723c8d9427d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 23:57:02 -0400 Subject: [PATCH 14/24] fix: remove presence-auto-idle and bookmarks imports that don't exist on this branch Removes imports and usages of usePresenceAutoIdle, presenceAutoIdledAtom, useInitBookmarks, presenceMode setting, and presenceAutoIdleTimeoutMs config that were accidentally merged from other feature branches but don't exist on fix/sw-push-session-recovery. Restores PresenceFeature to upstream dev shape. --- src/app/pages/client/ClientNonUIFeatures.tsx | 44 +++----------------- 1 file changed, 5 insertions(+), 39 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 3239ca39b..d43ba5779 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom, useAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -21,9 +21,7 @@ import NotificationSound from '$public/sound/notification.ogg'; import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; -import { useClientConfig } from '$hooks/useClientConfig'; -import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle'; +import { settingsAtom } from '$state/settings'; import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; @@ -58,7 +56,6 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; -import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -858,39 +855,14 @@ function HandleDecryptPushEvent() { function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); - const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); - const [autoIdled] = useAtom(presenceAutoIdledAtom); - const clientConfig = useClientConfig(); - const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0; - - usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { - // When auto-idled, broadcast as unavailable regardless of the configured mode. - const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); - // Effective broadcast state: honour effectiveMode when presence is on, otherwise offline. - // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg. - const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode; - const effectiveState = sendPresence ? activePresence : 'offline'; - const broadcasting = effectiveState !== 'offline'; - // Classic sync: set_presence query param on every /sync poll. // Passing undefined restores the default (online); Offline suppresses broadcasting. - mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline); - // Sliding sync: keep the extension enabled so we always receive others' presence. - // Only disable it when the master sendPresence toggle is off (full privacy mode). + mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); + // Sliding sync: enable/disable the presence extension on the next poll. getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); - // Explicitly PUT /presence/{userId}/status so the server knows the exact state: - // - MSC4186 servers that have no presence extension see this immediately. - // - When 'offline' (Invisible mode), we appear offline to others but still receive - // their presence events because the extension is still enabled above. - mx.setPresence({ - presence: effectiveState, - status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', - }).catch(() => { - // Server doesn't support presence — ignore. - }); - }, [mx, sendPresence, presenceMode, autoIdled]); + }, [mx, sendPresence]); return null; } @@ -900,17 +872,11 @@ function SettingsSyncFeature() { return null; } -function BookmarksFeature() { - useInitBookmarks(); - return null; -} - export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> - From b80b7081830afdb3ffa2261fd775e8bde6e07e42 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 00:45:28 -0400 Subject: [PATCH 15/24] revert(sw): replace live visibility ping with upstream appIsVisible+matchAll The checkLiveVisibility approach (postMessage ping with 500ms timeout) was causing false-positive suppression on iOS: the push event itself can briefly wake a suspended page, allowing it to respond with visibilityState='visible' even when the user is not looking at the app. This caused background notifications to silently stop after a period of inactivity. Revert to upstream/dev's approach: OR of appIsVisible flag (set via visibilitychange listener) and clients.matchAll() visibilityState. Remove the checkLiveVisibility function, visibilityCheckPendingMap, and the client-side checkVisibility responder. --- src/app/pages/client/ClientNonUIFeatures.tsx | 18 +---- src/sw.ts | 73 +++----------------- 2 files changed, 10 insertions(+), 81 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index d43ba5779..0b2b74f65 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -653,26 +653,10 @@ function SyncNotificationSettingsWithServiceWorker() { navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; - // Respond to live visibility pings from the SW push handler. - const handleSWMessage = (ev: MessageEvent) => { - if (ev.data?.type === 'checkVisibility' && typeof ev.data.seq === 'number') { - const visible = document.visibilityState === 'visible'; - navigator.serviceWorker.controller?.postMessage({ - type: 'visibilityCheckResult', - seq: ev.data.seq, - visible, - }); - } - }; - // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); - navigator.serviceWorker.addEventListener('message', handleSWMessage); - return () => { - document.removeEventListener('visibilitychange', postVisibility); - navigator.serviceWorker.removeEventListener('message', handleSWMessage); - }; + return () => document.removeEventListener('visibilitychange', postVisibility); }, []); useEffect(() => { diff --git a/src/sw.ts b/src/sw.ts index 98e01fbaa..c2e3997c6 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -12,53 +12,10 @@ let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; -/** Explicit visibility flag posted by the app via setAppVisible messages. - * Used only as a fast-path hint; the push handler verifies with a live ping. */ +// Tracks whether a page client has reported itself as visible. +// Combines with clients.matchAll() in the push handler because iOS Safari PWA +// often returns empty or stale results from matchAll(). let appIsVisible = false; - -// --------------------------------------------------------------------------- -// Live visibility check — actively probes window clients so the push handler -// doesn't rely on stale in-memory flags or matchAll() data (both are unreliable -// on iOS Safari PWA, which can freeze the page before the setAppVisible message -// is delivered). -// --------------------------------------------------------------------------- -const visibilityCheckPendingMap = new Map void>(); -let visibilityCheckSeq = 0; - -/** - * Post a checkVisibility message to every window client and resolve `true` if - * any client confirms it is currently visible within `timeoutMs`. - */ -async function checkLiveVisibility( - windowClients: readonly Client[], - timeoutMs = 500 -): Promise { - if (windowClients.length === 0) return false; - - visibilityCheckSeq += 1; - const seq = visibilityCheckSeq; - - const promise = new Promise((resolve) => { - visibilityCheckPendingMap.set(seq, resolve); - - setTimeout(() => { - if (visibilityCheckPendingMap.delete(seq)) { - resolve(false); - } - }, timeoutMs); - }); - - Array.from(windowClients).forEach((client) => { - try { - client.postMessage({ type: 'checkVisibility', seq }); - } catch { - // Client may have been killed — ignore. - } - }); - - return promise; -} - const { handlePushNotificationPushData } = createPushNotifications(self, () => ({ showMessageContent, showEncryptedMessageContent, @@ -657,16 +614,6 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } } } - if (type === 'visibilityCheckResult') { - const { seq, visible } = data as { seq?: number; visible?: boolean }; - if (typeof seq === 'number' && visible === true) { - const resolve = visibilityCheckPendingMap.get(seq); - if (resolve) { - visibilityCheckPendingMap.delete(seq); - resolve(true); - } - } - } if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; @@ -960,19 +907,17 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // notification handles the alert instead. - // Neither the in-memory appIsVisible flag nor clients.matchAll() visibilityState - // are reliable on iOS Safari PWA: iOS can freeze the page before the setAppVisible - // message is delivered, and matchAll() can return stale visibility states. - // Instead, actively ping window clients and wait up to 500 ms for a response. - const hasVisibleClient = await checkLiveVisibility(clients); + // Combine clients.matchAll() visibility with the explicit appIsVisible flag + // because iOS Safari PWA often returns empty or stale results from matchAll(). + const hasVisibleClient = + appIsVisible || clients.some((client) => client.visibilityState === 'visible'); console.debug( - '[SW push] liveVisibility:', - hasVisibleClient, - '| appIsVisible (hint):', + '[SW push] appIsVisible:', appIsVisible, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); + console.debug('[SW push] hasVisibleClient:', hasVisibleClient); if (hasVisibleClient) { console.debug('[SW push] suppressing OS notification — app is visible'); return; From 808a6542051f1081ad3c18fc709c9761d7bcbcc0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:07:07 -0400 Subject: [PATCH 16/24] fix(sw): address review feedback for push session recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix preloadedSession comment: only media fetch handlers use it, not handleMinimalPushPayload - Fix changeset frontmatter: '@sable/client': patch → default: patch --- .changeset/sw-push-session-recovery.md | 2 +- src/sw.ts | 3 +-- test.txt | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 test.txt diff --git a/.changeset/sw-push-session-recovery.md b/.changeset/sw-push-session-recovery.md index 625947009..646fefbdf 100644 --- a/.changeset/sw-push-session-recovery.md +++ b/.changeset/sw-push-session-recovery.md @@ -1,5 +1,5 @@ --- -'@sable/client': patch +default: patch --- fix(sw): improve push session recovery by increasing TTL, adding timeout fallback, and resetting heartbeat backoff on foreground sync diff --git a/src/sw.ts b/src/sw.ts index c2e3997c6..06b90aa8a 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -894,8 +894,7 @@ const onPushNotification = async (event: PushEvent) => { // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. // Capture the persisted session result into preloadedSession so that - // handleMinimalPushPayload and media fetch handlers can use it as a - // fallback without a second cache read. + // media fetch handlers can use it as a fallback without a second cache read. const [, persistedSession, clients] = await Promise.all([ loadPersistedSettings(), loadPersistedSession(), diff --git a/test.txt b/test.txt new file mode 100644 index 000000000..9daeafb98 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +test From 5f963cd79dc736056ca99309f55c2080a9e2b3be Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:07:16 -0400 Subject: [PATCH 17/24] chore: remove accidentally committed test.txt --- test.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test.txt diff --git a/test.txt b/test.txt deleted file mode 100644 index 9daeafb98..000000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -test From a5f036e20fe073f73a1172146fdb80ef894ac631 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:33:47 -0400 Subject: [PATCH 18/24] refactor: use mx.getUserId() instead of activeSession param in useAppVisibility Removes the Session dependency from useAppVisibility by deriving the userId directly from the MatrixClient instance. --- src/app/hooks/useAppVisibility.ts | 5 ++--- src/app/pages/client/ClientRoot.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 244fb0833..8a15d1284 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; -import { Session } from '$state/sessions'; import { useAtom } from 'jotai'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; @@ -19,14 +18,14 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; -export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { +export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = mobileOrTablet(); const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); + const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', mx?.getUserId() ?? undefined); // Derive phase flags from experiment variant; fall back to direct config when not in experiment. const inSessionSync = sessionSyncVariant.inExperiment; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 0d41d5ecf..1a653e950 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -254,7 +254,7 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncNicknames(mx); useLogoutListener(mx); - useAppVisibility(mx, activeSession); + useAppVisibility(mx); useEffect( () => () => { From bb8b35d18a8170ecfcf4e1f62191bf3ae1c17df4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 09:06:39 -0400 Subject: [PATCH 19/24] fix: kick sliding sync on foreground return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retryImmediately() is a no-op on SlidingSyncSdk — it returns true without touching the polling loop. Call slidingSync.resend() on foreground/focus to abort a stale long-poll and start a fresh one. Also fixes activeSession references that should use mx methods (getHomeserverUrl/getAccessToken/getUserId). --- src/app/hooks/useAppVisibility.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 8a15d1284..0fefeff22 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; import { useAtom } from 'jotai'; +import { getSlidingSyncManager } from '$client/initMatrix'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; import { useClientConfig, useExperimentVariant } from './useClientConfig'; @@ -25,7 +26,10 @@ export function useAppVisibility(mx: MatrixClient | undefined) { const isMobile = mobileOrTablet(); const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', mx?.getUserId() ?? undefined); + const sessionSyncVariant = useExperimentVariant( + 'sessionSyncStrategy', + mx?.getUserId() ?? undefined + ); // Derive phase flags from experiment variant; fall back to direct config when not in experiment. const inSessionSync = sessionSyncVariant.inExperiment; @@ -63,9 +67,9 @@ export function useAppVisibility(mx: MatrixClient | undefined) { const pushSessionNow = useCallback( (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { - const baseUrl = activeSession?.baseUrl; - const accessToken = activeSession?.accessToken; - const userId = activeSession?.userId; + const baseUrl = mx?.getHomeserverUrl(); + const accessToken = mx?.getAccessToken(); + const userId = mx?.getUserId(); const canPush = !!mx && typeof baseUrl === 'string' && @@ -95,15 +99,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { }); return 'sent'; }, - [ - activeSession?.accessToken, - activeSession?.baseUrl, - activeSession?.userId, - mx, - phase1ForegroundResync, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - ] + [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] ); useEffect(() => { @@ -123,6 +119,9 @@ export function useAppVisibility(mx: MatrixClient | undefined) { // Always kick the sync loop on foreground regardless of phase flags — // the SDK may be sitting in exponential backoff after iOS froze the tab. mx?.retryImmediately(); + // retryImmediately() is a no-op on SlidingSyncSdk — call resend() on the + // SlidingSync instance directly to abort a stale long-poll and start fresh. + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); if (!phase1ForegroundResync) return; @@ -146,6 +145,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { // Always kick the sync loop on focus for the same reason as above. mx?.retryImmediately(); + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); if (!phase1ForegroundResync) return; From 14c9d4bc8ad1b7dcaa56b5ca4ec4fb7b1799cfaa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 13:35:58 -0400 Subject: [PATCH 20/24] fix(sw): improve push notification reliability and encrypted room handling - Use getEffectiveEvent()?.type for decrypted event type in BackgroundNotifications - Fix isEncryptedRoom flag in pushNotification.ts (was hardcoded false) - Add isEncryptedRoom: true to relay payload when decryption succeeds - Wrap push handlers in try/catch with fallback notifications (prevents silent drops on iOS) - Parallelize requestDecryptionFromClient with Promise.any + shared timeout (was sequential) --- .../pages/client/BackgroundNotifications.tsx | 7 +- src/sw.ts | 92 +++++++++++++------ src/sw/pushNotification.ts | 2 +- 3 files changed, 70 insertions(+), 31 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 17aabc595..1e0a98dca 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -415,6 +415,11 @@ export function BackgroundNotifications() { const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption); + // After decryption, getType() still returns the wire type (m.room.encrypted). + // Use the effective event type to get the decrypted type when available. + const effectiveEventType = + (mEvent.getEffectiveEvent()?.type as string) ?? mEvent.getType(); + notifiedEventsRef.current.add(dedupeId); // Cap the set so it doesn't grow unbounded if (notifiedEventsRef.current.size > 200) { @@ -429,7 +434,7 @@ export function BackgroundNotifications() { recipientId: session.userId, previewText: resolveNotificationPreviewText({ content: mEvent.getContent(), - eventType: mEvent.getType(), + eventType: effectiveEventType, isEncryptedRoom, showMessageContent: showMessageContentRef.current, showEncryptedMessageContent: showEncryptedMessageContentRef.current, diff --git a/src/sw.ts b/src/sw.ts index 06b90aa8a..13812051c 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -396,36 +396,40 @@ async function requestDecryptionFromClient( ): Promise { const eventId = rawEvent.event_id as string; - // Chain clients sequentially using reduce to avoid await-in-loop and for-of. - return Array.from(windowClients).reduce( - async (prevPromise, client) => { - const prev = await prevPromise; - if (prev?.success) return prev; - - const promise = new Promise((resolve) => { - decryptionPendingMap.set(eventId, resolve); - }); + // Try all window clients in parallel with a single shared timeout. + // This avoids the worst case of N × 5s sequential timeouts when multiple + // tabs are frozen (common on iOS). + const clientAttempts = Array.from(windowClients).map((client) => { + const promise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, resolve); + }); - const timeout = new Promise((resolve) => { - setTimeout(() => { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] timed out waiting for client', client.id); - resolve(undefined); - }, 5000); - }); + try { + (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + } catch (err) { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] postMessage error', err); + return Promise.resolve(undefined as DecryptionResult | undefined); + } - try { - (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); - } catch (err) { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] postMessage error', err); - return undefined; - } + return promise as Promise; + }); - return Promise.race([promise, timeout]); - }, - Promise.resolve(undefined) as Promise - ); + if (clientAttempts.length === 0) return undefined; + + const timeout = new Promise((resolve) => { + setTimeout(() => { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] timed out waiting for all clients'); + resolve(undefined); + }, 5000); + }); + + // Return as soon as any client succeeds or the shared timeout fires. + return Promise.race([ + Promise.any(clientAttempts).catch(() => undefined), + timeout, + ]); } /** @@ -533,6 +537,7 @@ async function handleMinimalPushPayload( // Prefer relay's room name (has m.direct / computed SDK name); fall back to state fetch. room_name: result.room_name || resolvedRoomName, room_avatar_url: notificationAvatarUrl, + isEncryptedRoom: true, }); } else { // App is frozen or fully closed — show "Encrypted message" fallback. @@ -951,11 +956,40 @@ 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); + try { + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + } catch (err) { + console.error('[SW push] handleMinimalPushPayload failed:', err); + // Show a generic fallback so the user still sees something on iOS. + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: `room-${pushData.room_id}`, + renotify: true, + data: { room_id: pushData.room_id, event_id: pushData.event_id }, + } as NotificationOptions); + } return; } - await handlePushNotificationPushData(pushData); + try { + await handlePushNotificationPushData(pushData); + } catch (err) { + console.error('[SW push] handlePushNotificationPushData failed:', err); + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: pushData.room_id ? `room-${pushData.room_id}` : (pushData.event_id ?? 'Cinny'), + renotify: true, + data: { + room_id: pushData.room_id, + event_id: pushData.event_id, + user_id: pushData.user_id, + }, + } as NotificationOptions); + } }; // --------------------------------------------------------------------------- diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index 1152d3d44..73bc1a495 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -88,7 +88,7 @@ export const createPushNotifications = ( previewText: resolveNotificationPreviewText({ content: pushData?.content, eventType: pushData?.type, - isEncryptedRoom: false, + isEncryptedRoom: pushData?.isEncryptedRoom === true, showMessageContent: getNotificationSettings().showMessageContent, showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent, }), From 40d971bfc538cb092bed2e762da107d168762c32 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:17:59 -0400 Subject: [PATCH 21/24] chore: fix lint and format issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/BackgroundNotifications.tsx | 3 +-- src/sw.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 1e0a98dca..725a4fc25 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -417,8 +417,7 @@ export function BackgroundNotifications() { // After decryption, getType() still returns the wire type (m.room.encrypted). // Use the effective event type to get the decrypted type when available. - const effectiveEventType = - (mEvent.getEffectiveEvent()?.type as string) ?? mEvent.getType(); + const effectiveEventType = mEvent.getEffectiveEvent()?.type ?? mEvent.getType(); notifiedEventsRef.current.add(dedupeId); // Cap the set so it doesn't grow unbounded diff --git a/src/sw.ts b/src/sw.ts index 13812051c..6337bd48d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -426,10 +426,7 @@ async function requestDecryptionFromClient( }); // Return as soon as any client succeeds or the shared timeout fires. - return Promise.race([ - Promise.any(clientAttempts).catch(() => undefined), - timeout, - ]); + return Promise.race([Promise.any(clientAttempts).catch(() => undefined), timeout]); } /** From ac62f80f3909c6e56032ce82ef9e3f8ae249507d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:25:27 -0400 Subject: [PATCH 22/24] fix(config): enable SW session sync phases for reliable mobile notifications Add sessionSync.phase1ForegroundResync and phase2VisibleHeartbeat to config.json so the service worker session stays fresh on iOS. Without these flags useAppVisibility disables both foreground resync (phase1) and the 10-min visible heartbeat (phase2), leaving the CacheStorage session to age out after 24 h with no refresh. When iOS kills the SW while backgrounded and the session has gone stale, push decryption fails and notifications are silently dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config.json b/config.json index f0c3c8b61..3659a35d2 100644 --- a/config.json +++ b/config.json @@ -19,6 +19,11 @@ "enabled": true }, + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ From 4e28db5b76766315dec7f03f7fce16c8f7be17e6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:32:27 -0400 Subject: [PATCH 23/24] fix(sw): reuse preloaded session in handleMinimalPushPayload onPushNotification already fetches the persisted session and stores it in preloadedSession. Thread that through handleMinimalPushPayload's fallback chain so we skip the second cache.match() call on iOS restarts where the in-memory sessions Map is empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/sw.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 6337bd48d..45b760cb8 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -441,10 +441,11 @@ async function handleMinimalPushPayload( ): 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 - // the app (same pattern as settings persistence). + // the app (same pattern as settings persistence). If onPushNotification already loaded + // the persisted session into preloadedSession, reuse it to avoid a second cache read. // Last resort: if neither the in-memory map nor the cache has a session, ask any live // window client for a fresh token (the app may be backgrounded but still alive in memory). - let session = getAnyStoredSession() ?? (await loadPersistedSession()); + let session = getAnyStoredSession() ?? preloadedSession ?? (await loadPersistedSession()); if (!session && windowClients.length > 0) { console.debug('[SW push] no cached session, requesting from window clients'); const results = await Promise.all( From da209be9523b78a899227df364f354dd0d7dec11 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 00:43:11 -0400 Subject: [PATCH 24/24] fix(badge): only clear app badge when foregrounded When backgrounded, the service worker manages the badge from push payloads. The app's local unread state may be stale before sync catches up, causing the badge to flash on then immediately off. Guard clearAppBadge() with a visibility check so the SW badge persists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 0b2b74f65..f1d5e2770 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -134,7 +134,9 @@ function FaviconUpdater() { // for an OS-level app badge. if (highlightTotal > 0) { navigator.setAppBadge(highlightTotal); - } else { + } else if (document.visibilityState === 'visible') { + // Only clear when foregrounded — the SW sets the badge from push + // payloads while backgrounded, and local state may be stale. navigator.clearAppBadge(); } if (usePushNotifications) {