173 lines
6 KiB
TypeScript
173 lines
6 KiB
TypeScript
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 (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: handleLater,
|
|
// Lock out click-outside / Esc while register() is in-flight — same
|
|
// reason as handleLater's busy guard: we don't want to cooldown the
|
|
// prompt on an accidental dismiss during the native permission dialog.
|
|
clickOutsideDeactivates: () => !busy,
|
|
escapeDeactivates: (ev) => {
|
|
if (busy) return false;
|
|
return stopPropagation(ev);
|
|
},
|
|
}}
|
|
>
|
|
<Dialog variant="Surface">
|
|
<Header
|
|
style={{
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
}}
|
|
variant="Surface"
|
|
size="500"
|
|
>
|
|
<Box grow="Yes">
|
|
<Text size="H4">{t('Settings.push_prompt_title')}</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={handleLater} radii="300" disabled={busy}>
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
<Text priority="400">{t('Settings.push_prompt_body')}</Text>
|
|
{denied && (
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
{t('Settings.push_permission_blocked')}
|
|
</Text>
|
|
)}
|
|
<Box direction="Row" gap="200" justifyContent="End">
|
|
<Button variant="Secondary" fill="Soft" onClick={handleLater} disabled={busy}>
|
|
<Text size="B400">{t('Settings.push_prompt_later')}</Text>
|
|
</Button>
|
|
<Button
|
|
variant="Primary"
|
|
onClick={handleEnable}
|
|
before={busy ? <Spinner fill="Solid" variant="Primary" size="200" /> : undefined}
|
|
disabled={busy}
|
|
>
|
|
<Text size="B400">{t('Settings.enable')}</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|