143 lines
4.8 KiB
TypeScript
143 lines
4.8 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,
|
|
Button,
|
|
} from 'folds';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { isNativePlatform } from '../../utils/capacitor';
|
|
import {
|
|
canUseFullScreenIntent,
|
|
openFullScreenIntentSettings,
|
|
} from '../../plugins/fullScreenIntent';
|
|
import { usePushEnabled } from '../../hooks/usePushNotifications';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
|
|
// Mirrors PushPermissionPrompt's cooldown. 7 days is long enough that "not now"
|
|
// isn't nagging, short enough that a user who changes their mind doesn't have
|
|
// to dig through Settings to find the toggle.
|
|
const DISMISS_KEY = 'vojo_fsi_prompt_dismissed_at';
|
|
const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
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()));
|
|
};
|
|
|
|
export function FullScreenIntentPrompt() {
|
|
const { t } = useTranslation();
|
|
const pushEnabled = usePushEnabled();
|
|
const [visible, setVisible] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Only relevant when push is on — FSI wakeup only matters if we're actually
|
|
// going to show CallStyle notifications. Showing this before push is enabled
|
|
// would be out-of-context: user doesn't yet know what "incoming call screen"
|
|
// means in our UX. The PushPermissionPrompt comes first.
|
|
if (!isNativePlatform()) return undefined;
|
|
if (!pushEnabled) {
|
|
setVisible(false);
|
|
return undefined;
|
|
}
|
|
if (wasRecentlyDismissed()) return undefined;
|
|
|
|
let cancelled = false;
|
|
canUseFullScreenIntent().then((allowed) => {
|
|
if (cancelled) return;
|
|
if (allowed) return;
|
|
// Delay matches PushPermissionPrompt so the two never try to stack on
|
|
// top of each other at exact startup — PushPermissionPrompt goes first
|
|
// since its effect depends on `status === 'prompt'`; by the time that's
|
|
// resolved to 'granted' (and pushEnabled flips true), the PushPrompt is
|
|
// already gone and this one has room.
|
|
setTimeout(() => {
|
|
if (!cancelled) setVisible(true);
|
|
}, INITIAL_DELAY_MS);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [pushEnabled]);
|
|
|
|
const handleLater = useCallback(() => {
|
|
markDismissed();
|
|
setVisible(false);
|
|
}, []);
|
|
|
|
const handleEnable = useCallback(() => {
|
|
// Opens Settings; the user returns to the app via the back button. We don't
|
|
// re-check canUseFullScreenIntent() on resume because the prompt will
|
|
// simply not re-show (7-day cooldown would apply if we markDismissed —
|
|
// we deliberately DO NOT mark dismissed here so if the user declines in
|
|
// Settings or forgets, the next startup still reminds them).
|
|
openFullScreenIntentSettings().catch(() => {
|
|
/* plugin missing — nothing to do */
|
|
});
|
|
setVisible(false);
|
|
}, []);
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: handleLater,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<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.fsi_prompt_title')}</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={handleLater} radii="300">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
<Text priority="400">{t('Settings.fsi_prompt_body')}</Text>
|
|
<Box direction="Row" gap="200" justifyContent="End">
|
|
<Button variant="Secondary" fill="Soft" onClick={handleLater}>
|
|
<Text size="B400">{t('Settings.fsi_prompt_later')}</Text>
|
|
</Button>
|
|
<Button variant="Primary" onClick={handleEnable}>
|
|
<Text size="B400">{t('Settings.fsi_prompt_open')}</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|