mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: re-sync existing push registrations on app start
This commit is contained in:
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
supported: true,
|
||||
enableWebPush: vi.fn(),
|
||||
isWebPushEnabled: vi.fn(),
|
||||
resyncWebPush: vi.fn(),
|
||||
authState: {
|
||||
client: { getAccountId: () => "account-1" } as {
|
||||
getAccountId: () => string;
|
||||
@@ -53,6 +54,7 @@ vi.mock("@/lib/web-push", () => ({
|
||||
enableWebPush: mocks.enableWebPush,
|
||||
isWebPushSupported: () => mocks.supported,
|
||||
isWebPushEnabled: mocks.isWebPushEnabled,
|
||||
resyncWebPush: mocks.resyncWebPush,
|
||||
}));
|
||||
|
||||
function setNotificationPermission(permission: NotificationPermission) {
|
||||
@@ -92,6 +94,7 @@ describe("PushNotificationPrompt", () => {
|
||||
mocks.policyState.policy.pushRelayUrlLocked = false;
|
||||
mocks.enableWebPush.mockReset().mockResolvedValue({ subscriptionId: "sub-1" });
|
||||
mocks.isWebPushEnabled.mockReset().mockImplementation(async () => mocks.enabled);
|
||||
mocks.resyncWebPush.mockReset().mockResolvedValue(false);
|
||||
setNotificationPermission("default");
|
||||
});
|
||||
|
||||
@@ -170,6 +173,29 @@ describe("PushNotificationPrompt", () => {
|
||||
expect(screen.queryByText("title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-syncs an existing registration in the background on mount", async () => {
|
||||
mocks.enabled = true;
|
||||
render(<PushNotificationPrompt />);
|
||||
await advancePromptDelay();
|
||||
|
||||
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resyncWebPush).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
|
||||
}),
|
||||
);
|
||||
// Only the explicit Enable button may start the interactive flow.
|
||||
expect(mocks.enableWebPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch push in demo mode", async () => {
|
||||
mocks.authState.isDemoMode = true;
|
||||
render(<PushNotificationPrompt />);
|
||||
await advancePromptDelay();
|
||||
|
||||
expect(mocks.resyncWebPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render when browser notifications are blocked", async () => {
|
||||
setNotificationPermission("denied");
|
||||
render(<PushNotificationPrompt />);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
enableWebPush,
|
||||
isWebPushEnabled,
|
||||
isWebPushSupported,
|
||||
resyncWebPush,
|
||||
} from "@/lib/web-push";
|
||||
import { resolveActiveRelayUrl } from "@/lib/push-relays";
|
||||
import {
|
||||
@@ -109,6 +110,21 @@ export function PushNotificationPrompt() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Accounts that already have push on get their registration touched up in
|
||||
// the background once per page load: expiry refreshed and the server-side
|
||||
// delivery filter installed or repaired (older registrations predate it and
|
||||
// would keep waking the device for spam). Deliberately independent of the
|
||||
// prompt gating below - dismissing the onboarding prompt must not leave a
|
||||
// live registration stale.
|
||||
useEffect(() => {
|
||||
if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return;
|
||||
void resyncWebPush({
|
||||
client,
|
||||
relayBaseUrl,
|
||||
accountLabel: username ?? undefined,
|
||||
});
|
||||
}, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, relayBaseUrl, username]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
disableWebPush,
|
||||
enableWebPush,
|
||||
listPushDevices,
|
||||
resetWebPushResyncState,
|
||||
resyncWebPush,
|
||||
revokePushDevice,
|
||||
} from '@/lib/web-push';
|
||||
|
||||
@@ -153,6 +155,7 @@ function installFetch(activeByDevice: Record<string, boolean | 'unknown'>) {
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetWebPushResyncState();
|
||||
installPushBrowser();
|
||||
});
|
||||
|
||||
@@ -201,6 +204,63 @@ describe('enableWebPush', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Registrations made before the client learned about the delivery filter are
|
||||
// repaired in the background on app start - nobody should have to find the
|
||||
// settings toggle to stop the spam pushes.
|
||||
describe('resyncWebPush', () => {
|
||||
it('re-syncs an enabled registration and installs the missing filter', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
|
||||
installFetch({});
|
||||
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true);
|
||||
|
||||
expect(client.createPushSubscription).not.toHaveBeenCalled();
|
||||
expect(client.updatePushSubscription).toHaveBeenCalledWith(
|
||||
'push-old',
|
||||
expect.objectContaining({ emailPush: EXPECTED_EMAIL_PUSH }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when push is not enabled for the account', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
const client = makeClient([], { emailPushCapability: true });
|
||||
installFetch({});
|
||||
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false);
|
||||
|
||||
expect(client.listPushSubscriptions).not.toHaveBeenCalled();
|
||||
expect(client.createPushSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs at most once per account per page load', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
|
||||
installFetch({});
|
||||
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true);
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false);
|
||||
|
||||
expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('swallows failures instead of surfacing them to the app', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
client.listPushSubscriptions = vi.fn(async () => {
|
||||
throw new Error('server down');
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
throw new Error('relay down');
|
||||
}));
|
||||
|
||||
await expect(resyncWebPush({ client, relayBaseUrl: RELAY })).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Spam filed straight into Junk used to wake the device: `EmailDelivery`
|
||||
// fires for every ingested message. Servers that implement
|
||||
// draft-ietf-jmap-emailpush evaluate a per-account filter before pushing, so
|
||||
|
||||
@@ -646,3 +646,48 @@ export async function isWebPushEnabled(accountId: string): Promise<boolean> {
|
||||
const sub = await registration.pushManager.getSubscription();
|
||||
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<string>();
|
||||
|
||||
export interface ResyncWebPushParams {
|
||||
client: IJMAPClient;
|
||||
relayBaseUrl?: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring an already-enabled push registration up to date without any user
|
||||
* action: refresh its expiry and install/repair the delivery filter. Nothing
|
||||
* here can prompt - it only runs when push is already on for the account -
|
||||
* and every failure is swallowed because the app must not care whether the
|
||||
* background touch-up worked. Returns true when a re-sync actually ran.
|
||||
*/
|
||||
export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolean> {
|
||||
let accountId: string;
|
||||
try {
|
||||
accountId = params.client.getAccountId();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!accountId || resyncedAccountIds.has(accountId)) return false;
|
||||
try {
|
||||
if (!(await isWebPushEnabled(accountId))) return false;
|
||||
resyncedAccountIds.add(accountId);
|
||||
await enableWebPush({
|
||||
client: params.client,
|
||||
relayBaseUrl: params.relayBaseUrl,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook: forget which accounts were re-synced during this page load.
|
||||
export function resetWebPushResyncState(): void {
|
||||
resyncedAccountIds.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user