mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user