Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export async function enablePushNotifications(
kind: 'http' as const,
app_id: clientConfig.pushNotificationDetails?.webPushAppID,
pushkey: keys.p256dh,
app_display_name: 'Cinny',
app_display_name: 'Sable',
device_display_name: 'This Browser',
lang: navigator.language || 'en',
data: {
Expand Down Expand Up @@ -104,7 +104,7 @@ export async function enablePushNotifications(
kind: 'http' as const,
app_id: clientConfig.pushNotificationDetails?.webPushAppID,
pushkey: keys.p256dh,
app_display_name: 'Cinny',
app_display_name: 'Sable',
device_display_name:
(await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device',
lang: navigator.language || 'en',
Expand Down
11 changes: 7 additions & 4 deletions src/app/hooks/useAppVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useClientConfig } from './useClientConfig';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { pushSubscriptionAtom } from '../state/pushSubscription';
import { mobileOrTablet } from '../utils/user-agent';
import { createDebugLogger } from '../utils/debugLogger';

const debugLog = createDebugLogger('AppVisibility');
Expand All @@ -16,7 +15,6 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
const clientConfig = useClientConfig();
const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
const pushSubAtom = useAtom(pushSubscriptionAtom);
const isMobile = mobileOrTablet();

useEffect(() => {
const handleVisibilityChange = () => {
Expand All @@ -43,12 +41,17 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
if (!mx) return undefined;

const handleVisibilityForNotifications = (isVisible: boolean) => {
togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
// Always keep the pusher registered regardless of visibility — the SW's
// hasVisibleClient check handles OS-notification suppression when the app
// is in the foreground, so we never need to delete the pusher. Keeping
// it permanently avoids the enable/disable race that can leave the
// homeserver without a valid pusher after rapid tab-focus changes.
togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, true);
};

appEvents.onVisibilityChange = handleVisibilityForNotifications;
return () => {
appEvents.onVisibilityChange = null;
};
}, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
}, [mx, clientConfig, usePushNotifications, pushSubAtom]);
}
26 changes: 24 additions & 2 deletions src/app/pages/client/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function ClientRoot({ children }: ClientRootProps) {
log.log('initClient for', activeSession.userId);
const newMx = await initClient(activeSession);
loadedUserIdRef.current = activeSession.userId;
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId);
return newMx;
}, [activeSession, activeSessionId, setActiveSessionId])
);
Expand Down Expand Up @@ -234,7 +234,7 @@ export function ClientRoot({ children }: ClientRootProps) {
activeSession.userId,
'— reloading client'
);
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId);
if (mx?.clientRunning) {
stopClient(mx);
}
Expand All @@ -259,6 +259,28 @@ export function ClientRoot({ children }: ClientRootProps) {
useLogoutListener(mx);
useAppVisibility(mx);

// Keep the SW session warm so media fetches and push notifications work
// reliably after iOS kills and restarts the SW in the background.
// - Immediate resync whenever the tab comes back to the foreground.
// - Periodic heartbeat (10 min) keeps the persisted session up to date
// while the app is running.
const swSessionBaseUrl = activeSession?.baseUrl;
const swSessionAccessToken = activeSession?.accessToken;
const swSessionUserId = activeSession?.userId;
useEffect(() => {
if (!swSessionBaseUrl || !swSessionAccessToken) return undefined;
const resync = () => pushSessionToSW(swSessionBaseUrl, swSessionAccessToken, swSessionUserId);
const handleVisibility = () => {
if (document.visibilityState === 'visible') resync();
};
document.addEventListener('visibilitychange', handleVisibility);
const timer = setInterval(resync, 10 * 60 * 1000);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
clearInterval(timer);
};
}, [swSessionBaseUrl, swSessionAccessToken, swSessionUserId]);

useEffect(
() => () => {
if (mx?.clientRunning) {
Expand Down
8 changes: 8 additions & 0 deletions src/client/initMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,5 +802,13 @@ export const clearLoginData = async () => {
if (name) window.indexedDB.deleteDatabase(name);
});
window.localStorage.clear();

// Unregister all service workers so the next load starts fresh.
// Especially important on iOS/mobile where stale SWs can persist.
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((r) => r.unregister()));
Comment on lines +809 to +810
}

window.location.reload();
};
17 changes: 14 additions & 3 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,10 +763,21 @@ const onPushNotification = async (event: PushEvent) => {

// If the app is open and visible, skip the OS push notification — the in-app
// pill notification handles the alert instead.
// Combine clients.matchAll() visibility with the explicit appIsVisible flag
// because iOS Safari PWA often returns empty or stale results from matchAll().
//
// Require BOTH the explicit appIsVisible flag AND a visible client from
// matchAll() before suppressing. appIsVisible resets to false every time the
// SW starts fresh; on iOS the browser kills the SW between pushes, so on the
// next push appIsVisible is always false — we never suppress on a cold SW
// restart, which prevents the "notifications stop after a while" bug where
// stale matchAll() data (visibilityState stuck at 'visible') would cause all
// subsequent notifications to be silently dropped.
//
// When matchAll() returns zero clients (iOS Safari PWA fully-suspended quirk),
// clients.some() returns false — do NOT suppress. Better to show a duplicate
// (handled gracefully by the in-app banner) than to silently drop a
// notification while the app is backgrounded.
const hasVisibleClient =
appIsVisible || clients.some((client) => client.visibilityState === 'visible');
appIsVisible && clients.some((client) => client.visibilityState === 'visible');
console.debug(
'[SW push] appIsVisible:',
appIsVisible,
Expand Down
Loading