diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index d50d08e0..b300a1e4 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native'; +import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera'; import { X } from 'lucide-react-native'; @@ -23,6 +23,8 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { // Guards against the camera firing onBarcodeScanned dozens of times for the // same code before the modal tears down. const handled = React.useRef(false); + // Once iOS/Android stops letting us re-prompt, the only path left is Settings. + const blocked = !!permission && !permission.granted && !permission.canAskAgain; React.useEffect(() => { if (visible) handled.current = false; @@ -82,12 +84,16 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { {!permission?.granted ? ( - {permission && !permission.canAskAgain - ? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.' - : 'Bulwark Mail needs camera access to scan a sign-in QR code.'} + {blocked + ? 'Camera access is turned off. Turn it on in Settings to scan a sign-in QR code.' + : 'Scanning a sign-in QR code uses the camera. The QR code is read on this device only.'} - ) : null} diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 46f27296..827e6271 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -234,19 +234,28 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const openDuration = useAnimDuration(240); const closeDuration = useAnimDuration(200); + // Runs the slide-in. Kicked from both the visible effect and the Modal's + // onShow: on the very first open the modal's native view is not attached yet + // when the effect fires, so that first animation is dropped and the drawer + // stays parked off-screen. Re-running it once the modal is on screen commits + // the final offset (a no-op on every subsequent open). + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }), + ]).start(); + }, [slideX, overlayOpacity, openDuration]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }), - ]).start(); + runOpen(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }), ]).start(); } - }, [visible, slideX, overlayOpacity, openDuration, closeDuration]); + }, [visible, runOpen, slideX, overlayOpacity, closeDuration]); const accountEmail = username || ''; const initials = React.useMemo( @@ -274,6 +283,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) animationType="none" statusBarTranslucent onRequestClose={onClose} + onShow={runOpen} > diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index a9d04fb4..f11603ea 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -54,21 +54,28 @@ export function CalendarSidebarDrawer({ const closeDuration = useAnimDuration(200); const [expandedId, setExpandedId] = React.useState(null); + // Also kicked from the Modal's onShow — the first open fires this effect + // before the modal's native view exists, so that animation is dropped and + // the drawer stays parked off-screen until the second open. + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { + toValue: 0, + duration: openDuration, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(overlayOpacity, { + toValue: 1, + duration: openDuration, + useNativeDriver: true, + }), + ]).start(); + }, [slideX, overlayOpacity, openDuration]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { - toValue: 0, - duration: openDuration, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(overlayOpacity, { - toValue: 1, - duration: openDuration, - useNativeDriver: true, - }), - ]).start(); + runOpen(); } else { setExpandedId(null); Animated.parallel([ @@ -85,7 +92,7 @@ export function CalendarSidebarDrawer({ }), ]).start(); } - }, [visible, slideX, overlayOpacity, openDuration, closeDuration]); + }, [visible, runOpen, slideX, overlayOpacity, closeDuration]); const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]); @@ -114,6 +121,7 @@ export function CalendarSidebarDrawer({ animationType="none" statusBarTranslucent onRequestClose={onClose} + onShow={runOpen} > diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 56339ab5..a2b817c7 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -67,19 +67,26 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current; const overlay = React.useRef(new Animated.Value(0)).current; + // Also kicked from the Modal's onShow — the first open fires this effect + // before the modal's native view exists, so that animation is dropped and + // the drawer stays parked off-screen until the second open. + const runOpen = React.useCallback(() => { + Animated.parallel([ + Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }), + ]).start(); + }, [slideX, overlay]); + React.useEffect(() => { if (visible) { - Animated.parallel([ - Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - Animated.timing(overlay, { toValue: 1, duration: 240, useNativeDriver: true }), - ]).start(); + runOpen(); } else { Animated.parallel([ Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(overlay, { toValue: 0, duration: 200, useNativeDriver: true }), ]).start(); } - }, [visible, slideX, overlay]); + }, [visible, runOpen, slideX, overlay]); const select = (cat: ContactCategory) => { setSelectedCategory(cat); @@ -87,7 +94,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { }; return ( - +