From 71af9d105db83a6a7f7788f25e757b29b30ea2a0 Mon Sep 17 00:00:00 2001 From: Ninad Chandorkar Date: Wed, 5 Aug 2026 21:52:13 +0530 Subject: [PATCH] fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes App Review and testers hit on the iOS build of this app. **Camera pre-prompt.** App Review rejects a custom pre-permission screen whose button says "Allow" under guideline 5.1.1(iv) — it reads as an imitation of the system dialog. Verbatim from the rejection: "A custom message appears before the permission request, and to proceed users press an 'Allow' button. Use words like 'Continue' or 'Next' on the button instead." The QR scanner's permission screen now says "Continue", and the copy above it explains what the camera is for instead of asking for access. When the OS stops allowing a re-prompt (canAskAgain is false) the button becomes "Open Settings" and calls Linking.openSettings(), which is the recovery path the same guideline suggests. **Drawer first open.** The sidebar drawers park at -screenWidth and start the slide-in from the effect that watches `visible`. That effect runs before the Modal's native view is attached, so the very first open loses its animation and the drawer renders almost entirely off-screen; opening it again works because the view now exists. The open animation also runs from the Modal's onShow, which commits the final offset once the modal is really on screen and is a no-op on later opens. Applied to the mail, calendar and contacts drawers, which all share the pattern. --- src/components/QrScanModal.tsx | 18 ++++++---- src/components/SidebarDrawer.tsx | 20 ++++++++--- .../calendar/CalendarSidebarDrawer.tsx | 36 +++++++++++-------- .../contacts/ContactsSidebarDrawer.tsx | 19 ++++++---- 4 files changed, 62 insertions(+), 31 deletions(-) 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 ( - +