fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open

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.
This commit is contained in:
Ninad Chandorkar
2026-08-05 21:52:13 +05:30
parent 82b97fada4
commit 71af9d105d
4 changed files with 62 additions and 31 deletions
+12 -6
View File
@@ -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 ? (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{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.'}
</Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}>
Allow camera access
<Button
variant="default"
size="md"
onPress={() => void (blocked ? Linking.openSettings() : requestPermission())}
>
{blocked ? 'Open Settings' : 'Continue'}
</Button>
</View>
) : null}
+15 -5
View File
@@ -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}
>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
@@ -54,21 +54,28 @@ export function CalendarSidebarDrawer({
const closeDuration = useAnimDuration(200);
const [expandedId, setExpandedId] = React.useState<string | null>(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}
>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
@@ -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 (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>