From cfa834510e560289b9116578fb13968dbb135d90 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 14 Nov 2026 08:56:18 +0100
Subject: [PATCH 1/2] fix(nav): open the records inside the account menu on a
phone
The record switcher opened as a side sub-menu next to the account menu.
On a phone the account menu sits against the right edge and takes most
of the width, so the 240 px sub-menu had room on neither side and ran
off the screen with the names cut off. Below the sm breakpoint the
trigger now unfolds the records in place inside the account menu;
wider screens keep the sub-menu. The trigger, list and entry slots are
unchanged, so the journeys read the same at either width.
Refs #1372
---
CHANGELOG.md | 8 +
.../account-switcher-menu.browser.test.tsx | 182 +++++++++++++++
.../layout/account-switcher-menu.tsx | 220 +++++++++++++-----
3 files changed, 347 insertions(+), 63 deletions(-)
create mode 100644 src/components/layout/__tests__/account-switcher-menu.browser.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4058e701d..67e4b7628 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## [Unreleased]
+### Fixed
+
+- **Opening a record on a phone.** In the account menu, "Open a record"
+ used to open a second menu beside the first, and on a narrow screen it
+ ran off the edge and cut the names off. On a phone the records now
+ unfold inside the account menu itself; wider screens keep the side
+ menu. (#1372)
+
## [1.47.5] — 2026-11-12
### Fixed
diff --git a/src/components/layout/__tests__/account-switcher-menu.browser.test.tsx b/src/components/layout/__tests__/account-switcher-menu.browser.test.tsx
new file mode 100644
index 000000000..7b64edd10
--- /dev/null
+++ b/src/components/layout/__tests__/account-switcher-menu.browser.test.tsx
@@ -0,0 +1,182 @@
+/**
+ * The record switcher inside the account menu, at phone and desktop width
+ * (#1372).
+ *
+ * On a phone the account menu sits against the right edge of the top bar and
+ * takes most of the width. The records used to open in a side sub-menu that
+ * had room on neither side, so it ran off the screen and cut the names off.
+ * Below `sm` they now unfold inside the account menu itself; at desktop width
+ * the side sub-menu stays.
+ *
+ * The harness mounts the switcher the way the top bar does: a right-aligned
+ * trigger, `align="end"`, a `w-60` menu.
+ *
+ * Mutation check, run: forcing the side sub-menu at every width (the
+ * component before the fix) turns "unfolds the records inside the account
+ * menu" red, because the entries land outside the viewport and outside the
+ * account menu.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { page, userEvent } from "vitest/browser";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
+
+import { renderWithApp } from "../../../../tests/browser/render";
+
+const switchTo = vi.fn();
+const mutate = vi.fn();
+
+vi.mock("@/hooks/use-account-switch", () => ({
+ useAccountSwitch: () => ({ mutate, switchTo, isPending: false }),
+}));
+
+import { AccountSwitcherMenuItems } from "../account-switcher-menu";
+
+function entry(
+ accountId: string,
+ fullName: string,
+ recordKind: "shared" | "managed",
+): AccountAccessEntry {
+ return {
+ accountId,
+ username: accountId,
+ displayName: null,
+ fullName,
+ access: "read",
+ level: "read",
+ sections: null,
+ recordKind,
+ canWrite: false,
+ writableDomains: [],
+ manageableDomains: [],
+ };
+}
+
+const ACCOUNTS = [
+ entry("acct-a", "Alexandra Konstantinopoulou-Wetherington", "shared"),
+ entry("acct-b", "Maximilian Oberhausen", "managed"),
+];
+
+async function mountMenu() {
+ await renderWithApp(
+
+
+ Account
+
+ Settings
+
+
+
+
,
+ {
+ user: {
+ accountAccess: { accounts: ACCOUNTS, active: null, canSwitch: true },
+ },
+ },
+ );
+ await page.getByRole("button", { name: "Account" }).click();
+ await expect
+ .poll(() =>
+ document.querySelector('[data-slot="account-switcher-trigger"]'),
+ )
+ .toBeTruthy();
+}
+
+function trigger(): HTMLElement {
+ return document.querySelector(
+ '[data-slot="account-switcher-trigger"]',
+ )!;
+}
+
+function entries(): HTMLElement[] {
+ return [
+ ...document.querySelectorAll(
+ '[data-slot="account-switcher-entry"]',
+ ),
+ ];
+}
+
+afterEach(() => {
+ switchTo.mockClear();
+ mutate.mockClear();
+});
+
+describe("the record switcher at phone width", () => {
+ it("unfolds the records inside the account menu", async () => {
+ await page.viewport(390, 844);
+ await mountMenu();
+
+ await userEvent.click(trigger());
+ await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
+
+ const accountMenu = trigger().closest('[role="menu"]');
+ for (const row of entries()) {
+ // In the account menu, not in a menu of its own beside it.
+ expect(row.closest('[role="menu"]')).toBe(accountMenu);
+ const box = row.getBoundingClientRect();
+ expect(box.left).toBeGreaterThanOrEqual(0);
+ expect(box.right).toBeLessThanOrEqual(window.innerWidth);
+ // A finger-sized row.
+ expect(box.height).toBeGreaterThanOrEqual(44);
+ }
+ expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1);
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("keeps the account menu open while the list unfolds and folds", async () => {
+ await page.viewport(390, 844);
+ await mountMenu();
+
+ expect(entries()).toHaveLength(0);
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+
+ await userEvent.click(trigger());
+ await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
+
+ await userEvent.click(trigger());
+ await expect.poll(() => entries().length).toBe(0);
+ // The account menu itself is still there.
+ expect(trigger()).toBeTruthy();
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("opens the chosen record from the inline list", async () => {
+ await page.viewport(390, 844);
+ await mountMenu();
+
+ await userEvent.click(trigger());
+ await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
+ await userEvent.click(
+ document.querySelector(
+ '[data-slot="account-switcher-entry"][data-account-id="acct-b"]',
+ )!,
+ );
+
+ expect(switchTo).toHaveBeenCalledTimes(1);
+ expect(switchTo.mock.calls[0][0]).toBe("acct-b");
+ });
+});
+
+describe("the record switcher at desktop width", () => {
+ it("keeps the side sub-menu", async () => {
+ await page.viewport(1280, 900);
+ await mountMenu();
+
+ expect(trigger().hasAttribute("data-presentation")).toBe(false);
+ await userEvent.click(trigger());
+ await expect.poll(() => entries().length).toBe(ACCOUNTS.length);
+
+ const accountMenu = trigger().closest('[role="menu"]');
+ for (const row of entries()) {
+ const menu = row.closest('[role="menu"]');
+ expect(menu).toBeTruthy();
+ expect(menu).not.toBe(accountMenu);
+ }
+ });
+});
diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx
index 54abd94ca..98f2e98f0 100644
--- a/src/components/layout/account-switcher-menu.tsx
+++ b/src/components/layout/account-switcher-menu.tsx
@@ -1,8 +1,10 @@
"use client";
-import { Check, Loader2, Users } from "lucide-react";
+import { useId, useState } from "react";
+import { Check, ChevronDown, Loader2, Users } from "lucide-react";
import {
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
@@ -11,10 +13,15 @@ import {
} from "@/components/ui/dropdown-menu";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { useAuth } from "@/hooks/use-auth";
+import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
-import { accountLabel } from "@/lib/sharing/account-access-view";
+import {
+ accountLabel,
+ type AccountAccess,
+} from "@/lib/sharing/account-access-view";
+import { cn } from "@/lib/utils";
/**
* v1.36.0 — the switcher, as a slice of the user menu.
@@ -35,16 +42,83 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
* "Your own record" is a first-class entry rather than only a banner action:
* the way out has to be reachable from the same menu as the way in, because
* that is where somebody will look for it.
+ *
+ * Below the `sm` breakpoint the records open inside the account menu rather
+ * than in a sub-menu beside it. The phone's account menu sits against the
+ * right edge and takes most of the width; a 240 px sub-menu has room on
+ * neither side, and Radix only shifts a side menu along its own axis, so it
+ * ran off the screen and cut the names off. The trigger keeps its slot and the
+ * list keeps `account-switcher-menu`, so a journey that taps the trigger and
+ * then an entry reads the same at either width.
*/
export function AccountSwitcherMenuItems() {
const { user } = useAuth();
const { t } = useTranslations();
const switchAccount = useAccountSwitch();
+ const inline = useIsMobile("sm");
+ const [expanded, setExpanded] = useState(false);
+ const listId = useId();
const access = user?.accountAccess;
if (!access?.canSwitch) return null;
const activeId = access.active?.accountId ?? null;
+ const label = t("recordSharing.switcher.label");
+ const icon = switchAccount.isPending ? (
+
+ ) : (
+
+ );
+ const rows = (
+
+ );
+
+ if (inline) {
+ return (
+ <>
+
+ {
+ event.preventDefault();
+ setExpanded((open) => !open);
+ }}
+ >
+ {icon}
+ {label}
+
+
+ {expanded && (
+
+ {rows}
+
+ )}
+ >
+ );
+ }
return (
<>
@@ -54,72 +128,14 @@ export function AccountSwitcherMenuItems() {
data-slot="account-switcher-trigger"
className="min-h-11 py-2 sm:min-h-9"
>
- {switchAccount.isPending ? (
-
- ) : (
-
- )}
- {t("recordSharing.switcher.label")}
+ {icon}
+ {label}
- switchAccount.mutate(null)}
- >
-
- {t("recordSharing.switcher.ownRecord")}
-
- {activeId === null && (
- ✓
- )}
-
- {access.accounts.map((account) => {
- const presentation = resolveRecordPresentation(account);
-
- return (
-
- switchAccount.switchTo(
- account.accountId,
- sharedRecordLandingHref(account.sections),
- )
- }
- >
- {/* Two lines, not three. The access level had a line of its
- own here and it never fit — a 240 px menu truncated
- "Kann verwalten" as often as it showed it, and the entry
- still said which kind of record this is on the line below.
- The level is what the banner states once you are inside,
- where it governs what the page lets you do. It stays on
- the row as `data-access-level` for the browser journeys. */}
-
- {accountLabel(account)}
-
- {recordKindLabel(presentation.recordKind, t)}
-
-
- {activeId === account.accountId && (
-
- )}
-
- );
- })}
+ {rows}
>
@@ -134,3 +150,81 @@ function recordKindLabel(
? t("recordSharing.lookingAfter.kindManaged")
: t("recordSharing.lookingAfter.kindShared");
}
+
+/**
+ * The rows themselves: your own record first, then every record the server
+ * published. The same rows in the side sub-menu and in the inline list; the
+ * inline list indents them under the trigger (`inset`).
+ */
+function RecordRows({
+ access,
+ activeId,
+ inset,
+ switchAccount,
+}: {
+ access: AccountAccess;
+ activeId: string | null;
+ inset: boolean;
+ switchAccount: ReturnType;
+}) {
+ const { t } = useTranslations();
+
+ return (
+ <>
+ switchAccount.mutate(null)}
+ >
+
+ {t("recordSharing.switcher.ownRecord")}
+
+ {activeId === null && (
+ ✓
+ )}
+
+ {access.accounts.map((account) => {
+ const presentation = resolveRecordPresentation(account);
+
+ return (
+
+ switchAccount.switchTo(
+ account.accountId,
+ sharedRecordLandingHref(account.sections),
+ )
+ }
+ >
+ {/* Two lines, not three. The access level had a line of its
+ own here and it never fit — a 240 px menu truncated
+ "Kann verwalten" as often as it showed it, and the entry
+ still said which kind of record this is on the line below.
+ The level is what the banner states once you are inside,
+ where it governs what the page lets you do. It stays on
+ the row as `data-access-level` for the browser journeys. */}
+
+ {accountLabel(account)}
+
+ {recordKindLabel(presentation.recordKind, t)}
+
+
+ {activeId === account.accountId && (
+
+ )}
+
+ );
+ })}
+ >
+ );
+}
From 422decc9d9b153e01d17a769b9e7515a0409c10a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 14 Nov 2026 08:56:25 +0100
Subject: [PATCH 2/2] fix(notifications): say when the browser's push service
refuses
Enabling Browser Push showed only "Activation failed" whenever
pushManager.subscribe() rejected. Brave keeps push messaging off by
default and rejects with "AbortError: Registration failed - push
service error", which left people with nothing to act on.
The browser's own refusal is now classified apart from the server
answers around it. An AbortError from subscribe reads as the push
service refusing and links the Browser Push docs; in Brave the message
names the setting under brave://settings/privacy. A blocked permission
stays blocked, and a dismissed prompt or a rejected key keeps the
generic message. The subscribe flow moves out of the card component so
it stays under the function size baseline.
Refs Discussion #1378
---
CHANGELOG.md | 6 +
messages/de.json | 3 +
messages/en.json | 3 +
messages/es.json | 3 +
messages/fr.json | 3 +
messages/it.json | 3 +
messages/ko.json | 3 +
messages/nl.json | 3 +
messages/pl.json | 3 +
messages/pt-BR.json | 3 +
messages/ru.json | 3 +
.../__tests__/web-push-card.browser.test.tsx | 68 +++++++-
src/components/settings/web-push-card.tsx | 137 +++++++++++-----
.../web-push-subscribe-failure.test.ts | 146 ++++++++++++++++++
.../web-push-subscribe-failure.ts | 67 ++++++++
15 files changed, 412 insertions(+), 42 deletions(-)
create mode 100644 src/lib/notifications/__tests__/web-push-subscribe-failure.test.ts
create mode 100644 src/lib/notifications/web-push-subscribe-failure.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67e4b7628..69aedc6e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@
ran off the edge and cut the names off. On a phone the records now
unfold inside the account menu itself; wider screens keep the side
menu. (#1372)
+- **A clearer message when the browser refuses push.** When the browser's
+ push service turned the registration down, enabling Browser Push said
+ only "Activation failed". It now says that the push service refused and
+ links the help page. Brave keeps push messaging off by default, so in
+ Brave the message names the setting to turn on: "Use Google services
+ for push messaging" under brave://settings/privacy. (Discussion #1378)
## [1.47.5] — 2026-11-12
diff --git a/messages/de.json b/messages/de.json
index 77b23e200..3d4b52554 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Push-Benachrichtigungen aktiviert!",
"webPushUnsubscribed": "Push-Benachrichtigungen deaktiviert.",
"webPushSubscribeFailed": "Aktivierung fehlgeschlagen",
+ "webPushServiceRefused": "Der Push-Dienst deines Browsers hat die Anmeldung abgelehnt, deshalb ließ sich Push nicht einschalten. Der Browser muss seinen Push-Dienst erreichen, und eine Datenschutzeinstellung, ein Blocker oder das Netzwerk kann das verhindern. Versuche es später noch einmal oder in einem anderen Browser.",
+ "webPushServiceRefusedBrave": "Der Push-Dienst von Brave hat die Anmeldung abgelehnt. Brave lässt Push ab Werk ausgeschaltet: Öffne brave://settings/privacy, schalte dort die Nutzung von Google-Diensten für Push-Nachrichten ein und tippe dann noch einmal auf „Push aktivieren“.",
+ "webPushHelpLink": "Hilfe zu Browser-Push",
"codexConnected": "ChatGPT erfolgreich verbunden! Insights sind jetzt aktiv.",
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
diff --git a/messages/en.json b/messages/en.json
index 6705917dc..3c2d410d5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Push notifications enabled!",
"webPushUnsubscribed": "Push notifications disabled.",
"webPushSubscribeFailed": "Activation failed",
+ "webPushServiceRefused": "Your browser's push service refused the registration, so push could not be turned on. The browser has to reach its push service, and a privacy setting, a blocker or the network can stop it. Try again later or in another browser.",
+ "webPushServiceRefusedBrave": "Brave's push service refused the registration. Brave keeps push off by default: open brave://settings/privacy, turn on “Use Google services for push messaging” and tap Enable push again.",
+ "webPushHelpLink": "Help with browser push",
"codexConnected": "ChatGPT connected successfully! Insights are now active.",
"codexDisconnected": "ChatGPT connection removed.",
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
diff --git a/messages/es.json b/messages/es.json
index 791a45a5b..a51d8607e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "¡Notificaciones push activadas!",
"webPushUnsubscribed": "Notificaciones push desactivadas.",
"webPushSubscribeFailed": "Falló la activación",
+ "webPushServiceRefused": "El servicio push de tu navegador rechazó el registro, así que no se pudo activar push. El navegador tiene que llegar a su servicio push, y una opción de privacidad, un bloqueador o la red pueden impedirlo. Vuelve a intentarlo más tarde o en otro navegador.",
+ "webPushServiceRefusedBrave": "El servicio push de Brave rechazó el registro. Brave trae push desactivado de fábrica: abre brave://settings/privacy, activa el uso de los servicios de Google para la mensajería push y toca otra vez «Activar push».",
+ "webPushHelpLink": "Ayuda con push en el navegador",
"codexConnected": "¡ChatGPT conectado correctamente! Insights ya está activo.",
"codexDisconnected": "Conexión con ChatGPT eliminada.",
"codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.",
diff --git a/messages/fr.json b/messages/fr.json
index 574570c36..0e8816535 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Notifications push activées !",
"webPushUnsubscribed": "Notifications push désactivées.",
"webPushSubscribeFailed": "Échec de l’activation",
+ "webPushServiceRefused": "Le service push de votre navigateur a refusé l’inscription, le push n’a donc pas pu être activé. Le navigateur doit pouvoir joindre son service push, et un réglage de confidentialité, un bloqueur ou le réseau peut l’en empêcher. Réessayez plus tard ou dans un autre navigateur.",
+ "webPushServiceRefusedBrave": "Le service push de Brave a refusé l’inscription. Brave désactive le push par défaut : ouvrez brave://settings/privacy, activez l’utilisation des services Google pour la messagerie push, puis touchez à nouveau « Activer le push ».",
+ "webPushHelpLink": "Aide sur le push du navigateur",
"codexConnected": "ChatGPT connecté ! Insights est maintenant actif.",
"codexDisconnected": "Connexion à ChatGPT supprimée.",
"codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.",
diff --git a/messages/it.json b/messages/it.json
index c0158d044..f73de41b3 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Notifiche push attivate!",
"webPushUnsubscribed": "Notifiche push disattivate.",
"webPushSubscribeFailed": "Attivazione non riuscita",
+ "webPushServiceRefused": "Il servizio push del browser ha rifiutato la registrazione, quindi non è stato possibile attivare il push. Il browser deve raggiungere il proprio servizio push, e un'impostazione sulla privacy, un blocco dei contenuti o la rete possono impedirlo. Riprova più tardi o con un altro browser.",
+ "webPushServiceRefusedBrave": "Il servizio push di Brave ha rifiutato la registrazione. Brave tiene il push disattivato di serie: apri brave://settings/privacy, attiva l'uso dei servizi Google per la messaggistica push e tocca di nuovo «Attiva push».",
+ "webPushHelpLink": "Aiuto sul push del browser",
"codexConnected": "ChatGPT connesso correttamente! Insights è ora attivo.",
"codexDisconnected": "Connessione a ChatGPT rimossa.",
"codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.",
diff --git a/messages/ko.json b/messages/ko.json
index 5ff7cafa4..5f1f95d9b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "푸시 알림을 켰어요!",
"webPushUnsubscribed": "푸시 알림을 껐어요.",
"webPushSubscribeFailed": "켜지 못했어요",
+ "webPushServiceRefused": "브라우저의 푸시 서비스가 등록을 거부해서 푸시를 켜지 못했어요. 브라우저가 푸시 서비스에 연결할 수 있어야 하는데, 개인정보 보호 설정이나 차단 프로그램, 네트워크가 이를 막을 수 있어요. 나중에 다시 시도하거나 다른 브라우저에서 시도해 주세요.",
+ "webPushServiceRefusedBrave": "Brave의 푸시 서비스가 등록을 거부했어요. Brave는 기본적으로 푸시가 꺼져 있어요. brave://settings/privacy를 열고 푸시 메시지에 Google 서비스를 사용하는 설정을 켠 다음 '푸시 켜기'를 다시 눌러 주세요.",
+ "webPushHelpLink": "브라우저 푸시 도움말",
"codexConnected": "ChatGPT를 연결했어요! 이제 인사이트를 쓸 수 있어요.",
"codexDisconnected": "ChatGPT 연결을 해제했어요.",
"codexConnectionFailed": "ChatGPT 연결에 실패했어요. 다시 시도해 주세요.",
diff --git a/messages/nl.json b/messages/nl.json
index 6c21d8933..a0d5d7cd3 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Pushmeldingen ingeschakeld!",
"webPushUnsubscribed": "Pushmeldingen uitgeschakeld.",
"webPushSubscribeFailed": "Activeren mislukt",
+ "webPushServiceRefused": "De pushdienst van je browser heeft de registratie geweigerd, dus push kon niet worden ingeschakeld. De browser moet zijn pushdienst kunnen bereiken, en een privacyinstelling, een blocker of het netwerk kan dat tegenhouden. Probeer het later opnieuw of in een andere browser.",
+ "webPushServiceRefusedBrave": "De pushdienst van Brave heeft de registratie geweigerd. Brave zet push standaard uit: open brave://settings/privacy, zet het gebruik van Google-services voor pushberichten aan en tik daarna opnieuw op 'Push inschakelen'.",
+ "webPushHelpLink": "Hulp bij browserpush",
"codexConnected": "ChatGPT gekoppeld! Inzichten zijn nu actief.",
"codexDisconnected": "Koppeling met ChatGPT verwijderd.",
"codexConnectionFailed": "Koppelen met ChatGPT is mislukt. Probeer het opnieuw.",
diff --git a/messages/pl.json b/messages/pl.json
index d99f6a64d..6be2c1707 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Powiadomienia push włączone!",
"webPushUnsubscribed": "Powiadomienia push wyłączone.",
"webPushSubscribeFailed": "Aktywacja nie powiodła się",
+ "webPushServiceRefused": "Usługa push Twojej przeglądarki odrzuciła rejestrację, więc nie udało się włączyć push. Przeglądarka musi mieć dostęp do swojej usługi push, a może to zablokować ustawienie prywatności, bloker albo sieć. Spróbuj ponownie później albo w innej przeglądarce.",
+ "webPushServiceRefusedBrave": "Usługa push przeglądarki Brave odrzuciła rejestrację. Brave domyślnie ma wyłączony push: otwórz brave://settings/privacy, włącz korzystanie z usług Google do wiadomości push i stuknij ponownie „Włącz push”.",
+ "webPushHelpLink": "Pomoc dotycząca push w przeglądarce",
"codexConnected": "ChatGPT połączony pomyślnie! Insights jest teraz aktywny.",
"codexDisconnected": "Połączenie z ChatGPT usunięte.",
"codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.",
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 1ea7da102..8f58fb8a6 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Notificações push ativadas!",
"webPushUnsubscribed": "Notificações push desativadas.",
"webPushSubscribeFailed": "Falha na ativação",
+ "webPushServiceRefused": "O serviço de push do seu navegador recusou o registro, então não foi possível ativar o push. O navegador precisa alcançar o serviço de push dele, e uma configuração de privacidade, um bloqueador ou a rede podem impedir isso. Tente de novo mais tarde ou em outro navegador.",
+ "webPushServiceRefusedBrave": "O serviço de push do Brave recusou o registro. O Brave vem com o push desligado: abra brave://settings/privacy, ative o uso dos serviços do Google para mensagens push e toque de novo em “Ativar push”.",
+ "webPushHelpLink": "Ajuda com push no navegador",
"codexConnected": "ChatGPT conectado com sucesso! Os insights agora estão ativos.",
"codexDisconnected": "Conexão com o ChatGPT removida.",
"codexConnectionFailed": "Falha na conexão com o ChatGPT. Tente novamente.",
diff --git a/messages/ru.json b/messages/ru.json
index 0d451a0c5..a6df58670 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -6429,6 +6429,9 @@
"webPushSubscribed": "Push-уведомления включены!",
"webPushUnsubscribed": "Push-уведомления выключены.",
"webPushSubscribeFailed": "Не удалось активировать",
+ "webPushServiceRefused": "Push-сервис вашего браузера отклонил регистрацию, поэтому push не удалось включить. Браузер должен иметь доступ к своему push-сервису, а этому может помешать настройка конфиденциальности, блокировщик или сеть. Попробуйте позже или в другом браузере.",
+ "webPushServiceRefusedBrave": "Push-сервис Brave отклонил регистрацию. В Brave push по умолчанию выключен: откройте brave://settings/privacy, включите использование сервисов Google для push-сообщений и снова нажмите «Включить push».",
+ "webPushHelpLink": "Справка по push в браузере",
"codexConnected": "ChatGPT успешно подключён! Аналитика теперь активна.",
"codexDisconnected": "Подключение ChatGPT удалено.",
"codexConnectionFailed": "Не удалось подключить ChatGPT. Попробуйте снова.",
diff --git a/src/components/settings/__tests__/web-push-card.browser.test.tsx b/src/components/settings/__tests__/web-push-card.browser.test.tsx
index ca7ccb331..8d1b3cfd5 100644
--- a/src/components/settings/__tests__/web-push-card.browser.test.tsx
+++ b/src/components/settings/__tests__/web-push-card.browser.test.tsx
@@ -11,7 +11,7 @@ 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";
+import { WEB_PUSH_DOCS_HREF, WebPushCard } from "../web-push-card";
const SUBSCRIPTION = {
endpoint: "https://push.example.test/send/abc",
@@ -62,3 +62,69 @@ describe("WebPushCard", () => {
expect(document.querySelector('[role="alert"]')).toBeNull();
});
});
+
+/**
+ * A browser whose push service refuses the registration, as Brave does with
+ * push messaging switched off: `subscribe()` rejects with an `AbortError`.
+ */
+function refuseAtPushService(): void {
+ vi.spyOn(Notification, "permission", "get").mockReturnValue("granted");
+ vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue(
+ undefined,
+ );
+ const refusal = new DOMException(
+ "Registration failed - push service error",
+ "AbortError",
+ );
+ vi.spyOn(navigator.serviceWorker, "ready", "get").mockReturnValue(
+ Promise.resolve({
+ pushManager: { subscribe: () => Promise.reject(refusal) },
+ } as unknown as ServiceWorkerRegistration),
+ );
+}
+
+function alertText(): string {
+ return document.querySelector('[role="alert"]')?.textContent ?? "";
+}
+
+describe("WebPushCard when the push service refuses", () => {
+ afterEach(() => {
+ delete (navigator as Navigator & { brave?: unknown }).brave;
+ });
+
+ it("says the push service refused and links the docs", async () => {
+ refuseAtPushService();
+ const api = stubFetch().on("GET", "/api/notifications/vapid", {
+ data: { publicKey: "BAAAAAAA" },
+ });
+
+ const { screen } = await renderWithApp();
+ await screen.getByRole("button", { name: "Enable push" }).click();
+
+ await expect
+ .poll(() => alertText())
+ .toContain("Your browser's push service refused the registration");
+ await expect
+ .element(screen.getByRole("link", { name: "Help with browser push" }))
+ .toHaveAttribute("href", WEB_PUSH_DOCS_HREF);
+ // Nothing reached the server: the browser refused before a save.
+ expect(api.calls("POST", "/api/notifications/web-push")).toHaveLength(0);
+ });
+
+ it("names the setting in Brave", async () => {
+ refuseAtPushService();
+ Object.defineProperty(navigator, "brave", {
+ value: { isBrave: async () => true },
+ configurable: true,
+ });
+ stubFetch().on("GET", "/api/notifications/vapid", {
+ data: { publicKey: "BAAAAAAA" },
+ });
+
+ const { screen } = await renderWithApp();
+ await screen.getByRole("button", { name: "Enable push" }).click();
+
+ await expect.poll(() => alertText()).toContain("brave://settings/privacy");
+ expect(alertText()).toContain("Use Google services for push messaging");
+ });
+});
diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx
index f64c7d440..3ac712be7 100644
--- a/src/components/settings/web-push-card.tsx
+++ b/src/components/settings/web-push-card.tsx
@@ -1,9 +1,11 @@
"use client";
import { useEffect, useState } from "react";
-import { BellRing, Loader2 } from "lucide-react";
+import { BellRing, ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { InlineAction } from "@/components/ui/inline-action";
+import { DOCS_GUIDES_BASE } from "@/components/settings/integrations/setup-guide-link";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -14,6 +16,14 @@ import {
saveWebPushSubscription,
unsubscribeWebPush,
} from "@/lib/notifications/web-push-subscription";
+import {
+ classifyWebPushSubscribeFailure,
+ isBraveBrowser,
+ type WebPushSubscribeFailure,
+} from "@/lib/notifications/web-push-subscribe-failure";
+
+/** The docs section on Browser Push, linked when the browser refused. */
+export const WEB_PUSH_DOCS_HREF = `${DOCS_GUIDES_BASE}/reminder-channels/#web-push-vapid`;
/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */
function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
@@ -43,6 +53,67 @@ export function needsHomeScreen(): boolean {
type Unsupported = "browser" | "install" | null;
+/** What one press of "Enable push" ended in. */
+type SubscribeOutcome =
+ "subscribed" | "not-configured" | "save-failed" | WebPushSubscribeFailure;
+
+/**
+ * Subscribe this browser and store the subscription on the server. The
+ * browser's own refusal is classified apart from the server's answers around
+ * it: Brave, for one, rejects with "push service error" until push messaging
+ * is switched on in its privacy settings, and "Activation failed" told nobody
+ * that.
+ */
+async function subscribeThisBrowser(): Promise {
+ const vapidRes = await apiFetchRaw("/api/notifications/vapid");
+ if (!vapidRes.ok) return "not-configured";
+ const vapidData = await vapidRes.json();
+ const vapidPublicKey = vapidData.data.publicKey;
+
+ // Defer to the app-wide registration (`` in the
+ // root providers) rather than re-registering here — same scope, so a second
+ // `register("/sw.js")` would only return the existing worker. `ready`
+ // resolves with the active registration once it is controlling the page.
+ const registration = await navigator.serviceWorker.ready;
+
+ let subscription: PushSubscription;
+ try {
+ subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
+ });
+ } catch (error) {
+ return classifyWebPushSubscribeFailure(error, {
+ permission: Notification.permission,
+ isBrave: isBraveBrowser(),
+ });
+ }
+
+ const res = await saveWebPushSubscription(subscription);
+ return res.ok ? "subscribed" : "save-failed";
+}
+
+function subscribeOutcomeMessage(
+ outcome: SubscribeOutcome,
+ t: ReturnType["t"],
+): string {
+ switch (outcome) {
+ case "subscribed":
+ return t("settings.webPushSubscribed");
+ case "not-configured":
+ return t("settings.webPushNotConfigured");
+ case "denied":
+ return t("settings.webPushDenied");
+ case "push-service":
+ return t("settings.webPushServiceRefused");
+ case "push-service-brave":
+ return t("settings.webPushServiceRefusedBrave");
+ case "save-failed":
+ case "unknown":
+ return t("settings.webPushSubscribeFailed");
+ }
+}
+
export function WebPushCard() {
const { t } = useTranslations();
const [isSubscribed, setIsSubscribed] = useState(false);
@@ -52,6 +123,7 @@ export function WebPushCard() {
const [actionLoading, setActionLoading] = useState(false);
const [msg, setMsg] = useState(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
+ const [showHelp, setShowHelp] = useState(false);
useEffect(() => {
// Async I/O (browser API + service worker registration). The lint rule
@@ -95,50 +167,21 @@ export function WebPushCard() {
setActionLoading(true);
setMsg(null);
setMsgType(null);
+ setShowHelp(false);
+ let outcome: SubscribeOutcome;
try {
- const vapidRes = await apiFetchRaw("/api/notifications/vapid");
- if (!vapidRes.ok) {
- setMsg(t("settings.webPushNotConfigured"));
- setMsgType("error");
- setActionLoading(false);
- return;
- }
- const vapidData = await vapidRes.json();
- const vapidPublicKey = vapidData.data.publicKey;
-
- // Defer to the app-wide registration (`` in
- // the root providers) rather than re-registering here — same scope,
- // so a second `register("/sw.js")` would only return the existing
- // worker. `ready` resolves with the active registration once it is
- // controlling the page.
- const registration = await navigator.serviceWorker.ready;
-
- const subscription = await registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey),
- });
-
- const res = await saveWebPushSubscription(subscription);
- if (res.ok) {
- setIsSubscribed(true);
- setMsg(t("settings.webPushSubscribed"));
- setMsgType("success");
- } else {
- setMsg(t("settings.webPushSubscribeFailed"));
- setMsgType("error");
- }
+ outcome = await subscribeThisBrowser();
} catch {
- if (Notification.permission === "denied") {
- setIsDenied(true);
- setMsg(t("settings.webPushDenied"));
- } else {
- setMsg(t("settings.webPushSubscribeFailed"));
- }
- setMsgType("error");
- } finally {
- setActionLoading(false);
+ outcome = Notification.permission === "denied" ? "denied" : "unknown";
}
+ if (outcome === "subscribed") setIsSubscribed(true);
+ if (outcome === "denied") setIsDenied(true);
+ setMsg(subscribeOutcomeMessage(outcome, t));
+ setMsgType(outcome === "subscribed" ? "success" : "error");
+ // The push service refused: the fix is in the browser, so link the docs.
+ setShowHelp(outcome === "push-service" || outcome === "push-service-brave");
+ setActionLoading(false);
}
async function handleUnsubscribe() {
@@ -232,6 +275,18 @@ export function WebPushCard() {
{msg}
)}
+ {msg && showHelp && (
+
+
+ {t("settings.webPushHelpLink")}
+
+
+
+ )}
) : null}
diff --git a/src/lib/notifications/__tests__/web-push-subscribe-failure.test.ts b/src/lib/notifications/__tests__/web-push-subscribe-failure.test.ts
new file mode 100644
index 000000000..fed7f6d71
--- /dev/null
+++ b/src/lib/notifications/__tests__/web-push-subscribe-failure.test.ts
@@ -0,0 +1,146 @@
+/**
+ * How the Browser Push card reads a refusal from `pushManager.subscribe()`.
+ *
+ * The reported case is Brave with push messaging switched off, which rejects
+ * with `AbortError: Registration failed - push service error` and used to
+ * show only "Activation failed".
+ */
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ classifyWebPushSubscribeFailure,
+ isBraveBrowser,
+} from "../web-push-subscribe-failure";
+
+function domError(name: string, message: string): Error {
+ const error = new Error(message);
+ error.name = name;
+ return error;
+}
+
+const PUSH_SERVICE_ERROR = domError(
+ "AbortError",
+ "Registration failed - push service error",
+);
+
+describe("classifyWebPushSubscribeFailure", () => {
+ it("names the push service when the browser could not register with it", () => {
+ expect(
+ classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
+ permission: "granted",
+ isBrave: false,
+ }),
+ ).toBe("push-service");
+ });
+
+ it("names Brave's setting when the browser is Brave", () => {
+ expect(
+ classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
+ permission: "granted",
+ isBrave: true,
+ }),
+ ).toBe("push-service-brave");
+ });
+
+ it("reads any other AbortError from subscribe as the push service", () => {
+ // Chromium also says "push service not available"; Firefox says
+ // "Error retrieving push subscription." Both are the push service.
+ for (const message of [
+ "Registration failed - push service not available",
+ "Error retrieving push subscription.",
+ ]) {
+ expect(
+ classifyWebPushSubscribeFailure(domError("AbortError", message), {
+ permission: "granted",
+ isBrave: false,
+ }),
+ ).toBe("push-service");
+ }
+ });
+
+ it("keeps a blocked permission as blocked, whatever the error said", () => {
+ expect(
+ classifyWebPushSubscribeFailure(PUSH_SERVICE_ERROR, {
+ permission: "denied",
+ isBrave: true,
+ }),
+ ).toBe("denied");
+ });
+
+ it("does not blame the push service for a dismissed permission prompt", () => {
+ expect(
+ classifyWebPushSubscribeFailure(
+ domError("NotAllowedError", "Registration failed - permission denied"),
+ { permission: "default", isBrave: true },
+ ),
+ ).toBe("unknown");
+ expect(
+ classifyWebPushSubscribeFailure(
+ domError("AbortError", "Registration failed - permission denied"),
+ { permission: "default", isBrave: true },
+ ),
+ ).toBe("unknown");
+ });
+
+ it("does not blame the push service for a key the browser would not take", () => {
+ expect(
+ classifyWebPushSubscribeFailure(
+ domError(
+ "AbortError",
+ "Registration failed - missing applicationServerKey, and manifest empty or missing",
+ ),
+ { permission: "granted", isBrave: true },
+ ),
+ ).toBe("unknown");
+ expect(
+ classifyWebPushSubscribeFailure(
+ domError(
+ "InvalidAccessError",
+ "The provided applicationServerKey is not valid.",
+ ),
+ { permission: "granted", isBrave: false },
+ ),
+ ).toBe("unknown");
+ });
+
+ it("keeps the generic message for anything that is not an error object", () => {
+ expect(
+ classifyWebPushSubscribeFailure("boom", {
+ permission: "granted",
+ isBrave: false,
+ }),
+ ).toBe("unknown");
+ expect(
+ classifyWebPushSubscribeFailure(new TypeError("Failed to fetch"), {
+ permission: "granted",
+ isBrave: false,
+ }),
+ ).toBe("unknown");
+ });
+});
+
+describe("isBraveBrowser", () => {
+ const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
+
+ afterEach(() => {
+ if (original) Object.defineProperty(globalThis, "navigator", original);
+ });
+
+ function withNavigator(value: unknown): void {
+ Object.defineProperty(globalThis, "navigator", {
+ value,
+ configurable: true,
+ writable: true,
+ });
+ }
+
+ it("is true when navigator.brave is present", () => {
+ withNavigator({ brave: { isBrave: async () => true } });
+ expect(isBraveBrowser()).toBe(true);
+ });
+
+ it("is false for a Chromium without navigator.brave", () => {
+ withNavigator({ userAgent: "Mozilla/5.0 Chrome/130" });
+ expect(isBraveBrowser()).toBe(false);
+ });
+});
diff --git a/src/lib/notifications/web-push-subscribe-failure.ts b/src/lib/notifications/web-push-subscribe-failure.ts
new file mode 100644
index 000000000..baecd3dc2
--- /dev/null
+++ b/src/lib/notifications/web-push-subscribe-failure.ts
@@ -0,0 +1,67 @@
+/**
+ * Why `pushManager.subscribe()` refused, in the terms the Browser Push card
+ * explains to a person.
+ *
+ * Only the browser's own refusal is classified here. A missing VAPID key and
+ * a failed save are server answers and keep their own messages in the card.
+ *
+ * - `denied`: notifications are blocked for this site
+ * (`Notification.permission === "denied"`), whatever the error said.
+ * - `push-service` / `push-service-brave`: the browser could not register
+ * with its push service ("AbortError: Registration failed - push service
+ * error"). The page did nothing wrong and cannot fix it; the browser has to
+ * reach its push service. Brave ships with that switched off, which is the
+ * common case, so Brave gets the setting named.
+ * - `unknown`: anything else, which keeps the generic message. That includes
+ * a dismissed permission prompt (`NotAllowedError`, or an `AbortError`
+ * about permission in older Chromium builds): the permission is still
+ * "default", the person can try again, and calling it a push-service fault
+ * would send them to the wrong setting.
+ */
+export type WebPushSubscribeFailure =
+ "denied" | "push-service" | "push-service-brave" | "unknown";
+
+export interface WebPushSubscribeContext {
+ /** `Notification.permission` after the attempt. */
+ permission: NotificationPermission;
+ /** The browser announces itself as Brave (`navigator.brave`). */
+ isBrave: boolean;
+}
+
+function errorName(error: unknown): string {
+ return typeof error === "object" && error !== null && "name" in error
+ ? String((error as { name: unknown }).name)
+ : "";
+}
+
+function errorMessage(error: unknown): string {
+ return typeof error === "object" && error !== null && "message" in error
+ ? String((error as { message: unknown }).message)
+ : "";
+}
+
+export function classifyWebPushSubscribeFailure(
+ error: unknown,
+ { permission, isBrave }: WebPushSubscribeContext,
+): WebPushSubscribeFailure {
+ const name = errorName(error);
+ const message = errorMessage(error);
+
+ if (permission === "denied") return "denied";
+ if (name !== "AbortError") return "unknown";
+ if (/permission/i.test(message)) return "unknown";
+ // A key the server handed out that the browser would not take is the
+ // server's problem, not the push service's.
+ if (/applicationServerKey/i.test(message)) return "unknown";
+ return isBrave ? "push-service-brave" : "push-service";
+}
+
+/**
+ * Brave exposes `navigator.brave` so a page can tell it apart from Chrome,
+ * whose user agent it otherwise copies.
+ */
+export function isBraveBrowser(): boolean {
+ if (typeof navigator === "undefined") return false;
+ const brave = (navigator as Navigator & { brave?: unknown }).brave;
+ return typeof brave === "object" && brave !== null;
+}