mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Merge pull request #729 from MrDeik/agent/push-notification-onboarding
feat: add background notification onboarding
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -1068,6 +1068,7 @@
|
||||
"disable": "تعطيل",
|
||||
"confirm_disable_title": "تعطيل الإشعارات في الخلفية؟",
|
||||
"confirm_disable_message": "سيتوقف هذا الجهاز عن تلقي التنبيهات عند إغلاق الموقع.",
|
||||
"dismiss_aria": "تجاهل مطالبة الإشعارات",
|
||||
"ios_hint": "على iOS، ثبّت الموقع على الشاشة الرئيسية أولًا - يقدّم Safari خدمة Web Push فقط لتطبيقات PWA المثبّتة."
|
||||
},
|
||||
"sound_selection": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1068,6 +1068,7 @@
|
||||
"disable": "غیرفعال کردن",
|
||||
"confirm_disable_title": "غیرفعال کردن اعلانهای پسزمینه؟",
|
||||
"confirm_disable_message": "این دستگاه دیگر هشدار دریافت نمیکند",
|
||||
"dismiss_aria": "رد کردن پیشنهاد اعلانها",
|
||||
"ios_hint": "در iOS ابتدا سایت را به صفحه اصلی اضافه کنید"
|
||||
},
|
||||
"sound_selection": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"disable": "השבת",
|
||||
"confirm_disable_title": "השבת התראות בדפדפן?",
|
||||
"confirm_disable_message": "התקן זה יפסיק לקבל התראות כאשר האתר סגור.",
|
||||
"dismiss_aria": "בטל הודעת התראות",
|
||||
"ios_hint": "ב־iOS, התקן קודם כל את האתר בהיתוך שלך - Safari מספק Web Push רק לPWA מותקנות."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1078,6 +1078,7 @@
|
||||
"description": "このサイトが閉じているときに新着メールのシステム通知を受信します。Bulwark プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。",
|
||||
"disable": "無効化",
|
||||
"enable": "有効化",
|
||||
"dismiss_aria": "通知プロンプトを閉じる",
|
||||
"ios_hint": "iOS では、最初にサイトをホーム画面にインストールしてください。Safari はインストールされた PWA にのみ Web Push を配信します。",
|
||||
"reenable": "再登録",
|
||||
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
|
||||
|
||||
@@ -1078,6 +1078,7 @@
|
||||
"description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. Bulwark 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.",
|
||||
"disable": "비활성화",
|
||||
"enable": "활성화",
|
||||
"dismiss_aria": "알림 프롬프트 닫기",
|
||||
"ios_hint": "iOS에서는 먼저 사이트를 홈 화면에 설치하세요. Safari는 설치된 PWA에만 Web Push를 전달합니다.",
|
||||
"reenable": "다시 등록",
|
||||
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1078,6 +1078,7 @@
|
||||
"description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей Bulwark; релей никогда не видит содержимое писем.",
|
||||
"disable": "Отключить",
|
||||
"enable": "Включить",
|
||||
"dismiss_aria": "Закрыть запрос на уведомления",
|
||||
"ios_hint": "В iOS сначала установите сайт на главный экран – Safari доставляет Web Push только установленным PWA.",
|
||||
"reenable": "Перерегистрировать",
|
||||
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1078,6 +1078,7 @@
|
||||
"description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле Bulwark; реле ніколи не бачить вміст листів.",
|
||||
"disable": "Вимкнути",
|
||||
"enable": "Увімкнути",
|
||||
"dismiss_aria": "Закрити запит на сповіщення",
|
||||
"ios_hint": "На iOS спочатку встановіть сайт на головний екран – Safari доставляє Web Push лише встановленим PWA.",
|
||||
"reenable": "Перереєструвати",
|
||||
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
|
||||
|
||||
@@ -1078,6 +1078,7 @@
|
||||
"description": "在此网站关闭时接收新邮件的系统通知。通过 Bulwark 推送中继传送;中继永远不会看到邮件内容。",
|
||||
"disable": "禁用",
|
||||
"enable": "启用",
|
||||
"dismiss_aria": "关闭通知提示",
|
||||
"ios_hint": "在 iOS 上,请先将网站安装到主屏幕 – Safari 仅向已安装的 PWA 发送 Web Push。",
|
||||
"reenable": "重新注册",
|
||||
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
|
||||
|
||||
Reference in New Issue
Block a user