diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index 4ca380f6f..dcae89ac9 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -118,11 +118,19 @@ export function PushNotificationPrompt() { // live registration stale. useEffect(() => { if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return; - void resyncWebPush({ - client, - relayBaseUrl, - accountLabel: username ?? undefined, - }); + const resync = () => { + void resyncWebPush({ + client, + relayBaseUrl, + accountLabel: username ?? undefined, + }); + }; + resync(); + // A long-open tab comes back to the foreground: renew the subscription + // before the server's 7-day expiry (resyncWebPush runs at most daily). + const onVisible = () => { if (document.visibilityState === 'visible') resync(); }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); }, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, relayBaseUrl, username]); useEffect(() => { diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 369266b2a..af35b9b0f 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -234,16 +234,22 @@ describe('resyncWebPush', () => { expect(client.createPushSubscription).not.toHaveBeenCalled(); }); - it('runs at most once per account per page load', async () => { + it('runs at most once a day per account', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); installFetch({}); + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000); expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true); expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false); - expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1); + + // A tab left open past a day renews again, before Stalwart's 7-day expiry. + now.mockReturnValue(1_000_000 + 25 * 60 * 60 * 1000); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true); + expect(client.listPushSubscriptions).toHaveBeenCalledTimes(2); + now.mockRestore(); }); it('swallows failures instead of surfacing them to the app', async () => { diff --git a/lib/web-push.ts b/lib/web-push.ts index 973e1cd5f..0af89c82d 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -651,10 +651,13 @@ export async function isWebPushEnabled(accountId: string): Promise { return sub !== null && localStorage.getItem(subscriptionIdKey(accountId)) !== null; } -// Accounts already re-synced during this page load. One pass per account is -// plenty: the subscription only drifts between sessions (expiry, a client -// update that changed what we subscribe to, a recreated Junk mailbox). -const resyncedAccountIds = new Set(); +// When each account was last re-synced during this page load. Once a day is +// enough for the drift between sessions (a client update that changed what +// we subscribe to, a recreated Junk mailbox), but not only once: Stalwart +// clamps `expires` to 7 days, so a tab or installed app left open for a +// week would otherwise let the subscription lapse and push stop silently. +const RESYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; +const lastResyncAt = new Map(); export interface ResyncWebPushParams { client: IJMAPClient; @@ -676,10 +679,12 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise