Merge pull request #38 from koolninad/fix/camera-prompt-and-drawer-first-open

fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open
This commit is contained in:
Linus Rath
2026-08-05 18:39:36 +02:00
committed by GitHub
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>