diff --git a/src/components/IdentitySheet.tsx b/src/components/IdentitySheet.tsx index 42c260ce..043441c6 100644 --- a/src/components/IdentitySheet.tsx +++ b/src/components/IdentitySheet.tsx @@ -10,6 +10,7 @@ import { useSheetDrag } from '../lib/use-sheet-drag'; import type { Identity } from '../api/types'; import { useLocaleStore } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; interface IdentitySheetProps { visible: boolean; @@ -26,6 +27,8 @@ export function IdentitySheet({ const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); const { t } = useLocaleStore(); @@ -52,7 +55,7 @@ export function IdentitySheet({ diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx index b1267431..4095cdaa 100644 --- a/src/components/MoveSheet.tsx +++ b/src/components/MoveSheet.tsx @@ -15,6 +15,7 @@ import { useLocaleStore } from '../stores/locale-store'; import { localizeMailboxName } from '../lib/mailbox-label'; import type { Mailbox } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon { const lower = name.toLowerCase(); @@ -44,6 +45,8 @@ export function MoveSheet({ const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); @@ -84,7 +87,7 @@ export function MoveSheet({ diff --git a/src/components/PushOnboardingPrompt.tsx b/src/components/PushOnboardingPrompt.tsx index c7bf6e1c..16c80d4f 100644 --- a/src/components/PushOnboardingPrompt.tsx +++ b/src/components/PushOnboardingPrompt.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useFloatingCardFrame } from '../lib/use-layout'; import { BellRing, X } from 'lucide-react-native'; import Button from './Button'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; @@ -24,8 +25,6 @@ import { // Mirrors the webmail's push-notification-prompt: a short delay after login // so the inbox renders first, and never again for an account once dismissed. const PROMPT_DELAY_MS = 1500; -// Clears the tab bar, like the toasts (ToastHost). -const TAB_BAR_CLEARANCE = 64; /** * Once-per-account invitation to turn on background notifications. Shown @@ -51,6 +50,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null { const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const insets = useSafeAreaInsets(); + // Full width above the tab bar on a phone; bottom right on a tablet. + const frame = useFloatingCardFrame(); const duration = useAnimDuration(200); const appear = React.useRef(new Animated.Value(0)).current; @@ -117,7 +118,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null { style={[ styles.wrap, { - bottom: insets.bottom + TAB_BAR_CLEARANCE, + left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity: appear, transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }], }, @@ -165,8 +166,6 @@ function makeStyles(c: ThemePalette) { return StyleSheet.create({ wrap: { position: 'absolute', - left: spacing.md, - right: spacing.md, zIndex: 50, }, card: { diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index eda55105..f2dd9f93 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -12,6 +12,7 @@ import { useLocaleStore } from '../stores/locale-store'; import { getEmailTagIds } from '../lib/thread-utils'; import type { Email } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; interface TagSheetProps { visible: boolean; @@ -29,6 +30,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); @@ -73,7 +76,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle diff --git a/src/components/TemplateSheet.tsx b/src/components/TemplateSheet.tsx index 33eb6252..aeb4970e 100644 --- a/src/components/TemplateSheet.tsx +++ b/src/components/TemplateSheet.tsx @@ -11,6 +11,7 @@ import { useLocaleStore } from '../stores/locale-store'; import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store'; import { filterTemplates } from '../lib/template-utils'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; interface TemplateSheetProps { visible: boolean; @@ -33,6 +34,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) const hydrate = useTemplatesStore((s) => s.hydrate); const [query, setQuery] = React.useState(''); const slideY = React.useRef(new Animated.Value(500)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); @@ -71,7 +74,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) diff --git a/src/components/ToastHost.tsx b/src/components/ToastHost.tsx index 1762398a..86043935 100644 --- a/src/components/ToastHost.tsx +++ b/src/components/ToastHost.tsx @@ -4,6 +4,7 @@ import { type AccessibilityActionEvent, type LayoutChangeEvent, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useFloatingCardFrame } from '../lib/use-layout'; import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native'; import { spacing, radius, type ThemePalette } from '../theme/tokens'; import { useColors, useResolvedTheme } from '../theme/colors'; @@ -21,9 +22,14 @@ import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe'; export function ToastHost(): React.ReactElement | null { const toasts = useToastStore((s) => s.toasts); const insets = useSafeAreaInsets(); + // Full width above the tab bar on a phone; bottom right on a tablet. + const frame = useFloatingCardFrame(); if (toasts.length === 0) return null; return ( - + {toasts.map((t) => )} ); @@ -226,8 +232,6 @@ function ToastCard({ toast }: { toast: Toast }) { const styles = StyleSheet.create({ wrap: { position: 'absolute', - left: spacing.md, - right: spacing.md, gap: spacing.sm, zIndex: 60, }, diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index 5217ad6c..5f84e9cf 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useFloatingCardFrame } from '../lib/use-layout'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; @@ -11,8 +12,6 @@ import { useLocaleStore } from '../stores/locale-store'; import type { RootStackParamList } from '../navigation/types'; const VISIBLE_MS = 5000; -// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost). -const BOTTOM_CLEARANCE = 64; interface Shown { label: string; @@ -32,6 +31,8 @@ export function UndoSnackbar() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); + // Full width above the tab bar on a phone; bottom right on a tablet. + const frame = useFloatingCardFrame(); const t = useLocaleStore((s) => s.t); const navigation = useNavigation>(); const entry = useEmailStore((s) => s.pendingUndo); @@ -128,7 +129,7 @@ export function UndoSnackbar() { pointerEvents="box-none" style={[ styles.wrap, - { bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] }, + { left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] }, ]} > @@ -160,8 +161,6 @@ function makeStyles(c: ThemePalette) { return StyleSheet.create({ wrap: { position: 'absolute', - left: spacing.md, - right: spacing.md, zIndex: 50, }, bar: { diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index e44e760d..9a4e054f 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -18,6 +18,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; import Button from '../Button'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; export interface CalendarEditValues { name: string; @@ -36,6 +37,8 @@ interface CalendarEditSheetProps { // Create / rename / recolour / describe a calendar. The same fields the // webmail's calendar-management settings offer, as a bottom sheet. export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -72,7 +75,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend return ( - + diff --git a/src/components/calendar/CalendarShareSheet.tsx b/src/components/calendar/CalendarShareSheet.tsx index 683f0e1a..17db4fef 100644 --- a/src/components/calendar/CalendarShareSheet.tsx +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -18,6 +18,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager'; @@ -70,6 +71,8 @@ interface CalendarShareSheetProps { // principal list comes from the same Principal/query the Files share sheet // uses. export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -158,7 +161,7 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare return ( - + diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 972a2a9f..7fb0fd82 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -61,6 +61,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag'; import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence'; import type { TranslateFn } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; type RsvpStatus = 'accepted' | 'declined' | 'tentative'; @@ -141,6 +142,8 @@ export function EventDetailSheet({ // name) render with the contact card's name instead of the raw email. const resolveContactName = useContactNameResolver(); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const visible = !!event; const dragHandlers = useSheetDrag({ @@ -255,7 +258,7 @@ export function EventDetailSheet({ - + diff --git a/src/components/calendar/ICalImportSheet.tsx b/src/components/calendar/ICalImportSheet.tsx index ef1ece06..8bbe4713 100644 --- a/src/components/calendar/ICalImportSheet.tsx +++ b/src/components/calendar/ICalImportSheet.tsx @@ -18,6 +18,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; import type { Locale } from 'date-fns'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; // expo-document-picker is loaded lazily on first use; its native module is not // linked into every build (matches the FilesScreen / ContactImportSheet flow). @@ -75,6 +76,8 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor const [result, setResult] = React.useState(null); const slideY = React.useRef(new Animated.Value(900)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const resetState = React.useCallback(() => { @@ -189,7 +192,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor - + {t('calendar.import.title', 'Import Calendar')} diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx index 720a1c5c..73a11250 100644 --- a/src/components/calendar/ICalSubscriptionSheet.tsx +++ b/src/components/calendar/ICalSubscriptionSheet.tsx @@ -18,6 +18,7 @@ import { } from '../../stores/calendar-subscriptions-store'; import { jmapClient } from '../../api/jmap-client'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; // The webmail's refresh intervals and their labels. const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [ @@ -76,6 +77,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { }; const slideY = React.useRef(new Animated.Value(900)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { @@ -136,7 +139,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { - + diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index b7db70c5..a479102d 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -43,6 +43,7 @@ import { isWritableCalendar } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue'; @@ -108,6 +109,8 @@ export function TasksSheet({ const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, @@ -236,7 +239,7 @@ export function TasksSheet({ - + diff --git a/src/components/contacts/AddressBookPickerSheet.tsx b/src/components/contacts/AddressBookPickerSheet.tsx index a7ae075f..3059bf1e 100644 --- a/src/components/contacts/AddressBookPickerSheet.tsx +++ b/src/components/contacts/AddressBookPickerSheet.tsx @@ -10,6 +10,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag'; import { useContactsStore } from '../../stores/contacts-store'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; interface Props { visible: boolean; @@ -35,6 +36,8 @@ export default function AddressBookPickerSheet({ const [busy, setBusy] = React.useState(false); const slideY = React.useRef(new Animated.Value(600)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose }); @@ -78,7 +81,7 @@ export default function AddressBookPickerSheet({ diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx index a3415529..a42cf2fc 100644 --- a/src/components/contacts/ContactImportSheet.tsx +++ b/src/components/contacts/ContactImportSheet.tsx @@ -14,6 +14,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; // expo-document-picker is loaded lazily on first use; its native module is not // linked into every build (matches the FilesScreen upload flow). @@ -57,6 +58,8 @@ export default function ContactImportSheet({ const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null); const slideY = React.useRef(new Animated.Value(900)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const resetState = React.useCallback(() => { @@ -165,7 +168,7 @@ export default function ContactImportSheet({ - + {t('contacts.import.title', 'Import Contacts')} diff --git a/src/components/contacts/ContactPickerSheet.tsx b/src/components/contacts/ContactPickerSheet.tsx index a71993aa..6087fc4e 100644 --- a/src/components/contacts/ContactPickerSheet.tsx +++ b/src/components/contacts/ContactPickerSheet.tsx @@ -17,6 +17,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from ' import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; interface Props { visible: boolean; @@ -42,6 +43,8 @@ export default function ContactPickerSheet({ const [selected, setSelected] = React.useState>(new Set()); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlay = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, @@ -100,7 +103,7 @@ export default function ContactPickerSheet({ - + diff --git a/src/components/contacts/TagAssignSheet.tsx b/src/components/contacts/TagAssignSheet.tsx index 1cb9591c..fcda8431 100644 --- a/src/components/contacts/TagAssignSheet.tsx +++ b/src/components/contacts/TagAssignSheet.tsx @@ -10,6 +10,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag'; import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; interface Props { visible: boolean; @@ -36,6 +37,8 @@ export default function TagAssignSheet({ const [input, setInput] = React.useState(''); const slideY = React.useRef(new Animated.Value(600)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose }); @@ -68,7 +71,7 @@ export default function TagAssignSheet({ diff --git a/src/components/email/ActionSheet.tsx b/src/components/email/ActionSheet.tsx index db68e78f..50b06e5a 100644 --- a/src/components/email/ActionSheet.tsx +++ b/src/components/email/ActionSheet.tsx @@ -7,6 +7,7 @@ import { useColors } from '../../theme/colors'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; export interface ActionSheetItem { key: string; @@ -38,6 +39,8 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children const insets = useSafeAreaInsets(); const t = useLocaleStore((s) => s.t); const slideY = React.useRef(new Animated.Value(500)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); @@ -63,7 +66,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children diff --git a/src/components/files/ShareSheet.tsx b/src/components/files/ShareSheet.tsx index d7f224c7..73e185d0 100644 --- a/src/components/files/ShareSheet.tsx +++ b/src/components/files/ShareSheet.tsx @@ -19,6 +19,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; type RolePreset = 'read' | 'readWrite' | 'manager'; @@ -66,6 +67,8 @@ interface ShareSheetProps { } export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -158,7 +161,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps return ( - + diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx index 47649751..6f01f6b6 100644 --- a/src/components/settings/FolderSettings.tsx +++ b/src/components/settings/FolderSettings.tsx @@ -26,6 +26,7 @@ import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email'; import { jmapClient } from '../../api/jmap-client'; import type { Mailbox } from '../../api/types'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; const ROLE_ICON: Record = { inbox: Inbox, drafts: FileText, sent: Send, trash: Trash, @@ -64,6 +65,8 @@ function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => strin } export function FolderSettings() { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -299,7 +302,7 @@ export function FolderSettings() { - + diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx index abc9168d..193e8d92 100644 --- a/src/components/settings/IdentitySettings.tsx +++ b/src/components/settings/IdentitySettings.tsx @@ -28,6 +28,7 @@ import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/rec import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox'; import { sanitizeSignatureHtml } from '../../lib/signature-utils'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; type DraftIdentity = { id: string; @@ -93,6 +94,8 @@ function parseAddressList(value: string): { list: EmailAddress[]; invalid: strin } export function IdentitySettings() { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const identities = useSettingsStore((s) => s.identities); @@ -300,7 +303,7 @@ export function IdentitySettings() { ))} - + diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index fafa8c1d..95d521f5 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -18,8 +18,11 @@ import { useColors } from '../../theme/colors'; import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetInsets } from '../../lib/use-layout'; export function TemplateSettings() { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const tr = useLocaleStore((s) => s.t); @@ -198,7 +201,7 @@ export function TemplateSettings() { {/* Edit/Create modal */} - + @@ -272,7 +275,7 @@ export function TemplateSettings() { {/* Import modal */} setImportVisible(false)}> - + {tr('settings.templates.import', 'Import')} diff --git a/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx b/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx index 133d416b..7d4fa202 100644 --- a/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx +++ b/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx @@ -25,6 +25,7 @@ import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/ import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types'; import { useSyncCollections } from './use-sync-collections'; import { MODAL_ORIENTATIONS } from '../../../lib/modal-orientations'; +import { useSheetInsets } from '../../../lib/use-layout'; interface Props { visible: boolean; @@ -49,6 +50,8 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho const changed = React.useRef(false); const slideY = React.useRef(new Animated.Value(600)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const close = React.useCallback(() => { const wasChanged = changed.current; @@ -116,7 +119,7 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho diff --git a/src/lib/__tests__/use-layout.test.ts b/src/lib/__tests__/use-layout.test.ts index 13746b91..0ecfae38 100644 --- a/src/lib/__tests__/use-layout.test.ts +++ b/src/lib/__tests__/use-layout.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 411, height: 914 }) })); +vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) })); -import { layoutFor, listPaneWidth } from '../use-layout'; +import { floatingCardFrame, layoutFor, listPaneWidth, sheetSideInsets } from '../use-layout'; describe('layoutFor', () => { it('keeps a phone in portrait on the single-pane layout', () => { @@ -50,3 +51,31 @@ describe('listPaneWidth', () => { expect(listPaneWidth(2000)).toBe(420); }); }); + +describe('sheetSideInsets', () => { + it('keeps a phone sheet full width inside the side insets', () => { + expect(sheetSideInsets(411, 0, 0)).toEqual({ left: 0, right: 0 }); + }); + + it('centres the sheet at 560 dp on a wide window', () => { + expect(sheetSideInsets(1280, 0, 0)).toEqual({ left: 360, right: 360 }); + expect(sheetSideInsets(914, 0, 48)).toEqual({ left: 177, right: 177 }); + }); + + it('never puts a centred sheet under a side inset', () => { + expect(sheetSideInsets(640, 0, 48)).toEqual({ left: 48, right: 48 }); + }); +}); + +describe('floatingCardFrame', () => { + it('spans a phone with 12 dp margins above the tab bar', () => { + expect(floatingCardFrame(411, false, { left: 0, right: 0 })).toEqual({ left: 12, right: 12, bottom: 64 }); + }); + + it('sits bottom right at 360 dp on a split layout', () => { + const f = floatingCardFrame(1280, true, { left: 0, right: 0 }); + expect(1280 - f.left - f.right).toBe(360); + expect(f.right).toBe(20); + expect(f.bottom).toBe(20); + }); +}); diff --git a/src/lib/use-layout.ts b/src/lib/use-layout.ts index eb77de83..31c1c175 100644 --- a/src/lib/use-layout.ts +++ b/src/lib/use-layout.ts @@ -1,4 +1,6 @@ +import React from 'react'; import { useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; /** * Window size classes for the adaptive layouts (docs/ux-audit-2026-10.md, @@ -45,3 +47,67 @@ export function listPaneWidth(windowWidth: number): number { /** Width for sheets, dialogs and floating cards that would span a wide window. */ export const SHEET_MAX_WIDTH = 560; + +/** + * Left and right offsets of a bottom sheet: centred at SHEET_MAX_WIDTH on a + * window 600 dp or wider (a phone in landscape included), else full width + * inside the side safe-area insets (a landscape notch or navigation bar). + */ +export function sheetSideInsets( + width: number, + insetLeft: number, + insetRight: number, +): { left: number; right: number } { + if (width >= MEDIUM_MIN_WIDTH) { + const side = Math.max(Math.round((width - SHEET_MAX_WIDTH) / 2), insetLeft, insetRight); + return { left: side, right: side }; + } + return { left: insetLeft, right: insetRight }; +} + +/** `sheetSideInsets` for the current window. */ +export function useSheetInsets(): { left: number; right: number } { + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { left, right } = sheetSideInsets(width, insets.left, insets.right); + return React.useMemo(() => ({ left, right }), [left, right]); +} + +/** APP.md toast card: 360 wide, 20 from the edges on a large screen. */ +const FLOATING_CARD_WIDTH = 360; +const FLOATING_CARD_MARGIN = 20; +/** Phone: full width with 12 dp margins, above the 64 dp tab bar. */ +const PHONE_CARD_MARGIN = 12; +const TAB_BAR_CLEARANCE = 64; + +/** + * Where toasts, the undo bar and the push prompt float. A phone gets the full + * width above the tab bar; a split layout (tabs in a side rail) gets the + * webmail's desktop placement, bottom right. + */ +export function floatingCardFrame( + width: number, + split: boolean, + insets: { left: number; right: number }, +): { left: number; right: number; bottom: number } { + if (split) { + return { + left: Math.max(FLOATING_CARD_MARGIN, width - FLOATING_CARD_WIDTH - FLOATING_CARD_MARGIN - insets.right), + right: FLOATING_CARD_MARGIN + insets.right, + bottom: FLOATING_CARD_MARGIN, + }; + } + return { + left: PHONE_CARD_MARGIN + insets.left, + right: PHONE_CARD_MARGIN + insets.right, + bottom: TAB_BAR_CLEARANCE, + }; +} + +/** `floatingCardFrame` for the current window; `bottom` excludes the safe-area inset. */ +export function useFloatingCardFrame(): { left: number; right: number; bottom: number } { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { left, right, bottom } = floatingCardFrame(width, layoutFor(width, height).isSplit, insets); + return React.useMemo(() => ({ left, right, bottom }), [left, right, bottom]); +} diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx index 5d905da2..6d9f7ce0 100644 --- a/src/screens/ContactDetailScreen.tsx +++ b/src/screens/ContactDetailScreen.tsx @@ -33,6 +33,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; type Nav = NativeStackNavigationProp; type Route = RouteProp; @@ -789,6 +790,8 @@ function MoreActionsSheet({ const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { @@ -810,7 +813,7 @@ function MoreActionsSheet({ - + diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 34f5e0b5..e965d1c8 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -48,6 +48,7 @@ import { accountScopedId } from '../lib/thread-utils'; import type { Email, EmailAddress, Identity } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; type Props = NativeStackScreenProps & { /** Shown in a split view's detail pane: no back button. */ @@ -1269,6 +1270,8 @@ function MoreMenuSheet({ const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(600)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose }); @@ -1294,7 +1297,7 @@ function MoreMenuSheet({ @@ -1418,6 +1421,8 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); @@ -1443,7 +1448,7 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 57bdabb9..2a69888c 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -66,6 +66,7 @@ import Dialog from '../components/Dialog'; import ShareSheet from '../components/files/ShareSheet'; import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; // A file row carries the display name alongside the rest of the node. interface FileRow extends FileNode { @@ -1208,6 +1209,8 @@ interface ActionsSheetProps { function ActionsSheet({ target, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete, }: ActionsSheetProps) { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeSheetStyles(c), [c]); if (!target) return null; @@ -1220,7 +1223,7 @@ function ActionsSheet({ return ( - + @@ -1302,6 +1305,8 @@ interface FolderPickerSheetProps { // its descendants (a folder can't be moved into itself), flattened with // indentation, plus the drive root. function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: FolderPickerSheetProps) { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeSheetStyles(c), [c]); const options = useMemo(() => { @@ -1342,7 +1347,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: return ( - + @@ -1390,6 +1395,8 @@ interface SortSheetProps { } function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChangeDir }: SortSheetProps) { + // Centred on a wide window, clear of side insets in landscape. + const sheetInsets = useSheetInsets(); const c = useColors(); const styles = React.useMemo(() => makeSheetStyles(c), [c]); if (!visible) return null; @@ -1397,7 +1404,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang return ( - + {t('files.settings_default_sort', 'Sort')}