import React, { useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
} from 'folds';
import { useTranslation } from 'react-i18next';
import { isNativePlatform } from '../../utils/capacitor';
import {
usePushNotificationStatus,
useRegisterPushNotifications,
usePushEnabled,
} from '../../hooks/usePushNotifications';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
// 7-day cooldown between dismissals. Short enough that users who change their mind
// don't have to dig into settings to find the toggle; long enough that saying "not now"
// doesn't feel ignored.
const DISMISS_KEY = 'vojo_push_prompt_dismissed_at';
const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
// Slight delay so the prompt doesn't collide with login/first-render UI.
const INITIAL_DELAY_MS = 1500;
const wasRecentlyDismissed = (): boolean => {
const raw = localStorage.getItem(DISMISS_KEY);
if (!raw) return false;
const ts = Number(raw);
if (!Number.isFinite(ts)) return false;
return Date.now() - ts < DISMISS_COOLDOWN_MS;
};
const markDismissed = (): void => {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
};
const noop = (): void => undefined;
export function PushPermissionPrompt() {
const { t } = useTranslation();
const status = usePushNotificationStatus();
const register = useRegisterPushNotifications();
const enabled = usePushEnabled();
const [visible, setVisible] = useState(false);
const [registerState, doRegister] = useAsyncCallback(
useCallback(async () => {
await register();
}, [register])
);
const busy = registerState.status === AsyncStatus.Loading;
useEffect(() => {
// Only nag on native — browsers have their own permission UX and stacking
// our soft prompt on top just spams the user.
if (!isNativePlatform()) return undefined;
if (status !== 'prompt') return undefined;
// `enabled` is reactive via usePushEnabled — if push gets turned on from
// anywhere else (settings, another tab) during the delay, the effect reruns,
// cleanup cancels the timer, and the prompt stays hidden.
if (enabled) {
setVisible(false);
return undefined;
}
if (wasRecentlyDismissed()) return undefined;
const timer = setTimeout(() => setVisible(true), INITIAL_DELAY_MS);
return () => clearTimeout(timer);
}, [status, enabled]);
// Close on a successful enable — the status change would hide it too, but
// closing eagerly avoids a visible flicker while Capacitor round-trips permissions.
useEffect(() => {
if (registerState.status === AsyncStatus.Success) setVisible(false);
}, [registerState.status]);
// Dismiss paths are guarded against firing mid-register: closing during the
// native permission round-trip would stamp the 7-day cooldown AND hide any
// transient error, leaving the user stuck with push off and no nag for a week.
const handleLater = () => {
if (busy) return;
markDismissed();
setVisible(false);
};
const handleEnable = () => {
if (busy) return;
// useAsyncCallback re-throws so the state can be read via registerState;
// we don't need the rejection at the call site and without .catch it
// surfaces as an unhandled rejection for expected permission_denied flows.
doRegister().catch(noop);
};
if (!visible) return null;
const denied =
registerState.status === AsyncStatus.Error &&
(registerState.error as Error | undefined)?.message === 'permission_denied';
return (
}>
!busy,
escapeDeactivates: (ev) => {
if (busy) return false;
return stopPropagation(ev);
},
}}
>
);
}