Files
native/src/components/PushOnboardingPrompt.tsx
T

201 lines
6.9 KiB
TypeScript

import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { BellRing, X } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
import { useAuthStore } from '../stores/auth-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import {
DEFAULT_RELAY_BASE_URL,
dismissPushPrompt,
getEffectiveRelayBaseUrl,
isPushEnabledForAccount,
isPushSupported,
PushSetupError,
setStoredRelayBaseUrl,
setupPushNotifications,
wasPushPromptDismissed,
} from '../lib/push-notifications';
// Mirrors the webmail's push-notification-prompt: a short delay after login
// so the inbox renders first, and never again for an account once dismissed.
const PROMPT_DELAY_MS = 1500;
/**
* Once-per-account invitation to turn on background notifications. Shown
* only where push can actually work (Android with Play services), when the
* user has not disabled mail notifications, and until either "Enable" or
* "Not now" has been tapped for the active account.
*
* It floats above the tab bar and slides in, like the webmail's card, rather
* than taking space at the top: arriving 1.5 s into a launch it used to push
* the whole mail list down, and it drew under the status bar.
*/
export function PushOnboardingPrompt(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const client = useAuthStore((s) => s.client);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const username = useAuthStore((s) => s.username);
const emailNotificationsEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const [visible, setVisible] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const duration = useAnimDuration(200);
const appear = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
setVisible(false);
setError(null);
if (!isAuthenticated || !client || !activeAccountId || !emailNotificationsEnabled) return;
if (!isPushSupported()) return;
let cancelled = false;
const timer = setTimeout(() => {
void (async () => {
if (await wasPushPromptDismissed(activeAccountId)) return;
if (await isPushEnabledForAccount(activeAccountId)) return;
if (!cancelled) setVisible(true);
})();
}, PROMPT_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [isAuthenticated, client, activeAccountId, emailNotificationsEnabled]);
React.useEffect(() => {
if (!visible) return;
appear.setValue(0);
Animated.timing(appear, {
toValue: 1,
duration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [visible, appear, duration]);
if (!visible || !activeAccountId) return null;
const handleDismiss = () => {
setVisible(false);
void dismissPushPrompt(activeAccountId);
};
const handleEnable = async () => {
setBusy(true);
setError(null);
try {
const relayBaseUrl = (await getEffectiveRelayBaseUrl()) || DEFAULT_RELAY_BASE_URL;
await setStoredRelayBaseUrl(relayBaseUrl);
await setupPushNotifications({ relayBaseUrl, accountLabel: username ?? undefined });
await dismissPushPrompt(activeAccountId);
setVisible(false);
} catch (err) {
const message = err instanceof PushSetupError || err instanceof Error
? err.message
: t('settings.notifications.push.setup_failed', 'Setup failed');
setError(message);
} finally {
setBusy(false);
}
};
return (
<Animated.View
pointerEvents="box-none"
style={[
styles.wrap,
{
left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom,
opacity: appear,
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }],
},
]}
>
<View style={styles.card} accessibilityRole="summary" accessibilityLiveRegion="polite">
<View style={styles.iconWrap}>
<BellRing size={18} color={c.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.title}>
{t('push_prompt.title', 'Get notified about new mail')}
</Text>
<Text style={styles.body}>
{t(
'push_prompt.body',
'Turn on background notifications so new messages reach you while the app is closed.',
)}
</Text>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.actions}>
<Button size="sm" onPress={() => void handleEnable()} loading={busy}>
{t('push_prompt.enable', 'Enable')}
</Button>
<Button size="sm" variant="ghost" onPress={handleDismiss} disabled={busy}>
{t('push_prompt.dismiss', 'Not now')}
</Button>
</View>
</View>
<Pressable
onPress={handleDismiss}
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
style={styles.close}
>
<X size={16} color={c.mutedForeground} />
</Pressable>
</View>
</Animated.View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
zIndex: 50,
},
card: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 6,
},
iconWrap: {
width: 36,
height: 36,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
title: { ...typography.bodyMedium, color: c.text },
body: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
error: { ...typography.caption, color: c.error, marginTop: spacing.xs },
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
close: { padding: 2 },
});
}