diff --git a/src/components/settings/__tests__/web-push-card.browser.test.tsx b/src/components/settings/__tests__/web-push-card.browser.test.tsx
new file mode 100644
index 000000000..ca7ccb331
--- /dev/null
+++ b/src/components/settings/__tests__/web-push-card.browser.test.tsx
@@ -0,0 +1,64 @@
+/**
+ * The Web Push card with a subscription the browser already holds.
+ *
+ * The card reads its state from the browser alone. When the server row was
+ * lost, or the channel had been switched off by failed sends, the card said
+ * "enabled" and no reminder came; opening it now sends the subscription
+ * again, which stores the row and turns the channel back on.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { stubFetch } from "../../../../tests/browser/fetch-stub";
+import { renderWithApp } from "../../../../tests/browser/render";
+
+import { WebPushCard } from "../web-push-card";
+
+const SUBSCRIPTION = {
+ endpoint: "https://push.example.test/send/abc",
+ keys: { p256dh: "p256dh-key", auth: "auth-key" },
+};
+
+function holdSubscription(): void {
+ const subscription = { toJSON: () => SUBSCRIPTION };
+ // Headless Chromium answers "denied" without a prompt.
+ vi.spyOn(Notification, "permission", "get").mockReturnValue("granted");
+ vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue({
+ pushManager: { getSubscription: async () => subscription },
+ } as unknown as ServiceWorkerRegistration);
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("WebPushCard", () => {
+ it("sends the browser's existing subscription to the server when it opens", async () => {
+ holdSubscription();
+ const api = stubFetch().on("POST", "/api/notifications/web-push", {
+ data: { subscribed: true },
+ });
+
+ const { screen } = await renderWithApp();
+
+ await expect
+ .poll(() => api.calls("POST", "/api/notifications/web-push").length)
+ .toBe(1);
+ expect(api.calls("POST", "/api/notifications/web-push")[0].body).toEqual(
+ SUBSCRIPTION,
+ );
+ await expect.element(screen.getByText("Disable push")).toBeVisible();
+ });
+
+ it("keeps showing push as on when that request fails", async () => {
+ holdSubscription();
+ stubFetch().on("POST", "/api/notifications/web-push", {
+ status: 403,
+ error: "Demo mode: modifications are disabled",
+ });
+
+ const { screen } = await renderWithApp();
+
+ await expect.element(screen.getByText("Disable push")).toBeVisible();
+ expect(document.querySelector('[role="alert"]')).toBeNull();
+ });
+});
diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx
index f25ae2795..e7c19b0d7 100644
--- a/src/components/settings/web-push-card.tsx
+++ b/src/components/settings/web-push-card.tsx
@@ -38,6 +38,27 @@ export function needsHomeScreen(): boolean {
return appleTouch && !window.matchMedia("(display-mode: standalone)").matches;
}
+/**
+ * Store this browser's subscription for the signed-in account. Also run for a
+ * subscription the browser already holds, so a row the server lost or gave
+ * to another account on this browser comes back, and a channel switched off
+ * by failed sends is turned on again.
+ */
+function saveSubscription(subscription: PushSubscription): Promise {
+ const subJson = subscription.toJSON();
+ return apiFetchRaw("/api/notifications/web-push", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ endpoint: subJson.endpoint,
+ keys: {
+ p256dh: subJson.keys?.p256dh ?? "",
+ auth: subJson.keys?.auth ?? "",
+ },
+ }),
+ });
+}
+
type Unsupported = "browser" | "install" | null;
export function WebPushCard() {
@@ -73,6 +94,7 @@ export function WebPushCard() {
if (registration) {
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
+ if (subscription) void saveSubscription(subscription).catch(() => {});
}
} catch {
// Ignore errors during check
@@ -111,20 +133,7 @@ export function WebPushCard() {
applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
});
- const subJson = subscription.toJSON();
-
- const res = await apiFetchRaw("/api/notifications/web-push", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- endpoint: subJson.endpoint,
- keys: {
- p256dh: subJson.keys?.p256dh ?? "",
- auth: subJson.keys?.auth ?? "",
- },
- }),
- });
-
+ const res = await saveSubscription(subscription);
if (res.ok) {
setIsSubscribed(true);
setMsg(t("settings.webPushSubscribed"));