From fafea7bd02325460c263f67294e5f92f8a47289e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:30:45 -0400 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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 6b585d6596a9313a4e9e4eb4effb7bed7d627903 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:10:26 -0400 Subject: [PATCH 06/41] fix(notifications): pass room and userId context to reaction notification filter --- .changeset/reaction-notification-context.md | 5 +++++ src/app/pages/client/BackgroundNotifications.tsx | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/reaction-notification-context.md diff --git a/.changeset/reaction-notification-context.md b/.changeset/reaction-notification-context.md new file mode 100644 index 000000000..18e22446b --- /dev/null +++ b/.changeset/reaction-notification-context.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix reaction notifications not being delivered by passing room and user context to the notification event filter diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 395718223..0fa0c2d3a 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -323,7 +323,7 @@ export function BackgroundNotifications() { return; } - if (!isNotificationEvent(mEvent)) { + if (!isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { return; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..65bf1825a 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -336,7 +336,7 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { + if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { return; } From 5a4a324db73f6c1435588e8832c465a760a6c82c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:22:39 -0400 Subject: [PATCH 07/41] fix: wrap long if condition for prettier compliance --- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 65bf1825a..8a464cf82 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -336,7 +336,12 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { + if ( + !room || + isHistoricalEvent || + room.isSpaceRoom() || + !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined) + ) { return; } From 3c5d0870f0cd10cd294120ebd4eef49028d4ba30 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:36:42 -0400 Subject: [PATCH 08/41] 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 09/41] 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 90e2e1e736f5ed062b853319e1b01a79a7c35620 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 02:54:36 -0400 Subject: [PATCH 10/41] fix(notifications): open joined rooms at live timeline on notification click --- src/app/hooks/useNotificationJumper.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 43c358317..1c785d079 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -52,13 +52,17 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - log.log('jumping to:', pending.roomId, pending.eventId); + // Always open joined rooms at the live timeline for notification clicks. + // Event-scoped navigation can create a sparse historical context where the + // room appears to contain only the notification event. + const targetEventId = undefined; + log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectRoomPath(roomIdOrAlias, targetEventId)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -74,11 +78,11 @@ export function NotificationJumper() { getSpaceRoomPath( getCanonicalAliasOrRoomId(mx, parentSpace), roomIdOrAlias, - pending.eventId + targetEventId ) ); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); } } setPending(null); From 11fe16cffd98d8b54f193b3e1e304aa1516662ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 02:58:37 -0400 Subject: [PATCH 11/41] fix(notifications): prefer live timeline before event-scoped jump --- src/app/hooks/useNotificationJumper.ts | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 1c785d079..8a9f6aec2 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; -import { SyncState, ClientEvent } from '$types/matrix-sdk'; +import { SyncState, ClientEvent, RoomEvent, Room, MatrixEvent } from '$types/matrix-sdk'; import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions'; import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; @@ -52,10 +52,22 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - // Always open joined rooms at the live timeline for notification clicks. - // Event-scoped navigation can create a sparse historical context where the - // room appears to contain only the notification event. - const targetEventId = undefined; + const liveEvents = + room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.() ?? []; + const eventInLive = pending.eventId + ? liveEvents.some((event) => event.getId() === pending.eventId) + : false; + + // If the live timeline is empty the room data is not ready yet. + // Defer and retry on RoomEvent.Timeline so we can decide with real data. + if (!eventInLive && liveEvents.length === 0) { + log.log('live timeline empty, deferring jump...', { roomId: pending.roomId }); + return; + } + + // Keep event targeting when needed, but avoid event-scoped navigation for + // events already in the live timeline to prevent sparse historical context. + const targetEventId = eventInLive ? undefined : pending.eventId; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which @@ -121,11 +133,16 @@ export function NotificationJumper() { if (!pending) return undefined; const onRoom = () => performJumpRef.current(); + const onTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => { + if (eventRoom?.roomId === pending.roomId) performJumpRef.current(); + }; mx.on(ClientEvent.Room, onRoom); + mx.on(RoomEvent.Timeline, onTimeline); performJumpRef.current(); return () => { mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(RoomEvent.Timeline, onTimeline); }; }, [pending, mx]); // performJump intentionally omitted — use ref above From 4f1bed31018cc5964904342c04a28d0028bf5184 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 03:21:33 -0400 Subject: [PATCH 12/41] fix(notifications): defer event-scoped jump until event appears in live timeline --- src/app/hooks/useNotificationJumper.ts | 43 ++++++++++++++++++++------ 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 8a9f6aec2..90df74293 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -12,6 +12,10 @@ import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; +// How long to wait for the notification event to appear in the live timeline +// before falling back to opening the room at the live bottom. +const JUMP_TIMEOUT_MS = 15_000; + export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -27,6 +31,9 @@ export function NotificationJumper() { // churn re-calls performJump (from the ClientEvent.Room listener or effect // re-runs) before React has committed the null, causing repeated navigation. const jumpingRef = useRef(false); + // Tracks when we first started waiting for the target event to appear in the + // live timeline. Reset whenever `pending` changes. + const jumpStartTimeRef = useRef(null); const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; @@ -58,16 +65,33 @@ export function NotificationJumper() { ? liveEvents.some((event) => event.getId() === pending.eventId) : false; - // If the live timeline is empty the room data is not ready yet. - // Defer and retry on RoomEvent.Timeline so we can decide with real data. - if (!eventInLive && liveEvents.length === 0) { - log.log('live timeline empty, deferring jump...', { roomId: pending.roomId }); - return; + // Defer while the target event hasn't arrived in the live timeline yet. + // Navigating with an eventId not in the live timeline triggers a sparse + // historical context load — the room appears empty or shows only one message. + // Retry on each RoomEvent.Timeline until the event appears, then navigate + // with the eventId so the room scrolls to and highlights it in full context. + // After JUMP_TIMEOUT_MS fall back to opening the room at the live bottom. + if (pending.eventId && !eventInLive) { + if (jumpStartTimeRef.current === null) { + jumpStartTimeRef.current = Date.now(); + } + if (Date.now() - jumpStartTimeRef.current < JUMP_TIMEOUT_MS) { + log.log('event not yet in live timeline, deferring jump...', { + roomId: pending.roomId, + eventId: pending.eventId, + }); + return; + } + log.log('timed out waiting for event in live; falling back to live bottom', { + roomId: pending.roomId, + eventId: pending.eventId, + }); } - // Keep event targeting when needed, but avoid event-scoped navigation for - // events already in the live timeline to prevent sparse historical context. - const targetEventId = eventInLive ? undefined : pending.eventId; + // Pass eventId only when confirmed in the live timeline — scrolls to and + // highlights the event in full room context without a sparse historical load. + // Falls back to undefined (live bottom) when the event never appears in live. + const targetEventId = eventInLive ? pending.eventId : undefined; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which @@ -108,9 +132,10 @@ export function NotificationJumper() { } }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); - // Reset the guard only when pending is replaced (new notification or cleared). + // Reset guards only when pending is replaced (new notification or cleared). useEffect(() => { jumpingRef.current = false; + jumpStartTimeRef.current = null; }, [pending]); // Keep a stable ref to the latest performJump so that the listeners below From 5b3a0fb521d5ea4ed42188ec50752671aa546f78 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 08:26:35 -0400 Subject: [PATCH 13/41] 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 14/41] 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 15/41] 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 16/41] 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 17/41] 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 18/41] 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 19/41] 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 20/41] 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 21/41] 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 22/41] 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 23/41] 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 24/41] 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 25/41] 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 26/41] 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 27/41] 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 28/41] 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 29/41] 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) { From 17ed81638897fb3ae857612a1ba7a69532134f7d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 10:31:58 -0400 Subject: [PATCH 30/41] feat(cache): unregister service workers on Clear Cache Unregister all service worker registrations before reloading when the user clicks Clear Cache & Reload. On iOS/mobile, stale SWs can persist and serve outdated assets even after an app update; this ensures the next load starts completely fresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/client/initMatrix.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index f6f428a10..316157340 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -614,6 +614,14 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { stopClient(mx); clearNavToActivePathStore(mx.getSafeUserId()); await mx.store.deleteAllData(); + + // Unregister all service workers so the next load starts fresh. + // Especially important on iOS/mobile where stale SWs can persist. + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + window.location.reload(); }; From 66a4c007b029df79c2f8cd97b26a912e8d9eee83 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 18:48:03 -0400 Subject: [PATCH 31/41] fix(notifications): skip in-app notification for active room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add activeRoomIdAtom (synced from all RoomProviders) so BackgroundNotifications can detect when the user is already viewing the notification room. When the room matches and the window is focused, the background handler now returns early — no banner, no OS notification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pages/client/BackgroundNotifications.tsx | 15 +++++++++++++++ src/app/pages/client/direct/RoomProvider.tsx | 3 +++ src/app/pages/client/home/RoomProvider.tsx | 3 +++ src/app/pages/client/space/RoomProvider.tsx | 3 +++ src/app/state/room/activeRoomId.ts | 19 +++++++++++++++++++ 5 files changed, 43 insertions(+) create mode 100644 src/app/state/room/activeRoomId.ts diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 821fdffb5..cabe41287 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -35,6 +35,7 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import LogoSVG from '$public/res/svg/cinny-logo.svg'; import { nicknamesAtom } from '$state/nicknames'; +import { activeRoomIdAtom } from '$state/room/activeRoomId'; import { buildRoomMessageNotification, resolveNotificationPreviewText, @@ -110,8 +111,11 @@ export function BackgroundNotifications() { ); const shouldRunBackgroundNotifications = showNotifications || usePushNotifications; const nicknames = useAtomValue(nicknamesAtom); + const activeRoomId = useAtomValue(activeRoomIdAtom); const nicknamesRef = useRef(nicknames); nicknamesRef.current = nicknames; + const activeRoomIdRef = useRef(activeRoomId); + activeRoomIdRef.current = activeRoomId; // Refs so handleTimeline callbacks always read current settings without stale closures const showNotificationsRef = useRef(showNotifications); showNotificationsRef.current = showNotifications; @@ -456,6 +460,17 @@ export function BackgroundNotifications() { setPending({ roomId: room.roomId, eventId, targetSessionId: session.userId }); }; + // Skip notifications entirely when the active session is viewing + // this exact room and the window has focus — the user is already + // looking at the messages. + if (room.roomId === activeRoomIdRef.current && document.hasFocus()) { + debugLog.debug('notification', 'Skipping notification — room is active', { + roomId: room.roomId, + eventId, + }); + return; + } + // Show in-app banner when app is visible, mobile, and in-app notifications enabled const canShowInAppBanner = document.visibilityState === 'visible' && diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 1780fa728..a7a5eac0d 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -4,6 +4,7 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; +import { useActiveRoomIdSync } from '$state/room/activeRoomId'; import { useDirectRooms } from './useDirectRooms'; export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { @@ -16,6 +17,8 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); + useActiveRoomIdSync(roomId); + if (!room || !rooms.includes(room.roomId)) { return ; } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index b6f7ba7e3..c86ed8219 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -5,6 +5,7 @@ import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; +import { useActiveRoomIdSync } from '$state/room/activeRoomId'; import { useHomeRooms } from './useHomeRooms'; export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { @@ -18,6 +19,8 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); + useActiveRoomIdSync(roomId); + if (!room || !rooms.includes(room.roomId)) { return ( (undefined); + +/** Keep {@link activeRoomIdAtom} in sync with the current route's room. */ +export function useActiveRoomIdSync(roomId: string | undefined): void { + const setActiveRoomId = useSetAtom(activeRoomIdAtom); + useEffect(() => { + setActiveRoomId(roomId); + return () => setActiveRoomId(undefined); + }, [roomId, setActiveRoomId]); +} From a054294351811e36f785f9ff852c23392f7d27a3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 23:46:08 -0400 Subject: [PATCH 32/41] fix(notifications): improve notification jump reliability - Increase jump timeout from 15s to 30s for slow sync catch-up - Always pass eventId on navigation (even after timeout) so the room loads historical context around the notification message instead of dumping the user at live bottom Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useNotificationJumper.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 90df74293..30d2259b8 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -13,8 +13,8 @@ import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; // How long to wait for the notification event to appear in the live timeline -// before falling back to opening the room at the live bottom. -const JUMP_TIMEOUT_MS = 15_000; +// before navigating with the eventId anyway (triggers historical context load). +const JUMP_TIMEOUT_MS = 30_000; export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); @@ -88,10 +88,11 @@ export function NotificationJumper() { }); } - // Pass eventId only when confirmed in the live timeline — scrolls to and - // highlights the event in full room context without a sparse historical load. - // Falls back to undefined (live bottom) when the event never appears in live. - const targetEventId = eventInLive ? pending.eventId : undefined; + // Pass eventId when confirmed in the live timeline (best case — scrolls to + // and highlights the event in full room context), OR when the timeout fires + // (triggers a historical context load so the user at least sees the message + // they tapped). Only omit eventId when we never had one in the first place. + const targetEventId = pending.eventId ?? undefined; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which From 7a6c9494bde524be09e5bcebe92ad4d110d3540f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:19:59 -0400 Subject: [PATCH 33/41] fix(notifications): restore background visibility sync --- src/app/hooks/useAppVisibility.ts | 19 ++++++++++++++--- src/sw.ts | 35 ++++++++++++++++++------------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 0fefeff22..45fb0e04e 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -64,6 +64,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { const lastForegroundPushAtRef = useRef(0); const suppressHeartbeatUntilRef = useRef(0); const heartbeatFailuresRef = useRef(0); + const lastEmittedVisibilityRef = useRef(undefined); const pushSessionNow = useCallback( (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { @@ -103,12 +104,14 @@ export function useAppVisibility(mx: MatrixClient | undefined) { ); useEffect(() => { - const handleVisibilityChange = () => { - const isVisible = document.visibilityState === 'visible'; + const handleVisibilityState = (isVisible: boolean, source: 'visibilitychange' | 'pagehide') => { + if (lastEmittedVisibilityRef.current === isVisible) return; + lastEmittedVisibilityRef.current = isVisible; + debugLog.info( 'general', `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, - { visibilityState: document.visibilityState } + { visibilityState: document.visibilityState, source } ); appEvents.emitVisibilityChange(isVisible); if (!isVisible) { @@ -140,6 +143,14 @@ export function useAppVisibility(mx: MatrixClient | undefined) { } }; + const handleVisibilityChange = () => { + handleVisibilityState(document.visibilityState === 'visible', 'visibilitychange'); + }; + + const handlePageHide = () => { + handleVisibilityState(false, 'pagehide'); + }; + const handleFocus = () => { if (document.visibilityState !== 'visible') return; @@ -162,10 +173,12 @@ export function useAppVisibility(mx: MatrixClient | undefined) { }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pagehide', handlePageHide); window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pagehide', handlePageHide); window.removeEventListener('focus', handleFocus); }; }, [ diff --git a/src/sw.ts b/src/sw.ts index 45b760cb8..0e2659bcd 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -395,38 +395,43 @@ async function requestDecryptionFromClient( rawEvent: Record ): Promise { const eventId = rawEvent.event_id as string; - - // 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); + if (windowClients.length === 0) return undefined; + + // Broadcast to all clients, but resolve once from the first successful reply. + // The prior Promise.any implementation accidentally overwrote the pending + // resolver on each iteration, leaving only the last client able to satisfy + // the request for a given eventId. + const resultPromise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, (result) => { + decryptionPendingMap.delete(eventId); + resolve(result); }); + }); + let postedToClient = false; + Array.from(windowClients).forEach((client) => { try { (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + postedToClient = true; } catch (err) { - decryptionPendingMap.delete(eventId); console.warn('[SW decryptRelay] postMessage error', err); - return Promise.resolve(undefined as DecryptionResult | undefined); } - - return promise as Promise; }); - if (clientAttempts.length === 0) return undefined; + if (!postedToClient) { + decryptionPendingMap.delete(eventId); + return undefined; + } const timeout = new Promise((resolve) => { setTimeout(() => { decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] timed out waiting for all clients'); + console.warn('[SW decryptRelay] timed out waiting for client response'); resolve(undefined); }, 5000); }); - // Return as soon as any client succeeds or the shared timeout fires. - return Promise.race([Promise.any(clientAttempts).catch(() => undefined), timeout]); + return Promise.race([resultPromise, timeout]); } /** From e98bcb8144fc08232799c695fc715a53a7633750 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:41:02 -0400 Subject: [PATCH 34/41] fix(notifications): normalize DM room names --- src/app/pages/client/BackgroundNotifications.tsx | 5 +++-- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++++--- src/app/utils/room.ts | 10 ++++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index cabe41287..aad805035 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -26,6 +26,7 @@ import { getMemberDisplayName, getNotificationType, getStateEvent, + getRoomDisplayName, isNotificationEvent, getMDirects, isDMRoom, @@ -431,7 +432,7 @@ export function BackgroundNotifications() { } const notificationPayload = buildRoomMessageNotification({ - roomName: room.name ?? room.getCanonicalAlias() ?? room.roomId, + roomName: getRoomDisplayName(room), roomAvatar, username: senderName, recipientId: session.userId, @@ -487,7 +488,7 @@ export function BackgroundNotifications() { setInAppBannerRef.current({ id: dedupeId, title: notificationPayload.title, - roomName: room.name ?? room.getCanonicalAlias() ?? undefined, + roomName: getRoomDisplayName(room), senderName, body: notificationPayload.options.body, icon: notificationPayload.options.icon, diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f1d5e2770..f0c6aa5dd 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -31,6 +31,7 @@ import { getMemberDisplayName, getNotificationType, getStateEvent, + getRoomDisplayName, isDMRoom, isNotificationEvent, } from '$utils/room'; @@ -416,7 +417,7 @@ function MessageNotifications() { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const osPayload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', + roomName: getRoomDisplayName(room), roomAvatar: avatarMxc ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined, @@ -492,7 +493,7 @@ function MessageNotifications() { } const payload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', + roomName: getRoomDisplayName(room), roomAvatar, username: resolvedSenderName, previewText, @@ -507,7 +508,7 @@ function MessageNotifications() { setInAppBanner({ id: eventId, title: payload.title, - roomName: room.name ?? undefined, + roomName: getRoomDisplayName(room), serverName, senderName: resolvedSenderName, body: previewText, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 9391dbc90..fa6114466 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -536,6 +536,16 @@ export const getMemberDisplayName = ( return name; }; +/** + * Returns the room's display name, normalising the case where the SDK computes + * a raw Matrix user ID for unnamed DMs. In that case, use the localpart so + * notifications and banners do not show the full MXID. + */ +export const getRoomDisplayName = (room: Room): string => { + const { name } = room; + return name.match(/^@([^:]+):/)?.[1] ?? name; +}; + export const getMemberSearchStr = ( member: RoomMember, query: string, From 36a7e04d49a39e44f311b429a694bcb97c19f5fe Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:26:05 -0400 Subject: [PATCH 35/41] fix(notifications): guarantee jump timeout fallback --- src/app/hooks/useNotificationJumper.test.tsx | 121 +++++++++++++++++++ src/app/hooks/useNotificationJumper.ts | 40 +++++- 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/app/hooks/useNotificationJumper.test.tsx diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx new file mode 100644 index 000000000..f1076eaf4 --- /dev/null +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -0,0 +1,121 @@ +import { ReactNode } from 'react'; +import { act, render } from '@testing-library/react'; +import { Provider } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { SyncState } from '$types/matrix-sdk'; +import { getHomeRoomPath } from '$pages/pathUtils'; +import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; +import { mDirectAtom } from '$state/mDirectList'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { NotificationJumper } from './useNotificationJumper'; + +const navigateMock = vi.fn(); + +const roomTimelineEvents: { getId: () => string }[] = []; +const roomMock = { + roomId: '!room:test', + getMyMembership: vi.fn(() => 'join'), + getCanonicalAlias: vi.fn(() => undefined), + getUnfilteredTimelineSet: vi.fn(() => ({ + getLiveTimeline: () => ({ + getEvents: () => roomTimelineEvents, + }), + })), +}; + +const mxMock = { + getUserId: vi.fn(() => '@alice:test'), + getSyncState: vi.fn(() => SyncState.Syncing), + getRoom: vi.fn(() => roomMock), + getRooms: vi.fn(() => []), + on: vi.fn(), + removeListener: vi.fn(), +}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + +vi.mock('./useMatrixClient', () => ({ + useMatrixClient: () => mxMock, +})); + +vi.mock('./useSyncState', () => ({ + useSyncState: vi.fn(), +})); + +vi.mock('../utils/debug', () => ({ + createLogger: () => ({ + log: vi.fn(), + }), +})); + +type WrapperProps = { + children: ReactNode; +}; + +function HydrateAtoms({ children }: WrapperProps) { + useHydrateAtoms([ + [activeSessionIdAtom, '@alice:test'], + [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], + [mDirectAtom, new Set()], + [roomToParentsAtom, new Map()], + ]); + + return <>{children}; +} + +function HydratedWrapper({ children }: WrapperProps) { + return ( + + + {children} + + + ); +} + +describe('NotificationJumper', () => { + beforeEach(() => { + vi.useFakeTimers(); + navigateMock.mockReset(); + roomTimelineEvents.length = 0; + roomMock.getMyMembership.mockReturnValue('join'); + mxMock.getUserId.mockReturnValue('@alice:test'); + mxMock.getSyncState.mockReturnValue(SyncState.Syncing); + mxMock.getRoom.mockReturnValue(roomMock); + mxMock.getRooms.mockReturnValue([]); + mxMock.on.mockClear(); + mxMock.removeListener.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('navigates immediately when the target event is already in the live timeline', () => { + roomTimelineEvents.push({ getId: () => '$event:test' }); + + render(, { wrapper: HydratedWrapper }); + + expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test')); + }); + + it('falls back after the timeout even if no further room events arrive', () => { + render(, { wrapper: HydratedWrapper }); + + expect(navigateMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(30_000); + }); + + expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test')); + }); +}); diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 30d2259b8..71c376982 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -34,6 +34,14 @@ export function NotificationJumper() { // Tracks when we first started waiting for the target event to appear in the // live timeline. Reset whenever `pending` changes. const jumpStartTimeRef = useRef(null); + const jumpTimeoutRef = useRef | undefined>(undefined); + + const clearJumpTimeout = useCallback(() => { + if (jumpTimeoutRef.current !== undefined) { + clearTimeout(jumpTimeoutRef.current); + jumpTimeoutRef.current = undefined; + } + }, []); const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; @@ -75,7 +83,14 @@ export function NotificationJumper() { if (jumpStartTimeRef.current === null) { jumpStartTimeRef.current = Date.now(); } - if (Date.now() - jumpStartTimeRef.current < JUMP_TIMEOUT_MS) { + const elapsedMs = Date.now() - jumpStartTimeRef.current; + if (elapsedMs < JUMP_TIMEOUT_MS) { + if (jumpTimeoutRef.current === undefined) { + jumpTimeoutRef.current = setTimeout(() => { + jumpTimeoutRef.current = undefined; + performJumpRef.current(); + }, JUMP_TIMEOUT_MS - elapsedMs); + } log.log('event not yet in live timeline, deferring jump...', { roomId: pending.roomId, eventId: pending.eventId, @@ -95,6 +110,7 @@ export function NotificationJumper() { const targetEventId = pending.eventId ?? undefined; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; + clearJumpTimeout(); // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); @@ -131,13 +147,24 @@ export function NotificationJumper() { membership: room?.getMyMembership(), }); } - }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); + }, [ + pending, + activeSessionId, + mx, + mDirects, + roomToParents, + navigate, + setPending, + log, + clearJumpTimeout, + ]); // Reset guards only when pending is replaced (new notification or cleared). useEffect(() => { + clearJumpTimeout(); jumpingRef.current = false; jumpStartTimeRef.current = null; - }, [pending]); + }, [pending, clearJumpTimeout]); // Keep a stable ref to the latest performJump so that the listeners below // always invoke the current version without adding performJump to their dep @@ -172,5 +199,12 @@ export function NotificationJumper() { }; }, [pending, mx]); // performJump intentionally omitted — use ref above + useEffect( + () => () => { + clearJumpTimeout(); + }, + [clearJumpTimeout] + ); + return null; } From 5994810cfd9969fbdbfbc0f31d129a8531e0b9ce Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:26:41 -0400 Subject: [PATCH 36/41] test(notifications): align jumper room mock with room utils --- src/app/hooks/useNotificationJumper.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx index f1076eaf4..efcca25f2 100644 --- a/src/app/hooks/useNotificationJumper.test.tsx +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -18,6 +18,11 @@ const roomMock = { roomId: '!room:test', getMyMembership: vi.fn(() => 'join'), getCanonicalAlias: vi.fn(() => undefined), + getLiveTimeline: vi.fn(() => ({ + getState: vi.fn(() => ({ + getStateEvents: vi.fn(() => undefined), + })), + })), getUnfilteredTimelineSet: vi.fn(() => ({ getLiveTimeline: () => ({ getEvents: () => roomTimelineEvents, From cea8c2ae74ccf671eeae577e06e2578bd3403103 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:27:06 -0400 Subject: [PATCH 37/41] test(notifications): use current jotai hydrate api --- src/app/hooks/useNotificationJumper.test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx index efcca25f2..576a06581 100644 --- a/src/app/hooks/useNotificationJumper.test.tsx +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -66,12 +66,14 @@ type WrapperProps = { }; function HydrateAtoms({ children }: WrapperProps) { - useHydrateAtoms([ - [activeSessionIdAtom, '@alice:test'], - [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], - [mDirectAtom, new Set()], - [roomToParentsAtom, new Map()], - ]); + useHydrateAtoms( + new Map([ + [activeSessionIdAtom, '@alice:test'], + [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], + [mDirectAtom, new Set()], + [roomToParentsAtom, new Map()], + ]) + ); return <>{children}; } From c8e28946519b62df2d343d3c5eb85d94c9d2c235 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:27:37 -0400 Subject: [PATCH 38/41] test(notifications): initialize jumper atoms via jotai store --- src/app/hooks/useNotificationJumper.test.tsx | 28 +++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx index 576a06581..55bde4b48 100644 --- a/src/app/hooks/useNotificationJumper.test.tsx +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -1,7 +1,6 @@ import { ReactNode } from 'react'; import { act, render } from '@testing-library/react'; -import { Provider } from 'jotai'; -import { useHydrateAtoms } from 'jotai/utils'; +import { Provider, createStore } from 'jotai'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { SyncState } from '$types/matrix-sdk'; @@ -65,25 +64,16 @@ type WrapperProps = { children: ReactNode; }; -function HydrateAtoms({ children }: WrapperProps) { - useHydrateAtoms( - new Map([ - [activeSessionIdAtom, '@alice:test'], - [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], - [mDirectAtom, new Set()], - [roomToParentsAtom, new Map()], - ]) - ); - - return <>{children}; -} - function HydratedWrapper({ children }: WrapperProps) { + const store = createStore(); + store.set(activeSessionIdAtom, '@alice:test'); + store.set(pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set() }); + store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: new Map() }); + return ( - - - {children} - + + {children} ); } From fd502769a153f2468526888696d5796abb63576b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:35:14 -0400 Subject: [PATCH 39/41] fix(notifications): avoid jumper ref lint error --- src/app/hooks/useNotificationJumper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 71c376982..e04ad818d 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -35,6 +35,7 @@ export function NotificationJumper() { // live timeline. Reset whenever `pending` changes. const jumpStartTimeRef = useRef(null); const jumpTimeoutRef = useRef | undefined>(undefined); + const performJumpRef = useRef<() => void>(() => undefined); const clearJumpTimeout = useCallback(() => { if (jumpTimeoutRef.current !== undefined) { @@ -171,7 +172,6 @@ export function NotificationJumper() { // arrays. Adding performJump as a dep causes the effect to re-run (and call // performJump again) on every atom change during an account switch — that is // the second source of repeated navigation. - const performJumpRef = useRef(performJump); performJumpRef.current = performJump; useSyncState( From 00cfccb2190b6ba2a73554288e3c5a14be60cdf8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 09:03:16 -0400 Subject: [PATCH 40/41] fix(sw): recover session sync without controller --- src/app/hooks/useAppVisibility.ts | 4 +-- src/sw-session.ts | 44 +++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 45fb0e04e..224dd3137 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -76,8 +76,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { typeof baseUrl === 'string' && typeof accessToken === 'string' && typeof userId === 'string' && - 'serviceWorker' in navigator && - !!navigator.serviceWorker.controller; + 'serviceWorker' in navigator; if (!canPush) { debugLog.warn('network', 'Skipped SW session sync', { @@ -97,6 +96,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter, + hasSwController: !!navigator.serviceWorker?.controller, }); return 'sent'; }, diff --git a/src/sw-session.ts b/src/sw-session.ts index e4d2672da..63d285d99 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,11 +1,45 @@ -export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { - if (!('serviceWorker' in navigator)) return; - if (!navigator.serviceWorker.controller) return; +type ServiceWorkerSessionPayload = { + type: 'setSession'; + accessToken?: string; + baseUrl?: string; + userId?: string; +}; - navigator.serviceWorker.controller.postMessage({ +function postSessionPayload( + target: ServiceWorker | null | undefined, + payload: ServiceWorkerSessionPayload, + seenTargets: WeakSet +) { + if (!target || seenTargets.has(target)) return false; + seenTargets.add(target); + target.postMessage(payload); + return true; +} + +export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string): boolean { + if (!('serviceWorker' in navigator)) return false; + + const payload: ServiceWorkerSessionPayload = { type: 'setSession', accessToken, baseUrl, userId, - }); + }; + const seenTargets = new WeakSet(); + postSessionPayload(navigator.serviceWorker.controller, payload, seenTargets); + + // Backgrounded/mobile browsers can drop the current controller reference even + // though the registration is still active. Post to any reachable worker from + // navigator.serviceWorker.ready so the session is restored without a reload. + void navigator.serviceWorker.ready + .then((registration) => { + postSessionPayload(registration.active, payload, seenTargets); + postSessionPayload(registration.waiting, payload, seenTargets); + postSessionPayload(registration.installing, payload, seenTargets); + }) + .catch(() => undefined); + + // Treat a queued ready() delivery as a successful attempt so foreground/heartbeat + // recovery keeps running even if controller is temporarily absent. + return true; } From 973983839607e34d9713d8458f886c4ebbc480ae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 13:38:15 -0400 Subject: [PATCH 41/41] fix(sw): add media and notification diagnostics --- src/sw.ts | 55 ++++++++++++++++++++++++++++++-------- src/sw/pushNotification.ts | 10 +++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 0e2659bcd..1075330e5 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -760,23 +760,41 @@ async function fetchMediaWithRetry( let response = await fetch(url, { ...fetchConfig(token), redirect }); if (!isAuthFailureStatus(response.status)) return response; + console.warn('[SW media] Initial authenticated fetch failed', { + url, + status: response.status, + clientId, + hasClientBoundSession: !!(clientId && sessions.get(clientId)), + hasPreloadedSession: !!preloadedSession, + }); + const attemptedTokens = new Set([token]); - const retrySessions: SessionInfo[] = []; + const retrySessions: Array<{ session: SessionInfo; source: string }> = []; const seenSessions = new Set(); - const addRetrySession = (session?: SessionInfo) => { + const addRetrySession = (session: SessionInfo | undefined, source: string) => { 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); + retrySessions.push({ session, source }); }; - 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)); + if (clientId) addRetrySession(sessions.get(clientId), 'client_session'); + getMatchingSessions(url).forEach((session, index) => + addRetrySession(session, `matching_session_${index}`) + ); + addRetrySession(preloadedSession, 'preloaded_session'); + addRetrySession(await loadPersistedSession(), 'persisted_session'); + (await getLiveWindowSessions(url, clientId)).forEach((session, index) => + addRetrySession(session, `live_window_${index}`) + ); + + console.debug('[SW media] Retry candidates collected', { + url, + candidateSources: retrySessions.map(({ source }) => source), + candidateCount: retrySessions.length, + }); // Try each plausible token once. This handles token-refresh races and ambiguous // multi-account sessions on the same homeserver, including no-clientId requests. @@ -784,16 +802,31 @@ async function fetchMediaWithRetry( /* 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)) { + if (!candidate || attemptedTokens.has(candidate.session.accessToken)) { // skip this candidate } else { - attemptedTokens.add(candidate.accessToken); - response = await fetch(url, { ...fetchConfig(candidate.accessToken), redirect }); + attemptedTokens.add(candidate.session.accessToken); + console.debug('[SW media] Retrying with alternate session', { + url, + source: candidate.source, + attempt: i + 1, + }); + response = await fetch(url, { ...fetchConfig(candidate.session.accessToken), redirect }); if (!isAuthFailureStatus(response.status)) return response; + console.warn('[SW media] Alternate session also failed auth', { + url, + source: candidate.source, + status: response.status, + }); } } /* eslint-enable no-await-in-loop */ + console.warn('[SW media] Exhausted authenticated retry candidates', { + url, + finalStatus: response.status, + attemptedTokenCount: attemptedTokens.size, + }); return response; } diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index 73bc1a495..328e999db 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -45,7 +45,17 @@ export const createPushNotifications = ( silent, data, }; + const existingNotifications = await self.registration.getNotifications(); + const replacedCount = existingNotifications.filter( + (notification) => notification.tag === tag + ).length; console.debug('[SW showNotification] title:', title, '| data:', JSON.stringify(data, null, 2)); + console.debug('[SW showNotification] tag diagnostics:', { + tag, + roomId, + renotify, + replacedCount, + }); await self.registration.showNotification(title, notifOptions as NotificationOptions); };