mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
The background resync only touched the active client, so accounts turned on from the all-accounts panel lapsed when the server's push subscription expired after seven days. The resync now walks every connected login in turn, active one first, and runs again once the background logins finish connecting. Single-account behaviour is unchanged. The panel also lists which accounts failed to enable and why, instead of only a count.
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, 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,
|
|
} from "@/lib/web-push";
|
|
import { resolveActiveRelayUrl } from "@/lib/push-relays";
|
|
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}`;
|
|
}
|
|
|
|
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.
|
|
useEffect(() => {
|
|
if (!policyLoaded || !isAuthenticated || !client || !accountId || isDemoMode) return;
|
|
let cancelled = false;
|
|
const resync = async () => {
|
|
// 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,
|
|
});
|
|
}
|
|
};
|
|
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).
|
|
const onVisible = () => { if (document.visibilityState === 'visible') void resync(); };
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
return () => {
|
|
cancelled = true;
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
};
|
|
}, [accountId, client, connectedAccountsRevision, isAuthenticated, isDemoMode, policyLoaded, pushNotifyInboxOnly, relayBaseUrl, username]);
|
|
|
|
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,
|
|
inboxOnly: pushNotifyInboxOnly,
|
|
});
|
|
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>
|
|
);
|
|
}
|