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 (
-
+