Merge pull request #729 from MrDeik/agent/push-notification-onboarding

feat: add background notification onboarding
This commit is contained in:
Linus Rath
2026-08-06 14:47:31 +02:00
committed by GitHub
31 changed files with 803 additions and 7 deletions
+2
View File
@@ -11,6 +11,7 @@ import { ImpersonationReconciler } from "@/components/impersonation/impersonatio
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
@@ -45,6 +46,7 @@ export default async function LocaleLayout({
<PluginDialogHost />
<PluginConsentDialog />
<PWAInstallPrompt />
<PushNotificationPrompt />
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
+142 -2
View File
@@ -12,6 +12,18 @@ import { NextRequest, NextResponse } from 'next/server';
const ACCOUNT_ID = 'dev-account-001';
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
const emailCreationIds = new Map<string, string>();
const DEFAULT_PUSH_RELAY_ORIGIN = 'https://notifications.relay.bulwarkmail.org';
interface MockPushSubscription {
id: string;
deviceClientId: string;
expires: string | null;
types: string[] | null;
expectedVerificationCode: string;
verified: boolean;
}
const pushSubscriptions: MockPushSubscription[] = [];
// ---------------------------------------------------------------------------
// Mailboxes
@@ -1501,6 +1513,10 @@ function buildThreads() {
type MethodArgs = Record<string, unknown>;
type MethodResult = [string, Record<string, unknown>, string];
type MethodHandler = (
args: MethodArgs,
callId: string,
) => MethodResult | Promise<MethodResult>;
function handleCoreEcho(args: MethodArgs, callId: string): MethodResult {
return ['Core/echo', args, callId];
@@ -1934,12 +1950,134 @@ function handleSieveScriptGet(_args: MethodArgs, callId: string): MethodResult {
return ['SieveScript/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId];
}
function handlePushSubscriptionGet(args: MethodArgs, callId: string): MethodResult {
const ids = Array.isArray(args.ids) ? args.ids as string[] : null;
const selected = ids
? pushSubscriptions.filter((subscription) => ids.includes(subscription.id))
: pushSubscriptions;
const foundIds = new Set(selected.map((subscription) => subscription.id));
return ['PushSubscription/get', {
state: nextState(),
list: selected.map(({ id, deviceClientId, expires, types }) => ({
id,
deviceClientId,
expires,
types,
})),
notFound: ids?.filter((id) => !foundIds.has(id)) ?? [],
}, callId];
}
async function handlePushSubscriptionSet(args: MethodArgs, callId: string): Promise<MethodResult> {
const created: Record<string, { id: string; expires: string | null; types: string[] | null }> = {};
const notCreated: Record<string, { type: string; description: string }> = {};
const updated: Record<string, null> = {};
const notUpdated: Record<string, { type: string; description: string }> = {};
const destroyed: string[] = [];
const notDestroyed: Record<string, { type: string }> = {};
const create = args.create as Record<string, Record<string, unknown>> | undefined;
if (create) {
for (const [creationId, data] of Object.entries(create)) {
try {
const url = new URL(String(data.url ?? ''));
const allowedOrigin = new URL(
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || DEFAULT_PUSH_RELAY_ORIGIN,
).origin;
if (url.origin !== allowedOrigin || !url.pathname.startsWith('/api/push/jmap/')) {
throw new Error('The dev mock only sends verification to the configured push relay');
}
const id = `push-${crypto.randomUUID()}`;
const verificationCode = crypto.randomUUID().replace(/-/g, '');
const subscription: MockPushSubscription = {
id,
deviceClientId: String(data.deviceClientId ?? ''),
expires: typeof data.expires === 'string' ? data.expires : null,
types: Array.isArray(data.types) ? data.types as string[] : null,
expectedVerificationCode: verificationCode,
verified: false,
};
const verificationResponse = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
'@type': 'PushVerification',
pushSubscriptionId: id,
verificationCode,
}),
});
if (!verificationResponse.ok) {
throw new Error(`Push relay rejected verification (${verificationResponse.status})`);
}
pushSubscriptions.push(subscription);
created[creationId] = {
id,
expires: subscription.expires,
types: subscription.types,
};
} catch (error) {
notCreated[creationId] = {
type: 'invalidProperties',
description: error instanceof Error ? error.message : 'Invalid push subscription',
};
}
}
}
const update = args.update as Record<string, Record<string, unknown>> | undefined;
if (update) {
for (const [id, patch] of Object.entries(update)) {
const subscription = pushSubscriptions.find((candidate) => candidate.id === id);
if (!subscription) {
notUpdated[id] = { type: 'notFound', description: 'Push subscription not found' };
continue;
}
if (
typeof patch.verificationCode === 'string'
&& patch.verificationCode !== subscription.expectedVerificationCode
) {
notUpdated[id] = { type: 'invalidProperties', description: 'Invalid verification code' };
continue;
}
if (typeof patch.verificationCode === 'string') subscription.verified = true;
if (typeof patch.expires === 'string') subscription.expires = patch.expires;
if (Array.isArray(patch.types)) subscription.types = patch.types as string[];
updated[id] = null;
}
}
if (Array.isArray(args.destroy)) {
for (const id of args.destroy as string[]) {
const index = pushSubscriptions.findIndex((subscription) => subscription.id === id);
if (index === -1) {
notDestroyed[id] = { type: 'notFound' };
continue;
}
pushSubscriptions.splice(index, 1);
destroyed.push(id);
}
}
return ['PushSubscription/set', {
created,
notCreated,
updated,
notUpdated,
destroyed,
notDestroyed,
}, callId];
}
// Catch-all for unknown methods
function handleUnknown(method: string, _args: MethodArgs, callId: string): MethodResult {
return ['error', { type: 'unknownMethod', description: `Mock server does not implement ${method}` }, callId];
}
const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => MethodResult> = {
const METHOD_HANDLERS: Record<string, MethodHandler> = {
'Core/echo': handleCoreEcho,
'Mailbox/get': handleMailboxGet,
'Mailbox/set': handleMailboxSet,
@@ -2009,6 +2147,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
'CalendarEvent/set': (_args, callId) => ['CalendarEvent/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId],
'SieveScript/get': handleSieveScriptGet,
'SieveScript/set': (_args, callId) => ['SieveScript/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId],
'PushSubscription/get': handlePushSubscriptionGet,
'PushSubscription/set': handlePushSubscriptionSet,
};
// ---------------------------------------------------------------------------
@@ -2270,7 +2410,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (tooLarge) {
responses.push(tooLarge);
} else if (handler) {
const result = handler(args, callId);
const result = await handler(args, callId);
responses.push(result);
} else {
responses.push(handleUnknown(method, args, callId));
@@ -0,0 +1,205 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PUSH_NOTIFICATION_PROMPT_DELAY_MS, PushNotificationPrompt } from "../push-notification-prompt";
import { PWA_INSTALL_PROMPT_VISIBILITY_EVENT } from "../pwa-install-prompt";
const mocks = vi.hoisted(() => ({
pathname: "/en",
enabled: false,
supported: true,
enableWebPush: vi.fn(),
isWebPushEnabled: vi.fn(),
authState: {
client: { getAccountId: () => "account-1" } as {
getAccountId: () => string;
},
username: "alice@example.com",
isAuthenticated: true,
isDemoMode: false,
},
settingsState: { emailNotificationsEnabled: true },
policyState: {
loaded: true,
policy: { pushRelayUrl: "https://notifications.relay.bulwarkmail.org" },
},
}));
vi.mock("next/navigation", () => ({
usePathname: () => mocks.pathname,
}));
vi.mock("@/stores/auth-store", () => ({
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) =>
selector(mocks.authState),
}));
vi.mock("@/stores/settings-store", () => ({
useSettingsStore: (
selector: (state: typeof mocks.settingsState) => unknown,
) => selector(mocks.settingsState),
}));
vi.mock("@/stores/policy-store", () => ({
usePolicyStore: (selector: (state: typeof mocks.policyState) => unknown) =>
selector(mocks.policyState),
}));
vi.mock("@/lib/web-push", () => ({
DEFAULT_RELAY_BASE_URL: "https://notifications.relay.bulwarkmail.org",
enableWebPush: mocks.enableWebPush,
isWebPushSupported: () => mocks.supported,
isWebPushEnabled: mocks.isWebPushEnabled,
}));
function setNotificationPermission(permission: NotificationPermission) {
Object.defineProperty(window, "Notification", {
configurable: true,
value: { permission },
});
Object.defineProperty(globalThis, "Notification", {
configurable: true,
value: { permission },
});
}
async function advancePromptDelay() {
await act(async () => {
await vi.advanceTimersByTimeAsync(PUSH_NOTIFICATION_PROMPT_DELAY_MS);
});
}
describe("PushNotificationPrompt", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
mocks.pathname = "/en";
mocks.supported = true;
mocks.enabled = false;
mocks.authState.client = { getAccountId: () => "account-1" };
mocks.authState.username = "alice@example.com";
mocks.authState.isAuthenticated = true;
mocks.authState.isDemoMode = false;
mocks.settingsState.emailNotificationsEnabled = true;
mocks.policyState.loaded = true;
mocks.policyState.policy.pushRelayUrl =
"https://notifications.relay.bulwarkmail.org";
mocks.enableWebPush.mockReset().mockResolvedValue({ subscriptionId: "sub-1" });
mocks.isWebPushEnabled.mockReset().mockImplementation(async () => mocks.enabled);
setNotificationPermission("default");
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it("offers onboarding when the current account has no push subscription", async () => {
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.getByText("title")).toBeInTheDocument();
expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1");
});
it("enables push through the existing web-push flow", async () => {
render(<PushNotificationPrompt />);
await advancePromptDelay();
await act(async () => {
fireEvent.click(screen.getByText("enable"));
await Promise.resolve();
});
expect(mocks.enableWebPush).toHaveBeenCalledWith({
client: mocks.authState.client,
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
accountLabel: "alice@example.com",
});
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("does not render when push is already enabled", async () => {
mocks.enabled = true;
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("does not render when browser notifications are blocked", async () => {
setNotificationPermission("denied");
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("does not render on the settings route", async () => {
mocks.pathname = "/en/settings";
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
});
it("persists the per-account don't-remind choice", async () => {
const { unmount } = render(<PushNotificationPrompt />);
await advancePromptDelay();
fireEvent.click(screen.getByText("dont_remind"));
expect(
localStorage.getItem("bulwark.push.onboarding.dismissed.v1.account-1"),
).toBe("1");
unmount();
mocks.isWebPushEnabled.mockClear();
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
});
it("keeps the session dismissal of every account that was dismissed", async () => {
const { rerender } = render(<PushNotificationPrompt />);
await advancePromptDelay();
fireEvent.click(screen.getByText("not_now"));
mocks.authState.client = { getAccountId: () => "account-2" };
rerender(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.getByText("title")).toBeInTheDocument();
fireEvent.click(screen.getByText("not_now"));
mocks.authState.client = { getAccountId: () => "account-1" };
rerender(<PushNotificationPrompt />);
await advancePromptDelay();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("defers push onboarding until the PWA install prompt is closed", async () => {
render(<PushNotificationPrompt />);
act(() => {
window.dispatchEvent(
new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, {
detail: { visible: true },
}),
);
});
await advancePromptDelay();
expect(mocks.isWebPushEnabled).not.toHaveBeenCalled();
expect(screen.queryByText("title")).not.toBeInTheDocument();
act(() => {
window.dispatchEvent(
new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, {
detail: { visible: false },
}),
);
});
await advancePromptDelay();
expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1");
expect(screen.getByText("title")).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
PWAInstallPrompt,
} from "../pwa-install-prompt";
vi.mock("@/hooks/use-config", () => ({
useConfig: () => ({
appName: "Bulwark",
faviconUrl: "",
appLogoLightUrl: "",
appLogoDarkUrl: "",
}),
}));
describe("PWAInstallPrompt", () => {
beforeEach(() => {
localStorage.clear();
});
it("closes after the browser install prompt is dismissed", async () => {
const prompt = vi.fn().mockResolvedValue(undefined);
const event = new Event("beforeinstallprompt");
Object.assign(event, {
prompt,
userChoice: Promise.resolve({ outcome: "dismissed" }),
});
render(<PWAInstallPrompt />);
act(() => window.dispatchEvent(event));
expect(screen.getByText("title")).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByText("install"));
await Promise.resolve();
});
expect(prompt).toHaveBeenCalledOnce();
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
it("announces that it is hidden after Not now", () => {
const visibility: boolean[] = [];
const listener = (event: Event) => {
visibility.push(
(event as CustomEvent<{ visible?: boolean }>).detail?.visible === true,
);
};
window.addEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener);
try {
const event = new Event("beforeinstallprompt");
Object.assign(event, {
prompt: vi.fn(),
userChoice: Promise.resolve({ outcome: "dismissed" }),
});
render(<PWAInstallPrompt />);
act(() => window.dispatchEvent(event));
fireEvent.click(screen.getByText("not_now"));
expect(visibility).toContain(true);
expect(visibility.at(-1)).toBe(false);
} finally {
window.removeEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener);
}
});
});
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Bell, Loader2, X } from "lucide-react";
import { useAuthStore } from "@/stores/auth-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useSettingsStore } from "@/stores/settings-store";
import {
DEFAULT_RELAY_BASE_URL,
enableWebPush,
isWebPushEnabled,
isWebPushSupported,
} from "@/lib/web-push";
import {
isPWAInstallPromptVisible,
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
} from "@/components/pwa-install-prompt";
const DISMISSED_KEY_PREFIX = "bulwark.push.onboarding.dismissed.v1.";
export const PUSH_NOTIFICATION_PROMPT_DELAY_MS = 1_000;
function dismissedKey(accountId: string): string {
return `${DISMISSED_KEY_PREFIX}${accountId}`;
}
function isExcludedPath(pathname: string): boolean {
return /(^|\/)settings(?:\/|$)/.test(pathname)
|| /(^|\/)setup(?:\/|$)/.test(pathname)
|| /(^|\/)login(?:\/|$)/.test(pathname);
}
function wasDismissed(accountId: string): boolean {
try {
return localStorage.getItem(dismissedKey(accountId)) === "1";
} catch {
return false;
}
}
function dismissPermanently(accountId: string): void {
try {
localStorage.setItem(dismissedKey(accountId), "1");
} catch {
// Storage may be unavailable in hardened/private browser contexts. The
// prompt still closes for the current session.
}
}
export function PushNotificationPrompt() {
const pathname = usePathname();
const tPush = useTranslations("settings.notifications.push");
const tInstall = useTranslations("pwa_install");
const client = useAuthStore((state) => state.client);
const username = useAuthStore((state) => state.username);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isDemoMode = useAuthStore((state) => state.isDemoMode);
const emailNotificationsEnabled = useSettingsStore(
(state) => state.emailNotificationsEnabled,
);
const policyLoaded = usePolicyStore((state) => state.loaded);
const adminPushRelayUrl = usePolicyStore(
(state) => state.policy.pushRelayUrl,
);
const [showPrompt, setShowPrompt] = useState(false);
const [isEnabling, setIsEnabling] = useState(false);
const [error, setError] = useState<string | null>(null);
// Session-only dismissal is per account: dismissing on one account must not
// re-offer the prompt on an account dismissed earlier in the same session.
const [sessionDismissedAccountIds, setSessionDismissedAccountIds] = useState<
ReadonlySet<string>
>(() => new Set());
const [pwaPromptVisible, setPwaPromptVisible] = useState(
isPWAInstallPromptVisible,
);
const accountId = client?.getAccountId() ?? null;
const relayBaseUrl = (adminPushRelayUrl ?? "").trim()
|| DEFAULT_RELAY_BASE_URL;
const dismissForSession = useCallback((id: string) => {
setSessionDismissedAccountIds((previous) => {
if (previous.has(id)) return previous;
const next = new Set(previous);
next.add(id);
return next;
});
}, []);
// A layout effect subscribes before the browser can paint, so a visibility
// event dispatched by the PWA prompt during the same commit is not missed.
useLayoutEffect(() => {
const handlePwaPromptVisibility = (event: Event) => {
const visible = (event as CustomEvent<{ visible?: boolean }>).detail?.visible;
setPwaPromptVisible(visible === true);
};
window.addEventListener(
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
handlePwaPromptVisibility,
);
setPwaPromptVisible(isPWAInstallPromptVisible());
return () => {
window.removeEventListener(
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
handlePwaPromptVisibility,
);
};
}, []);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
setShowPrompt(false);
setError(null);
if (
!policyLoaded
|| !isAuthenticated
|| !client
|| !accountId
|| isDemoMode
|| !emailNotificationsEnabled
|| isExcludedPath(pathname)
|| sessionDismissedAccountIds.has(accountId)
|| pwaPromptVisible
|| wasDismissed(accountId)
|| !isWebPushSupported()
|| Notification.permission === "denied"
) {
return;
}
timer = setTimeout(() => {
void isWebPushEnabled(accountId)
.then((enabled) => {
if (!cancelled && !enabled) setShowPrompt(true);
})
.catch(() => {
// Failure to inspect the browser subscription should not trigger an
// onboarding prompt whose current state we cannot determine.
});
}, PUSH_NOTIFICATION_PROMPT_DELAY_MS);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [
accountId,
client,
emailNotificationsEnabled,
isAuthenticated,
isDemoMode,
pathname,
policyLoaded,
pwaPromptVisible,
sessionDismissedAccountIds,
]);
const handleEnable = async () => {
if (!client) return;
setIsEnabling(true);
setError(null);
try {
await enableWebPush({
client,
relayBaseUrl,
accountLabel: username ?? undefined,
});
setShowPrompt(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to enable push");
} finally {
setIsEnabling(false);
}
};
const handleDismiss = () => {
if (accountId) dismissForSession(accountId);
setShowPrompt(false);
};
const handleDismissForever = () => {
if (accountId) {
dismissPermanently(accountId);
dismissForSession(accountId);
}
setShowPrompt(false);
};
if (!showPrompt) return null;
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<Bell className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 shrink-0" />
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
{tPush("title")}
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{tPush("description")}
</p>
</div>
</div>
<button
type="button"
onClick={handleDismiss}
disabled={isEnabling}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-50"
aria-label={tPush("dismiss_aria")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-2">
{error ? (
<p className="text-xs text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
<div className="flex gap-2">
<button
type="button"
onClick={handleDismiss}
disabled={isEnabling}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
>
{tInstall("not_now")}
</button>
<button
type="button"
onClick={() => void handleEnable()}
disabled={isEnabling}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{isEnabling ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{tPush("enable")}
</button>
</div>
<button
type="button"
onClick={handleDismissForever}
disabled={isEnabling}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center disabled:opacity-50"
>
{tInstall("dont_remind")}
</button>
</div>
</div>
);
}
+28 -5
View File
@@ -12,6 +12,16 @@ interface BeforeInstallPromptEvent extends Event {
}
const DISMISSED_KEY = "pwa-install-dismissed";
export const PWA_INSTALL_PROMPT_VISIBILITY_EVENT =
"bulwark:pwa-install-prompt-visibility";
// Mirrors the last dispatched visibility so a listener that mounts after the
// event was fired can still read the current state instead of assuming hidden.
let promptVisible = false;
export function isPWAInstallPromptVisible(): boolean {
return promptVisible;
}
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
@@ -36,16 +46,29 @@ export function PWAInstallPrompt() {
};
}, []);
useEffect(() => {
promptVisible = showPrompt;
window.dispatchEvent(
new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, {
detail: { visible: showPrompt },
}),
);
return () => {
if (showPrompt) promptVisible = false;
};
}, [showPrompt]);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
setShowPrompt(false);
}
// A beforeinstallprompt event can only be consumed once. Close the card
// whether the browser prompt was accepted or dismissed so the next
// onboarding step can continue in the same session.
setDeferredPrompt(null);
setShowPrompt(false);
};
const handleDismiss = () => {
+75
View File
@@ -199,6 +199,81 @@ describe('dev-jmap mock server', () => {
});
});
describe('POST /api - PushSubscription lifecycle', () => {
it('creates, verifies, lists, and destroys a push subscription', async () => {
const relayFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 202 }),
);
try {
const createReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/set', {
create: {
new: {
deviceClientId: 'browser-device-1',
url: 'https://notifications.relay.bulwarkmail.org/api/push/jmap/browser-device-1',
types: ['Email'],
expires: '2030-01-01T00:00:00.000Z',
},
},
}, 'c0']],
}),
});
const createRes = await POST(createReq, { params: Promise.resolve({ path: ['api'] }) });
const createData = await createRes.json();
const id = createData.methodResponses[0][1].created.new.id as string;
const verificationBody = JSON.parse(
String(relayFetch.mock.calls[0]?.[1]?.body),
) as { verificationCode: string };
expect(id).toMatch(/^push-/);
expect(relayFetch).toHaveBeenCalledOnce();
const verifyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/set', {
update: { [id]: { verificationCode: verificationBody.verificationCode } },
}, 'v0']],
}),
});
const verifyRes = await POST(verifyReq, { params: Promise.resolve({ path: ['api'] }) });
const verifyData = await verifyRes.json();
expect(verifyData.methodResponses[0][1].updated[id]).toBeNull();
const getReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/get', { ids: [id] }, 'g0']],
}),
});
const getRes = await POST(getReq, { params: Promise.resolve({ path: ['api'] }) });
const getData = await getRes.json();
expect(getData.methodResponses[0][1].list).toEqual([
expect.objectContaining({ id, deviceClientId: 'browser-device-1' }),
]);
const destroyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [['PushSubscription/set', { destroy: [id] }, 'd0']],
}),
});
const destroyRes = await POST(destroyReq, { params: Promise.resolve({ path: ['api'] }) });
const destroyData = await destroyRes.json();
expect(destroyData.methodResponses[0][1].destroyed).toEqual([id]);
} finally {
relayFetch.mockRestore();
}
});
});
describe('POST /api - back-references', () => {
it('should resolve #ids from Email/query result', async () => {
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
+1
View File
@@ -1068,6 +1068,7 @@
"disable": "تعطيل",
"confirm_disable_title": "تعطيل الإشعارات في الخلفية؟",
"confirm_disable_message": "سيتوقف هذا الجهاز عن تلقي التنبيهات عند إغلاق الموقع.",
"dismiss_aria": "تجاهل مطالبة الإشعارات",
"ios_hint": "على iOS، ثبّت الموقع على الشاشة الرئيسية أولًا - يقدّم Safari خدمة Web Push فقط لتطبيقات PWA المثبّتة."
},
"sound_selection": {
+1
View File
@@ -1068,6 +1068,7 @@
"disable": "Desactiva",
"confirm_disable_title": "Voleu desactivar les notificacions en segon pla?",
"confirm_disable_message": "Aquest dispositiu deixarà de rebre avisos quan el lloc estigui tancat.",
"dismiss_aria": "Descarta l'avís de notificacions",
"ios_hint": "A l'iOS, primer instal·leu el lloc a la pantalla d'inici - el Safari només entrega Web Push a les PWA instal·lades."
},
"sound_selection": {
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Přijímat systémová oznámení o nové poště, když je tento web zavřený. Doručováno přes push relay Bulwark; relay nikdy nevidí obsah pošty.",
"disable": "Zakázat",
"enable": "Povolit",
"dismiss_aria": "Zavřít výzvu k oznámením",
"ios_hint": "Na iOS nejprve nainstalujte web na domovskou obrazovku – Safari doručuje Web Push pouze nainstalovaným PWA.",
"reenable": "Znovu zaregistrovat",
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
+1
View File
@@ -1068,6 +1068,7 @@
"disable": "Deaktivér",
"confirm_disable_title": "Deaktivér baggrundsnotifikationer?",
"confirm_disable_message": "Denne enhed stopper med at modtage alarmer, når sitet er lukket.",
"dismiss_aria": "Afvis notifikationsprompt",
"ios_hint": "På iOS skal du installere sitet på din startskærm først - Safari leverer kun Web Push til installerede PWA'er."
},
"sound_selection": {
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das Bulwark Push-Relay; das Relay sieht niemals E-Mail-Inhalte.",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"dismiss_aria": "Benachrichtigungshinweis schließen",
"ios_hint": "Installieren Sie die Seite unter iOS zuerst auf dem Startbildschirm – Safari liefert Web Push nur an installierte PWAs.",
"reenable": "Neu registrieren",
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
+1
View File
@@ -1067,6 +1067,7 @@
"disable": "Disable",
"confirm_disable_title": "Disable background notifications?",
"confirm_disable_message": "This device will stop receiving alerts when the site is closed.",
"dismiss_aria": "Dismiss notification prompt",
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs."
},
"sound_selection": {
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de Bulwark; el relay nunca ve el contenido del correo.",
"disable": "Desactivar",
"enable": "Activar",
"dismiss_aria": "Cerrar aviso de notificaciones",
"ios_hint": "En iOS, instala primero el sitio en la pantalla de inicio: Safari solo entrega Web Push a PWAs instaladas.",
"reenable": "Volver a registrar",
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
+1
View File
@@ -1068,6 +1068,7 @@
"disable": "غیرفعال کردن",
"confirm_disable_title": "غیرفعال کردن اعلان‌های پس‌زمینه؟",
"confirm_disable_message": "این دستگاه دیگر هشدار دریافت نمی‌کند",
"dismiss_aria": "رد کردن پیشنهاد اعلان‌ها",
"ios_hint": "در iOS ابتدا سایت را به صفحه اصلی اضافه کنید"
},
"sound_selection": {
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push Bulwark ; le relais ne voit jamais le contenu des courriers.",
"disable": "Désactiver",
"enable": "Activer",
"dismiss_aria": "Fermer l'invite de notifications",
"ios_hint": "Sur iOS, installez d'abord le site sur l'écran d'accueil – Safari ne livre Web Push qu'aux PWA installées.",
"reenable": "Réenregistrer",
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
+1
View File
@@ -1054,6 +1054,7 @@
"disable": "השבת",
"confirm_disable_title": "השבת התראות בדפדפן?",
"confirm_disable_message": "התקן זה יפסיק לקבל התראות כאשר האתר סגור.",
"dismiss_aria": "בטל הודעת התראות",
"ios_hint": "ב־iOS, התקן קודם כל את האתר בהיתוך שלך - Safari מספק Web Push רק לPWA מותקנות."
}
},
+1
View File
@@ -1068,6 +1068,7 @@
"disable": "Letiltás",
"confirm_disable_title": "Háttérértesítések letiltása?",
"confirm_disable_message": "Ez az eszköz nem fog figyelmeztetéseket kapni, amikor az oldal be van zárva.",
"dismiss_aria": "Értesítési ablak elutasítása",
"ios_hint": "iOS-en először telepítsd az oldalt a kezdőképernyőre - a Safari csak telepített PWA-knak kézbesít Web Push értesítéseket."
},
"sound_selection": {
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Ricevi notifiche di sistema per la nuova posta quando questo sito è chiuso. Consegnato tramite il relay push Bulwark; il relay non vede mai il contenuto della posta.",
"disable": "Disabilita",
"enable": "Abilita",
"dismiss_aria": "Chiudi avviso di notifiche",
"ios_hint": "Su iOS, installa prima il sito sulla schermata Home: Safari consegna Web Push solo alle PWA installate.",
"reenable": "Registra di nuovo",
"relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "このサイトが閉じているときに新着メールのシステム通知を受信します。Bulwark プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。",
"disable": "無効化",
"enable": "有効化",
"dismiss_aria": "通知プロンプトを閉じる",
"ios_hint": "iOS では、最初にサイトをホーム画面にインストールしてください。Safari はインストールされた PWA にのみ Web Push を配信します。",
"reenable": "再登録",
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. Bulwark 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.",
"disable": "비활성화",
"enable": "활성화",
"dismiss_aria": "알림 프롬프트 닫기",
"ios_hint": "iOS에서는 먼저 사이트를 홈 화면에 설치하세요. Safari는 설치된 PWA에만 Web Push를 전달합니다.",
"reenable": "다시 등록",
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Saņem sistēmas paziņojumus par jaunu pastu, kad šī vietne ir aizvērta. Piegādāts caur Bulwark push releju; relejs nekad neredz pasta saturu.",
"disable": "Atspējot",
"enable": "Iespējot",
"dismiss_aria": "Aizvērt paziņojumu uzaicinājumu",
"ios_hint": "Operētājsistēmā iOS vispirms instalējiet vietni sākuma ekrānā – Safari piegādā Web Push tikai instalētajām PWA.",
"reenable": "Reģistrēt vēlreiz",
"relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Ontvang systeemmeldingen voor nieuwe e-mail wanneer deze site gesloten is. Geleverd via de Bulwark-pushrelay; de relay ziet nooit e-mailinhoud.",
"disable": "Uitschakelen",
"enable": "Inschakelen",
"dismiss_aria": "Meldingsvenster sluiten",
"ios_hint": "Installeer de site op iOS eerst op het beginscherm – Safari levert Web Push alleen aan geïnstalleerde PWA's.",
"reenable": "Opnieuw registreren",
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Otrzymuj powiadomienia systemowe o nowych wiadomościach, gdy ta strona jest zamknięta. Dostarczane przez przekaźnik push Bulwark; przekaźnik nigdy nie widzi treści wiadomości.",
"disable": "Wyłącz",
"enable": "Włącz",
"dismiss_aria": "Zamknij monit powiadomień",
"ios_hint": "W systemie iOS najpierw zainstaluj stronę na ekranie głównym – Safari dostarcza Web Push tylko zainstalowanym PWA.",
"reenable": "Zarejestruj ponownie",
"relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Receba notificações do sistema para novas mensagens quando este site estiver fechado. Entregue através do relay push do Bulwark; o relay nunca vê o conteúdo da mensagem.",
"disable": "Desativar",
"enable": "Ativar",
"dismiss_aria": "Dispensar aviso de notificações",
"ios_hint": "No iOS, instale o site primeiro na tela inicial – o Safari só entrega Web Push para PWAs instalados.",
"reenable": "Registrar novamente",
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
+1
View File
@@ -1068,6 +1068,7 @@
"disable": "Dezactivare",
"confirm_disable_title": "Dezactivați notificările din fundal?",
"confirm_disable_message": "Acest dispozitiv nu va mai primi alerte atunci când site-ul este închis.",
"dismiss_aria": "Ignorați solicitarea de notificări",
"ios_hint": "Pe iOS, instalați mai întâi site-ul pe ecranul de start – Safari trimite notificări Web Push numai către aplicațiile web progresive (PWA) instalate."
},
"sound_selection": {
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей Bulwark; релей никогда не видит содержимое писем.",
"disable": "Отключить",
"enable": "Включить",
"dismiss_aria": "Закрыть запрос на уведомления",
"ios_hint": "В iOS сначала установите сайт на главный экран – Safari доставляет Web Push только установленным PWA.",
"reenable": "Перерегистрировать",
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
+1
View File
@@ -1092,6 +1092,7 @@
"disable": "Zakázať",
"confirm_disable_title": "Zakázať oznámenia na pozadí?",
"confirm_disable_message": "Toto zariadenie prestane prijímať upozornenia, keď je stránka zatvorená.",
"dismiss_aria": "Zavrieť výzvu na oznámenia",
"ios_hint": "Na iOS najprv nainštalujte stránku na domovskú obrazovku – Safari doručuje Web Push iba nainštalovaným PWA."
}
},
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Bu site kapalıyken yeni postalar için sistem bildirimleri alın. Bulwark push röle aracılığıyla iletilir; röle posta içeriğini asla görmez.",
"disable": "Devre dışı bırak",
"enable": "Etkinleştir",
"dismiss_aria": "Bildirim istemini kapat",
"ios_hint": "iOS'ta önce siteyi ana ekranınıza yükleyin – Safari Web Push'u yalnızca yüklü PWA'lara teslim eder.",
"reenable": "Yeniden kaydet",
"relay_desc": "Varsayılan olarak barındırılan Bulwark rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле Bulwark; реле ніколи не бачить вміст листів.",
"disable": "Вимкнути",
"enable": "Увімкнути",
"dismiss_aria": "Закрити запит на сповіщення",
"ios_hint": "На iOS спочатку встановіть сайт на головний екран – Safari доставляє Web Push лише встановленим PWA.",
"reenable": "Перереєструвати",
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
+1
View File
@@ -1078,6 +1078,7 @@
"description": "在此网站关闭时接收新邮件的系统通知。通过 Bulwark 推送中继传送;中继永远不会看到邮件内容。",
"disable": "禁用",
"enable": "启用",
"dismiss_aria": "关闭通知提示",
"ios_hint": "在 iOS 上,请先将网站安装到主屏幕 – Safari 仅向已安装的 PWA 发送 Web Push。",
"reenable": "重新注册",
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",