fix: float the notification prompt instead of pushing the list down

The push onboarding card was laid out above the tab navigator. It
arrives 1.5 s into a launch, so it pushed the whole mail list down, and
it drew under the status bar because it ignored the top inset.

It now floats above the tab bar like the webmail's card and the toasts,
respecting the bottom inset, and fades and slides in (instantly when
animations are off), so nothing else moves when it appears or goes.
This commit is contained in:
Linus Rath
2026-09-24 19:35:47 +02:00
parent b9db3764ff
commit f2dd3a8b4b
+78 -35
View File
@@ -1,9 +1,11 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BellRing, X } from 'lucide-react-native';
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';
@@ -22,12 +24,18 @@ import {
// 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;
// Clears the tab bar, like the toasts (ToastHost).
const TAB_BAR_CLEARANCE = 64;
/**
* 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();
@@ -42,6 +50,9 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
const [visible, setVisible] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const insets = useSafeAreaInsets();
const duration = useAnimDuration(200);
const appear = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
setVisible(false);
@@ -63,6 +74,17 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
};
}, [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 = () => {
@@ -90,56 +112,77 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
};
return (
<View style={styles.card} accessibilityRole="summary">
<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>
<Animated.View
pointerEvents="box-none"
style={[
styles.wrap,
{
bottom: insets.bottom + TAB_BAR_CLEARANCE,
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={8}
accessibilityRole="button"
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
style={styles.close}
>
<X size={16} color={c.mutedForeground} />
</Pressable>
</View>
<Pressable
onPress={handleDismiss}
hitSlop={8}
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',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
card: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
marginHorizontal: spacing.md,
marginTop: spacing.sm,
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,