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); }, }} >
{t('Settings.push_prompt_title')}
{t('Settings.push_prompt_body')} {denied && ( {t('Settings.push_permission_blocked')} )}
); }