mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
# Conflicts: # components/contacts/contacts-app.tsx # components/email/__tests__/composer-discard.test.tsx # components/email/calendar-invitation-banner.tsx # components/email/email-composer.tsx # components/mail/mail-app.tsx # components/pro/pro-compose-tab-body.tsx # components/pro/pro-email-tab-body.tsx # components/ui/__tests__/avatar.test.tsx # components/ui/avatar.tsx # hooks/use-mailbox-drop.ts # lib/__tests__/calendar-alerts.test.ts # lib/__tests__/jmap-calendar-tasks.test.ts # lib/__tests__/jmap-send-threading.test.ts # lib/demo/demo-client.ts # lib/demo/fixtures/calendars.ts # lib/jmap/client-interface.ts # lib/jmap/client.ts # lib/web-push.ts # stores/__tests__/contact-store.test.ts # stores/__tests__/email-store-thread-key.test.ts # stores/calendar-store.ts # stores/contact-store.ts # stores/email-store.ts
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { Bell, Loader2, X } from "@/components/icons";
|
|
import { useAccountStore } from "@/stores/account-store";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { usePolicyStore } from "@/stores/policy-store";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import {
|
|
enableWebPush,
|
|
isWebPushEnabled,
|
|
isWebPushSupported,
|
|
resyncWebPush,
|
|
waitForWebPushResync,
|
|
} from "@/lib/web-push";
|
|
import { resolveActiveRelayUrl } from "@/lib/push-relays";
|
|
import { jmapErrorType } from "@/lib/jmap/jmap-error";
|
|
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;
|
|
export const MUTES_RESYNC_DELAY_MS = 1_000;
|
|
|
|
function dismissedKey(accountId: string): string {
|
|
return `${DISMISSED_KEY_PREFIX}${accountId}`;
|
|
}
|
|
|
|
interface ResyncTarget {
|
|
client: NonNullable<ReturnType<typeof useAuthStore.getState>["client"]>;
|
|
accountLabel?: string;
|
|
}
|
|
|
|
/**
|
|
* Every login whose push registration this tab should keep alive: the active
|
|
* one first, then each other connected login. Push can be enabled for a login
|
|
* that is not on screen (the all-accounts panel in settings), and Stalwart
|
|
* expires a PushSubscription after 7 days, so renewing only the active login
|
|
* would let the others lapse. The other clients are the ones the auth store
|
|
* already keeps for the account switcher, so this opens no new connection;
|
|
* resyncWebPush itself skips logins without push in a local check.
|
|
*/
|
|
function resyncTargets(
|
|
activeClient: ResyncTarget["client"],
|
|
activeLabel: string | undefined,
|
|
): ResyncTarget[] {
|
|
const targets: ResyncTarget[] = [{ client: activeClient, accountLabel: activeLabel }];
|
|
const auth = useAuthStore.getState();
|
|
const others = auth.getAllConnectedClients?.() ?? new Map();
|
|
if (others.size === 0) return targets;
|
|
const accounts = useAccountStore.getState().accounts;
|
|
for (const [loginId, other] of others) {
|
|
if (!other || other === activeClient) continue;
|
|
const account = accounts.find((a) => a.id === loginId);
|
|
targets.push({ client: other, accountLabel: account?.username });
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
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);
|
|
// Bumped when the background logins finish connecting: the renewal below
|
|
// re-runs then so it reaches them too.
|
|
const connectedAccountsRevision = useAuthStore(
|
|
(state) => state.connectedAccountsRevision,
|
|
);
|
|
const emailNotificationsEnabled = useSettingsStore(
|
|
(state) => state.emailNotificationsEnabled,
|
|
);
|
|
const pushNotifyInboxOnly = useSettingsStore(
|
|
(state) => state.pushNotifyInboxOnly,
|
|
);
|
|
const policyLoaded = usePolicyStore((state) => state.loaded);
|
|
const policy = usePolicyStore((state) => state.policy);
|
|
const userPushRelayUrl = useSettingsStore((state) => state.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 = resolveActiveRelayUrl(policy, userPushRelayUrl);
|
|
|
|
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,
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
// Accounts that already have push on get their registration touched up in
|
|
// the background once per page load: expiry refreshed and the server-side
|
|
// delivery filter installed or repaired (older registrations predate it and
|
|
// would keep waking the device for spam). Deliberately independent of the
|
|
// prompt gating below - dismissing the onboarding prompt must not leave a
|
|
// live registration stale. The prompt in turn waits for this pass, since
|
|
// push reads as "off" while a dropped browser subscription is recreated.
|
|
useEffect(() => {
|
|
if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return;
|
|
let cancelled = false;
|
|
const resync = async (force = false) => {
|
|
// One login at a time: they share the browser's single
|
|
// PushSubscription, and a parallel re-create would race on it.
|
|
for (const target of resyncTargets(client, username ?? undefined)) {
|
|
if (cancelled) return;
|
|
await resyncWebPush({
|
|
client: target.client,
|
|
relayBaseUrl,
|
|
accountLabel: target.accountLabel,
|
|
inboxOnly: pushNotifyInboxOnly,
|
|
mutes: useSettingsStore.getState().notificationMutes,
|
|
force,
|
|
});
|
|
}
|
|
};
|
|
void resync();
|
|
// A long-open tab comes back to the foreground: renew the subscription
|
|
// before the server's 7-day expiry (resyncWebPush runs at most daily) and
|
|
// recreate one the browser dropped in the meantime.
|
|
const onVisible = () => { if (document.visibilityState === 'visible') void resync(); };
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
// The service worker saw the push service drop or replace the browser
|
|
// subscription (pushsubscriptionchange in public/sw.js): the relay has to
|
|
// learn the new endpoint now, not at the next daily pass.
|
|
const onSwMessage = (event: MessageEvent) => {
|
|
const kind = (event.data as { kind?: unknown } | null)?.kind;
|
|
if (kind === 'pushsubscriptionchange') void resync(true);
|
|
};
|
|
navigator.serviceWorker?.addEventListener('message', onSwMessage);
|
|
return () => {
|
|
cancelled = true;
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
navigator.serviceWorker?.removeEventListener('message', onSwMessage);
|
|
};
|
|
}, [accountId, client, connectedAccountsRevision, isAuthenticated, isDemoMode, policyLoaded, pushNotifyInboxOnly, relayBaseUrl, username]);
|
|
|
|
// Muting a folder or account changes the server-side push filter, which the
|
|
// daily renewal above would only send tomorrow. Send it to every login with
|
|
// push on now, after a short pause so a few clicks in a row make one update.
|
|
// Compared by content: a settings sync hands back an equal but new object.
|
|
const notificationMutes = useSettingsStore((state) => state.notificationMutes);
|
|
const mutesKey = JSON.stringify(notificationMutes);
|
|
const lastMutesKey = useRef(mutesKey);
|
|
useEffect(() => {
|
|
if (lastMutesKey.current === mutesKey) return;
|
|
lastMutesKey.current = mutesKey;
|
|
if (!policyLoaded || !isAuthenticated || !client || isDemoMode) return;
|
|
let cancelled = false;
|
|
const timer = setTimeout(async () => {
|
|
for (const target of resyncTargets(client, username ?? undefined)) {
|
|
if (cancelled) return;
|
|
await resyncWebPush({
|
|
client: target.client,
|
|
relayBaseUrl,
|
|
accountLabel: target.accountLabel,
|
|
inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly,
|
|
mutes: useSettingsStore.getState().notificationMutes,
|
|
force: true,
|
|
});
|
|
}
|
|
}, MUTES_RESYNC_DELAY_MS);
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(timer);
|
|
};
|
|
// Only a change of the mutes should send an update; the rest is read live.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mutesKey]);
|
|
|
|
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(() => {
|
|
// Push can read as "off" only because the background pass above is
|
|
// still recreating a browser subscription Safari dropped (#1321): wait
|
|
// for it, and offer onboarding only if push is still off afterwards.
|
|
void waitForWebPushResync(accountId)
|
|
.then(() => 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,
|
|
inboxOnly: pushNotifyInboxOnly,
|
|
mutes: useSettingsStore.getState().notificationMutes,
|
|
});
|
|
setShowPrompt(false);
|
|
} catch (err) {
|
|
// Readable text for the server refusals a user can act on.
|
|
const type = jmapErrorType(err);
|
|
if (type === "forbidden") setError(tPush("error_forbidden"));
|
|
else if (type === "overQuota") setError(tPush("error_over_quota"));
|
|
else 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>
|
|
);
|
|
}
|