diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5485a398..78da5bc3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,7 @@ - + diff --git a/app.config.js b/app.config.js index ff70718d..f787583c 100644 --- a/app.config.js +++ b/app.config.js @@ -31,7 +31,7 @@ module.exports = { // Android's manifest is maintained by hand and lists the same schemes. scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'], version: VERSION, - orientation: 'portrait', + orientation: 'default', icon: './assets/icon.png', userInterfaceStyle: 'automatic', newArchEnabled: true, diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 816ddc66..a82463e9 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -5,6 +5,7 @@ import Button from './Button'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useLocaleStore } from '../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; interface DialogInput { value: string; @@ -55,7 +56,7 @@ export default function Dialog({ const confirmLabel = confirmText ?? t('common.confirm', 'Confirm'); const cancelLabel = cancelText ?? t('common.cancel', 'Cancel'); return ( - + diff --git a/src/components/IdentitySheet.tsx b/src/components/IdentitySheet.tsx index 65390aae..42c260ce 100644 --- a/src/components/IdentitySheet.tsx +++ b/src/components/IdentitySheet.tsx @@ -9,6 +9,7 @@ import { useColors } from '../theme/colors'; 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'; interface IdentitySheetProps { visible: boolean; @@ -44,7 +45,7 @@ export function IdentitySheet({ }, [visible, slideY, overlayOpacity]); return ( - + diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx index ac458760..b1267431 100644 --- a/src/components/MoveSheet.tsx +++ b/src/components/MoveSheet.tsx @@ -14,6 +14,7 @@ import { useSheetDrag } from '../lib/use-sheet-drag'; 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'; function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon { const lower = name.toLowerCase(); @@ -76,7 +77,7 @@ export function MoveSheet({ }, [mailboxes]); return ( - + diff --git a/src/components/PasteSignInLinkModal.tsx b/src/components/PasteSignInLinkModal.tsx index b962f585..c8b8fb73 100644 --- a/src/components/PasteSignInLinkModal.tsx +++ b/src/components/PasteSignInLinkModal.tsx @@ -19,6 +19,7 @@ import Button from './Button'; import { useLocaleStore } from '../stores/locale-store'; import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth'; import { describeLoginError } from '../lib/login-errors'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; interface PasteSignInLinkModalProps { visible: boolean; @@ -80,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn const title = t('login.mobile.paste_title', 'Paste sign-in link'); return ( - + diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index cc458cd7..bd81ca3b 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors, useResolvedTheme } from '../theme/colors'; import Button from './Button'; import { useLocaleStore } from '../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; interface QrScanModalProps { visible: boolean; @@ -42,7 +43,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { ); return ( - + {permission?.granted ? ( ; focus(): void; + /** Drop the caret, which closes the keyboard. */ + blur(): void; } interface Props { @@ -176,6 +178,9 @@ const RichTextEditor = React.forwardRef(function Ri focus: () => { call(`window.__rne && window.__rne.focus()`); }, + blur: () => { + call(`document.activeElement && document.activeElement.blur()`); + }, }), [call]); const onMessage = (event: WebViewMessageEvent) => { diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index d207879f..a7e993a1 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -37,6 +37,7 @@ import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/types'; import type { Mailbox } from '../api/types'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; const CHEVRON_SLOT = 20; const INDENT_STEP = 12; @@ -228,7 +229,7 @@ function ActionSheet({ title, actions, onClose }: { title: string; actions: Shee const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( - + {title} @@ -270,7 +271,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose } onSubmit(name); }; return ( - + {title} @@ -746,7 +747,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const tagViewActive = !!filters.keyword; return ( - + diff --git a/src/components/TemplateSheet.tsx b/src/components/TemplateSheet.tsx index 4d0cb034..33eb6252 100644 --- a/src/components/TemplateSheet.tsx +++ b/src/components/TemplateSheet.tsx @@ -10,6 +10,7 @@ import { useSheetDrag } from '../lib/use-sheet-drag'; 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'; interface TemplateSheetProps { visible: boolean; @@ -63,7 +64,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) }, [templates, query]); return ( - + diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index c2b3fbe4..e44e760d 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -17,6 +17,7 @@ import { useColors } from '../../theme/colors'; 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'; export interface CalendarEditValues { name: string; @@ -69,7 +70,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 52be9dde..683f0e1a 100644 --- a/src/components/calendar/CalendarShareSheet.tsx +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -17,6 +17,7 @@ import type { Calendar, CalendarRights, Principal } from '../../api/types'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager'; @@ -155,7 +156,7 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare }; return ( - + diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index 0f43d235..f6181e68 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -23,6 +23,7 @@ import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { useLocaleStore } from '../../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; /** A calendar view listed at the top of the drawer. */ export interface DrawerViewOption { @@ -151,7 +152,7 @@ export function CalendarSidebarDrawer({ }; return ( - + diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx index 4d7955a9..720a1c5c 100644 --- a/src/components/calendar/ICalSubscriptionSheet.tsx +++ b/src/components/calendar/ICalSubscriptionSheet.tsx @@ -17,6 +17,7 @@ import { type CalendarSubscription, } from '../../stores/calendar-subscriptions-store'; import { jmapClient } from '../../api/jmap-client'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; // The webmail's refresh intervals and their labels. const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [ @@ -131,7 +132,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { }; return ( - + diff --git a/src/components/calendar/MonthDropdown.tsx b/src/components/calendar/MonthDropdown.tsx index fda002cf..5d03fad1 100644 --- a/src/components/calendar/MonthDropdown.tsx +++ b/src/components/calendar/MonthDropdown.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { FlatList, + ScrollView, StyleSheet, View, useWindowDimensions, @@ -49,7 +50,13 @@ export function MonthDropdown({ }: MonthDropdownProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { width } = useWindowDimensions(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + // Pages are as wide as the panel (the window, or a pane on a tablet) and + // re-measure on rotation. In a short window the panel takes at most about + // half of it and its month scrolls inside. + const [measuredWidth, setMeasuredWidth] = React.useState(0); + const width = measuredWidth || windowWidth; + const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55)); // The months are fixed while the panel is open; it remounts on each open. const [months] = React.useState(() => { const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE); @@ -69,7 +76,11 @@ export function MonthDropdown({ const renderItem = React.useCallback( ({ item }: ListRenderItemInfo) => ( - + - + ), - [width, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate], + [width, panelHeight, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate], ); const getItemLayout = React.useCallback( @@ -90,20 +101,28 @@ export function MonthDropdown({ ); return ( - + { + const w = Math.round(e.nativeEvent.layout.width); + if (w > 0 && w !== measuredWidth) setMeasuredWidth(w); + }} + > ); diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx index 4bbda8b5..5e2a07df 100644 --- a/src/components/calendar/MonthScrollView.tsx +++ b/src/components/calendar/MonthScrollView.tsx @@ -18,6 +18,7 @@ import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; import { dayIndexIn, monthFocusRow, + monthRowGeometry, monthKeyOf, monthMask, sampledRow, @@ -124,8 +125,9 @@ function MonthScrollViewInner({ const height = e.nativeEvent.layout.height; if (height > 0) setListHeight(height); }, []); - const rowHeight = Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(listHeight / VISIBLE_ROWS)); - const viewportHeight = rowHeight * VISIBLE_ROWS; + // A phone in landscape has room for three or four weeks, not six. + const { rowHeight, visibleRows } = monthRowGeometry(listHeight, MONTH_ROW_HEIGHT_FULL_MIN, VISIBLE_ROWS); + const viewportHeight = listHeight; // The month in focus: set by navigation, then by what scrolls under the // sample line. @@ -134,16 +136,28 @@ function MonthScrollViewInner({ const onVisibleRef = React.useRef(onVisibleDateChange); onVisibleRef.current = onVisibleDateChange; const offsetRef = React.useRef(0); - // Row at the top of the viewport; a change of row height (the "show time" - // setting) remounts the list there. - const topRowRef = React.useRef(null); + // Week at the top of the viewport; a change of row height (the "show time" + // setting, a rotation) remounts the list there. Kept as the week, not its + // index, because weeks added above shift the indices. + const topWeekRef = React.useRef(null); + // A (re)mounted list reports offset 0 before its initial scroll lands; that + // report would set the month to the window's first one (a rotation jumped + // back a month). Until the list reaches its start row, or the user drags, + // scroll reports don't move the month. + const settledRef = React.useRef(false); const handleScroll = React.useCallback( (e: NativeSyntheticEvent) => { const offset = e.nativeEvent.contentOffset.y; + if (!settledRef.current) { + const expected = (initialRef.current?.row ?? 0) * rowHeight; + if (Math.abs(offset - expected) > rowHeight / 2) return; + settledRef.current = true; + } offsetRef.current = offset; - topRowRef.current = Math.round(offset / rowHeight); const list = weeksRef.current; + const top = list[Math.max(0, Math.min(list.length - 1, Math.round(offset / rowHeight)))]; + if (top) topWeekRef.current = dayKey(top[0]); const row = sampledRow(offset, viewportHeight, rowHeight, list.length); const days = list[row]; if (!days) return; @@ -158,15 +172,18 @@ function MonthScrollViewInner({ [viewportHeight, rowHeight], ); + const markSettled = React.useCallback(() => { settledRef.current = true; }, []); + // Navigation inside the window scrolls the focused month's first week to // the top. The list mounts there already, so its own nonce needs nothing. const listRef = React.useRef>(null); const handledNonceRef = React.useRef(focus.nonce); const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null); if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) { + settledRef.current = false; initialRef.current = { rowHeight, - row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts), + row: rowOfWeek(weeks, topWeekRef.current) ?? monthFocusRow(window, focus.date, weeks.length, opts), }; } React.useEffect(() => { @@ -263,12 +280,13 @@ function MonthScrollViewInner({ onEndReached={onExtendEnd} onEndReachedThreshold={0.5} onScroll={handleScroll} + onScrollBeginDrag={markSettled} scrollEventThrottle={32} snapToInterval={rowHeight} decelerationRate="fast" showsVerticalScrollIndicator={false} - initialNumToRender={VISIBLE_ROWS + 2} - maxToRenderPerBatch={VISIBLE_ROWS} + initialNumToRender={visibleRows + 2} + maxToRenderPerBatch={visibleRows} windowSize={5} /> )} @@ -282,3 +300,9 @@ export const MonthScrollView = React.memo(MonthScrollViewInner); function weekKey(days: Date[]): string { return dayKey(days[0]); } + +function rowOfWeek(weeks: Date[][], key: string | null): number | null { + if (!key) return null; + const i = weeks.findIndex((days) => dayKey(days[0]) === key); + return i >= 0 ? i : null; +} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index c4b6044f..3d37e6ec 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -38,6 +38,8 @@ export const MONTH_ROW_HEIGHT = 54; export const MONTH_ROW_HEIGHT_CHIPS = 74; // Smallest week row of the full-screen month grid. export const MONTH_ROW_HEIGHT_FULL_MIN = 72; +/** Smallest row of the paged month grid, which can't scroll to hidden weeks. */ +const MONTH_ROW_HEIGHT_FILL_MIN = 36; // Corner radius of event bars (repos/branding/APP.md). const EVENT_RADIUS = 2; @@ -424,8 +426,11 @@ function MonthViewInner({ // Today on a clock in the calendar's time zone. const today = displayNow(); + // Every week of the month has to show (there is no scrolling here), so in a + // short window (phone landscape) rows shrink below the usual minimum and + // their chips collapse into "+N". const fullRowHeight = fill && gridHeight > 0 - ? Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(gridHeight / rows.length)) + ? Math.max(MONTH_ROW_HEIGHT_FILL_MIN, Math.floor(gridHeight / rows.length)) : undefined; return ( diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx index 47881f5c..6c940bf2 100644 --- a/src/components/calendar/ParticipantInput.tsx +++ b/src/components/calendar/ParticipantInput.tsx @@ -107,6 +107,9 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu )} + diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index ad8864a2..b7db70c5 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -42,6 +42,7 @@ import { 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'; type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue'; @@ -230,7 +231,7 @@ export function TasksSheet({ ]; return ( - + diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 19aa4d44..fdb658a4 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -217,6 +217,7 @@ function TimeGridScrollViewInner({ listener: (e: NativeSyntheticEvent) => { const list = daysRef.current; offsetXRef.current = e.nativeEvent.contentOffset.x; + if (e.nativeEvent.contentOffset.x > 0) scrolledSinceMountRef.current = true; const col = Math.max( 0, Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)), @@ -239,7 +240,12 @@ function TimeGridScrollViewInner({ const visibleHandlerRef = React.useRef(onVisibleDateChange); visibleHandlerRef.current = onVisibleDateChange; const lastVisibleKeyRef = React.useRef(null); + // Right after a (re)mount the list reports its first days as visible + // before the initial scroll lands; that would set the title to the window's + // first month. Reports wait until the list has scrolled at all. + const scrolledSinceMountRef = React.useRef(false); const onViewableItemsChanged = React.useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => { + if (!scrolledSinceMountRef.current) return; let first: Date | null = null; for (const token of viewableItems) { const day = token.item as Date; @@ -379,6 +385,21 @@ function TimeGridScrollViewInner({ ), }; } + // The remounted list starts at that day, but the header strip still holds + // the old pixel offset until the list scrolls: move it now, or the header + // shows other days (or none) after a rotation. + React.useLayoutEffect(() => { + const index = initialIndexRef.current?.index ?? 0; + scrolledSinceMountRef.current = false; + offsetXRef.current = index * colWidth; + scrollX.setValue(offsetXRef.current); + const day = days[index]; + if (day) { + headerAnchorRef.current = day; + setHeaderAnchor(day); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listKey]); const listStyle = React.useMemo(() => ({ width: listWidth, height: GRID_HEIGHT }), [listWidth]); return ( diff --git a/src/components/contacts/AddressBookPickerSheet.tsx b/src/components/contacts/AddressBookPickerSheet.tsx index 3cf71758..a7ae075f 100644 --- a/src/components/contacts/AddressBookPickerSheet.tsx +++ b/src/components/contacts/AddressBookPickerSheet.tsx @@ -9,6 +9,7 @@ import { useColors } from '../../theme/colors'; 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'; interface Props { visible: boolean; @@ -70,7 +71,7 @@ export default function AddressBookPickerSheet({ }; return ( - + diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx index e7d04db1..a3415529 100644 --- a/src/components/contacts/ContactImportSheet.tsx +++ b/src/components/contacts/ContactImportSheet.tsx @@ -13,6 +13,7 @@ import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; // expo-document-picker is loaded lazily on first use; its native module is not // linked into every build (matches the FilesScreen upload flow). @@ -160,7 +161,7 @@ export default function ContactImportSheet({ }; return ( - + diff --git a/src/components/contacts/ContactPickerSheet.tsx b/src/components/contacts/ContactPickerSheet.tsx index 62828edd..a71993aa 100644 --- a/src/components/contacts/ContactPickerSheet.tsx +++ b/src/components/contacts/ContactPickerSheet.tsx @@ -16,6 +16,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; interface Props { visible: boolean; @@ -94,7 +95,7 @@ export default function ContactPickerSheet({ }; return ( - + diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 68be926b..f3a2cb5b 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -18,6 +18,7 @@ import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/co import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; type Nav = NativeStackNavigationProp; @@ -161,7 +162,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { ); return ( - + diff --git a/src/components/contacts/TagAssignSheet.tsx b/src/components/contacts/TagAssignSheet.tsx index 27fdc07c..1cb9591c 100644 --- a/src/components/contacts/TagAssignSheet.tsx +++ b/src/components/contacts/TagAssignSheet.tsx @@ -9,6 +9,7 @@ import { useColors } from '../../theme/colors'; 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'; interface Props { visible: boolean; @@ -60,7 +61,7 @@ export default function TagAssignSheet({ }; return ( - + diff --git a/src/components/email/ActionSheet.tsx b/src/components/email/ActionSheet.tsx index 6c83cf95..db68e78f 100644 --- a/src/components/email/ActionSheet.tsx +++ b/src/components/email/ActionSheet.tsx @@ -6,6 +6,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke 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'; export interface ActionSheetItem { key: string; @@ -55,7 +56,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children }, [visible, slideY, overlayOpacity]); return ( - + diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index 0c8255d9..be0d1a80 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -12,6 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import EmailBodyView from '../EmailBodyView'; import type { PreviewKind } from '../../lib/attachment-display'; import type { EmlPreview } from './use-body-override'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; export interface PreviewItem { kind: PreviewKind; @@ -160,7 +161,7 @@ export function AttachmentPreviewModal({ }; return ( - + + diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 7128ac31..524fad68 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -37,6 +37,7 @@ import type { FilterComparator, FilterActionType, } from '../../lib/sieve/types'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment']; const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches']; @@ -226,7 +227,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F }, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]); return ( - + + - = { inbox: Inbox, drafts: FileText, sent: Send, trash: Trash, @@ -297,7 +298,7 @@ export function FolderSettings() { )} - + diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx index 898f3a9a..abc9168d 100644 --- a/src/components/settings/IdentitySettings.tsx +++ b/src/components/settings/IdentitySettings.tsx @@ -27,6 +27,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/recipients'; import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox'; import { sanitizeSignatureHtml } from '../../lib/signature-utils'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; type DraftIdentity = { id: string; @@ -298,7 +299,7 @@ export function IdentitySettings() { ))} - + diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index b8ca34fc..fafa8c1d 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -17,6 +17,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke 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'; export function TemplateSettings() { const c = useColors(); @@ -196,7 +197,7 @@ export function TemplateSettings() { {/* Edit/Create modal */} - + @@ -270,7 +271,7 @@ export function TemplateSettings() { {/* Import modal */} - setImportVisible(false)}> + setImportVisible(false)}> diff --git a/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx b/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx index 7f440ed1..133d416b 100644 --- a/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx +++ b/src/components/settings/device-sync/DeviceSyncCollectionsSheet.tsx @@ -24,6 +24,7 @@ import { import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections'; import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types'; import { useSyncCollections } from './use-sync-collections'; +import { MODAL_ORIENTATIONS } from '../../../lib/modal-orientations'; interface Props { visible: boolean; @@ -108,7 +109,7 @@ export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, autho : t('settings.device_sync.list_error_server', 'The server could not list them.'); return ( - + diff --git a/src/components/settings/settings-section.tsx b/src/components/settings/settings-section.tsx index 22eb3140..f7a378dd 100644 --- a/src/components/settings/settings-section.tsx +++ b/src/components/settings/settings-section.tsx @@ -14,6 +14,7 @@ import { useColors } from '../../theme/colors'; import ToggleSwitchComponent from '../ToggleSwitch'; import { useLocaleStore } from '../../stores/locale-store'; import { useSearchHighlight } from './search-highlight'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; /** * Mirrors webmail settings-section.tsx: @@ -193,7 +194,7 @@ export function Select({ value, onChange, options, style }: SelectProps) { {current?.label ?? ''} - { expect(dayIndexIn([], new Date())).toBe(-1); }); }); + +describe('monthRowGeometry', () => { + it('fits six weeks when they are at least the minimum height', () => { + expect(monthRowGeometry(600, 72)).toEqual({ rowHeight: 100, visibleRows: 6 }); + }); + + it('shows fewer whole rows in a short window instead of running past it', () => { + // A phone in landscape: about 220 dp under the weekday header. + const { rowHeight, visibleRows } = monthRowGeometry(220, 72); + expect(visibleRows).toBe(3); + expect(rowHeight * visibleRows).toBeLessThanOrEqual(220); + expect(rowHeight).toBeGreaterThanOrEqual(72); + }); + + it('keeps one row when even that is taller than the window', () => { + expect(monthRowGeometry(50, 72)).toEqual({ rowHeight: 50, visibleRows: 1 }); + }); +}); diff --git a/src/lib/__tests__/use-keyboard-obstruction.test.ts b/src/lib/__tests__/use-keyboard-obstruction.test.ts new file mode 100644 index 00000000..5cf9a68e --- /dev/null +++ b/src/lib/__tests__/use-keyboard-obstruction.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +type Listener = (e: unknown) => void; +const keyboardListeners = new Map(); +let dimsListener: ((e: { window: { width: number; height: number } }) => void) | null = null; +let keyboardVisible = false; +let screen = { width: 400, height: 900 }; + +vi.mock('react-native', () => ({ + Dimensions: { + get: () => screen, + addEventListener: (_: string, fn: typeof dimsListener) => { + dimsListener = fn; + return { remove() {} }; + }, + }, + Keyboard: { + addListener: (name: string, fn: Listener) => { + keyboardListeners.set(name, fn); + return { remove() {} }; + }, + isVisible: () => keyboardVisible, + dismiss: vi.fn(() => { keyboardVisible = false; }), + }, + Platform: { OS: 'android' }, +})); + +// Just enough React to run the hook once: state goes to `obstruction`. +let obstruction = 0; +vi.mock('react', () => ({ + default: { + useState: (initial: number) => [initial, (v: number) => { obstruction = v; }], + useRef: (v: T) => ({ current: v }), + useEffect: (fn: () => void) => { fn(); }, + }, +})); + +import { Keyboard } from 'react-native'; +import { keyboardObstruction, useKeyboardObstruction } from '../use-keyboard-obstruction'; + +beforeEach(() => { + keyboardListeners.clear(); + dimsListener = null; + keyboardVisible = false; + screen = { width: 400, height: 900 }; + obstruction = 0; + vi.clearAllMocks(); +}); + +describe('keyboardObstruction', () => { + it('measures from the keyboard top to the screen bottom (gesture bar included)', () => { + // Keyboard 300 tall, reported without the 24 dp gesture bar. + expect(keyboardObstruction(900, 576, 300, 24)).toBe(324); + }); + + it('never covers more than the keyboard and the bottom inset', () => { + expect(keyboardObstruction(900, 100, 260, 24)).toBe(284); + }); + + it('is zero when the keyboard top is below the screen', () => { + expect(keyboardObstruction(411, 600, 260, 0)).toBe(0); + }); + + it('falls back to whichever value is known', () => { + expect(keyboardObstruction(900, undefined, 280, 24)).toBe(280); + expect(keyboardObstruction(900, 600, undefined, 24)).toBe(300); + expect(keyboardObstruction(900, undefined, undefined, 24)).toBe(0); + }); +}); + +describe('useKeyboardObstruction', () => { + it('follows the keyboard events', () => { + useKeyboardObstruction(24); + keyboardVisible = true; + keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 576, height: 300 } }); + expect(obstruction).toBe(324); + keyboardListeners.get('keyboardDidHide')!({}); + expect(obstruction).toBe(0); + }); + + it('closes the keyboard and drops the stale overlap when the window rotates', () => { + const onWindowChange = vi.fn(); + useKeyboardObstruction(24, onWindowChange); + keyboardVisible = true; + keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 576, height: 300 } }); + expect(obstruction).toBe(324); + + // Rotated: Android sends no keyboard event, only the new window size. + screen = { width: 900, height: 400 }; + dimsListener!({ window: { width: 900, height: 400 } }); + + expect(obstruction).toBe(0); + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + expect(onWindowChange).toHaveBeenCalledTimes(1); + }); + + it('leaves a closed keyboard alone on a window change', () => { + const onWindowChange = vi.fn(); + useKeyboardObstruction(24, onWindowChange); + dimsListener!({ window: { width: 900, height: 400 } }); + expect(Keyboard.dismiss).not.toHaveBeenCalled(); + expect(onWindowChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/calendar-month-scroll.ts b/src/lib/calendar-month-scroll.ts index b5d5b04b..6f536490 100644 --- a/src/lib/calendar-month-scroll.ts +++ b/src/lib/calendar-month-scroll.ts @@ -34,6 +34,22 @@ export function monthFocusRow( return Math.max(0, Math.min(rowCount - 1, row)); } +/** + * Row height and number of whole rows for a month grid of `height`. Six + * weeks fill the space when they fit at `minRow` or taller; a short window + * (a phone in landscape) shows fewer, taller-than-minimum rows instead of + * six rows that run off the bottom. + */ +export function monthRowGeometry( + height: number, + minRow: number, + maxRows = 6, +): { rowHeight: number; visibleRows: number } { + if (height <= 0 || minRow <= 0) return { rowHeight: minRow, visibleRows: maxRows }; + const visibleRows = Math.max(1, Math.min(maxRows, Math.floor(height / minRow))); + return { rowHeight: Math.floor(height / visibleRows), visibleRows }; +} + /** Row under the sample line for a scroll offset. */ export function sampledRow( offset: number, diff --git a/src/lib/modal-orientations.ts b/src/lib/modal-orientations.ts new file mode 100644 index 00000000..aa54e5aa --- /dev/null +++ b/src/lib/modal-orientations.ts @@ -0,0 +1,14 @@ +import type { ModalProps } from 'react-native'; + +/** + * Every RN `` passes this. Without it iOS presents a modal in portrait + * only, which rotates an iPad (or a landscape iPhone) back to portrait while + * the modal is open. + */ +export const MODAL_ORIENTATIONS: NonNullable = [ + 'portrait', + 'portrait-upside-down', + 'landscape', + 'landscape-left', + 'landscape-right', +]; diff --git a/src/lib/use-keyboard-obstruction.ts b/src/lib/use-keyboard-obstruction.ts new file mode 100644 index 00000000..df351537 --- /dev/null +++ b/src/lib/use-keyboard-obstruction.ts @@ -0,0 +1,77 @@ +import React from 'react'; +import { Dimensions, Keyboard, Platform, type KeyboardEvent } from 'react-native'; + +/** + * How much of the screen bottom the keyboard covers, given the keyboard's top + * edge (`screenY`) and its reported height. On Android edge-to-edge the + * reported height leaves out the gesture bar, so the gap from the keyboard's + * top to the screen bottom is the better measure; it is capped by the + * keyboard height plus the bottom inset so a stale screen size (a rotation + * that arrives before the keyboard's own event) can't produce an overlap + * taller than the keyboard. + */ +export function keyboardObstruction( + screenHeight: number, + screenY: number | undefined, + height: number | undefined, + bottomInset: number, +): number { + const fromTop = typeof screenY === 'number' ? screenHeight - screenY : NaN; + const reported = typeof height === 'number' && height > 0 ? height : NaN; + if (Number.isNaN(fromTop) && Number.isNaN(reported)) return 0; + if (Number.isNaN(fromTop)) return Math.max(0, reported); + if (Number.isNaN(reported)) return Math.max(0, fromTop); + return Math.max(0, Math.min(fromTop, reported + bottomInset)); +} + +/** + * Tracks the keyboard's overlap with the bottom of the screen. + * + * Android sends no keyboard event when the window rotates with the keyboard + * open, so the last frame (from the other orientation) is all there is: a + * portrait keyboard kept as the overlap blanked the whole landscape composer. + * A window change with the keyboard open therefore closes the keyboard; the + * text stays, and the next tap opens it with the right size. `onWindowChange` + * lets a screen drop focus that `Keyboard.dismiss` can't reach (a WebView). + */ +export function useKeyboardObstruction(bottomInset: number, onWindowChange?: () => void): number { + const [obstruction, setObstruction] = React.useState(0); + const insetRef = React.useRef(bottomInset); + insetRef.current = bottomInset; + const onWindowChangeRef = React.useRef(onWindowChange); + onWindowChangeRef.current = onWindowChange; + React.useEffect(() => { + const apply = (e?: Pick | null) => { + if (!e?.endCoordinates) { + setObstruction(0); + return; + } + const { screenY, height } = e.endCoordinates; + setObstruction(keyboardObstruction(Dimensions.get('screen').height, screenY, height, insetRef.current)); + }; + const subs = + Platform.OS === 'ios' + ? [ + Keyboard.addListener('keyboardWillChangeFrame', apply), + Keyboard.addListener('keyboardWillHide', () => setObstruction(0)), + ] + : [ + Keyboard.addListener('keyboardDidShow', apply), + Keyboard.addListener('keyboardDidHide', () => setObstruction(0)), + ]; + let lastWindow = Dimensions.get('window'); + const dims = Dimensions.addEventListener('change', ({ window }) => { + if (window.width === lastWindow.width && window.height === lastWindow.height) return; + lastWindow = window; + setObstruction(0); + if (!Keyboard.isVisible()) return; + Keyboard.dismiss(); + onWindowChangeRef.current?.(); + }); + return () => { + for (const s of subs) s.remove(); + dims.remove(); + }; + }, []); + return obstruction; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 3ec09ce2..5d8b0a8c 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -31,6 +31,8 @@ import { useEmailStore } from '../stores/email-store'; import { ownMailboxes } from '../lib/mailbox-tree'; import { getEmailInitials } from '../lib/avatar-utils'; import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store'; +import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; +import { useLayout } from '../lib/use-layout'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; import { useHasContacts } from '../lib/capabilities'; @@ -76,6 +78,7 @@ import { import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; import type { EmailAddress, Identity } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; type Props = NativeStackScreenProps; @@ -365,7 +368,7 @@ function OptionsSheet({ const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( - + {}}> {!!title && {title}} @@ -395,34 +398,9 @@ export default function ComposeScreen({ route, navigation }: Props) { const timeFormat = useSettingsStore((s) => s.timeFormat); const insets = useSafeAreaInsets(); // Track the visible keyboard obstruction so the format bar stays above it. - // On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is - // measured from the top of the gesture-nav bar rather than from the true - // screen bottom, so we derive obstruction height from `Dimensions.screen` - // instead of trusting `endCoordinates.height` directly. The same trick - // works on iOS for the QuickType / autofill / dictation strip. - const [kbObstruction, setKbObstruction] = React.useState(0); - React.useEffect(() => { - const recompute = (endY: number) => { - const screenH = Dimensions.get('screen').height; - setKbObstruction(Math.max(0, screenH - endY)); - }; - const subs = - Platform.OS === 'ios' - ? [ - Keyboard.addListener('keyboardWillChangeFrame', (e) => { - recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER); - }), - ] - : [ - Keyboard.addListener('keyboardDidShow', (e) => { - recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER); - }), - Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)), - ]; - return () => { - for (const s of subs) s.remove(); - }; - }, []); + const kbObstruction = useKeyboardObstruction(insets.bottom, () => editorRef.current?.blur()); + const { shortHeight } = useLayout(); + const typingInShortWindow = shortHeight && kbObstruction > 0; // When the keyboard is up it covers the bottom safe area, so we don't // need to add it on top — pad by whichever is larger. const bottomPad = Math.max(kbObstruction, insets.bottom); @@ -2226,6 +2204,9 @@ export default function ComposeScreen({ route, navigation }: Props) { /> ))} - + + {t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')} - {!!draftStatusText && ( + {!!draftStatusText && !typingInShortWindow && ( {draftStatusText} )} @@ -2386,6 +2367,7 @@ export default function ComposeScreen({ route, navigation }: Props) { {t('email_composer.subject', 'Subject')} - - setTemplateSheetOpen(true)} - icon={} /> - setRequestReadReceipt((v) => !v)} - icon={} /> - { void togglePlainTextMode(); }} - label={plainTextMode - ? t('email_composer.format_rich_text', 'Switch to rich text (HTML)') - : t('email_composer.format_plain_text', 'Switch to plain text')} - icon={} /> + {/* A phone in landscape with the keyboard up has room for a few lines + of text only; the format bar comes back with the keyboard down. */} + {!typingInShortWindow && ( + + setTemplateSheetOpen(true)} + icon={} /> + setRequestReadReceipt((v) => !v)} + icon={} /> + { void togglePlainTextMode(); }} + label={plainTextMode + ? t('email_composer.format_rich_text', 'Switch to rich text (HTML)') + : t('email_composer.format_plain_text', 'Switch to plain text')} + icon={} /> - {!plainTextMode && ( - <> - + {!plainTextMode && ( + <> + - editorRef.current?.exec('bold')} - icon={} /> - editorRef.current?.exec('italic')} - icon={} /> - editorRef.current?.exec('underline')} - icon={} /> - editorRef.current?.exec('strikeThrough')} - icon={} /> - setColorTarget('text')} - label={t('email_composer.toolbar.text_color', 'Text color')} - icon={} /> - setColorTarget('background')} - label={t('email_composer.toolbar.background_color', 'Background color')} - icon={} /> + editorRef.current?.exec('bold')} + icon={} /> + editorRef.current?.exec('italic')} + icon={} /> + editorRef.current?.exec('underline')} + icon={} /> + editorRef.current?.exec('strikeThrough')} + icon={} /> + setColorTarget('text')} + label={t('email_composer.toolbar.text_color', 'Text color')} + icon={} /> + setColorTarget('background')} + label={t('email_composer.toolbar.background_color', 'Background color')} + icon={} /> - + - editorRef.current?.exec('formatBlock:H1')} - icon={} /> - editorRef.current?.exec('formatBlock:H2')} - icon={} /> + editorRef.current?.exec('formatBlock:H1')} + icon={} /> + editorRef.current?.exec('formatBlock:H2')} + icon={} /> - + - editorRef.current?.exec('insertUnorderedList')} - icon={} /> - editorRef.current?.exec('insertOrderedList')} - icon={} /> - editorRef.current?.exec('formatBlock:BLOCKQUOTE')} - icon={} /> - setTableMenuOpen(true)} - icon={} /> + editorRef.current?.exec('insertUnorderedList')} + icon={} /> + editorRef.current?.exec('insertOrderedList')} + icon={} /> + editorRef.current?.exec('formatBlock:BLOCKQUOTE')} + icon={} /> + setTableMenuOpen(true)} + icon={
} /> - + - editorRef.current?.exec('justifyLeft')} - icon={} /> - editorRef.current?.exec('justifyCenter')} - icon={} /> - editorRef.current?.exec('justifyRight')} - icon={} /> + editorRef.current?.exec('justifyLeft')} + icon={} /> + editorRef.current?.exec('justifyCenter')} + icon={} /> + editorRef.current?.exec('justifyRight')} + icon={} /> - + - - : } /> - { void insertInlineImage(); }} - icon={} /> - editorRef.current?.exec('removeFormat')} - icon={} /> + + : } /> + { void insertInlineImage(); }} + icon={} /> + editorRef.current?.exec('removeFormat')} + icon={} /> - + - editorRef.current?.exec('undo')} - icon={} /> - editorRef.current?.exec('redo')} - icon={} /> - - )} - + editorRef.current?.exec('undo')} + icon={} /> + editorRef.current?.exec('redo')} + icon={} /> + + )} + + )} - {/* Text / background colour palette */} - setColorTarget(null)}> + setColorTarget(null)}> setColorTarget(null)}> {}}> @@ -2631,7 +2618,7 @@ export default function ComposeScreen({ route, navigation }: Props) { /> {/* Schedule send sheet */} - {customStage !== null && Platform.OS === 'ios' && ( - setCustomStage(null)}> + setCustomStage(null)}> setCustomStage(null)}> {}}> {/* From options: sub-address tag / From override */} - setFromOptionsOpen(false)}> + setFromOptionsOpen(false)}> {t('email_composer.from', 'From')} @@ -2882,6 +2869,7 @@ function makeStyles(c: ThemePalette) { borderBottomColor: c.border, gap: spacing.sm, }, + headerCompact: { paddingVertical: 0 }, headerBtn: { width: componentSizes.buttonLg, height: componentSizes.buttonLg, diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx index b1fcdfca..3c835af9 100644 --- a/src/screens/ContactDetailScreen.tsx +++ b/src/screens/ContactDetailScreen.tsx @@ -32,6 +32,7 @@ import AddressBookPickerSheet from '../components/contacts/AddressBookPickerShee 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'; type Nav = NativeStackNavigationProp; type Route = RouteProp; @@ -802,7 +803,7 @@ function MoreActionsSheet({ }, [visible, slideY, overlayOpacity]); return ( - + diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index 1d3cec1b..36ce5532 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -31,6 +31,7 @@ import ContactPickerSheet from '../components/contacts/ContactPickerSheet'; import { spacing, radius, typography, componentSizes, 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'; type Nav = NativeStackNavigationProp; type Route = RouteProp; @@ -1802,7 +1803,7 @@ export default function ContactFormScreen() { }; if (Platform.OS === 'ios') { return ( - setDatePickerIndex(null)}> + setDatePickerIndex(null)}> setDatePickerIndex(null)} /> diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 9a399042..91b9811c 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -51,6 +51,7 @@ import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; import { usePendingMailSearch } from '../navigation/pending-mail-search'; import type { Attachment, Email } from '../api/types'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; function getSenderName(email: Email, unknownLabel: string): string { return email.from?.[0]?.name || email.from?.[0]?.email || unknownLabel; @@ -1132,6 +1133,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setDrawerOpen(false)} /> - setDatePickerField(null)}> + setDatePickerField(null)}> setDatePickerField(null)} /> diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index dbeec789..ad8bafcc 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -4,7 +4,7 @@ import { Modal, useWindowDimensions, Animated, Easing, Alert, FlatList, Keyboard, Dimensions, Platform, } from 'react-native'; -import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; +import type { LayoutChangeEvent, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { @@ -35,6 +35,7 @@ import { import { shareEmailEml } from '../lib/email-export'; import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; import { useSheetDrag } from '../lib/use-sheet-drag'; +import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; import { useLocaleStore } from '../stores/locale-store'; import { findArchiveMailbox, findTrashMailbox, mailboxAccountId, mailboxesOfAccount, mailboxOfEmail, @@ -46,6 +47,7 @@ import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer- 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'; type Props = NativeStackScreenProps; @@ -87,6 +89,10 @@ function EmailViewer({ route, navigation }: Props) { const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId); const insets = useSafeAreaInsets(); const { width: windowWidth } = useWindowDimensions(); + // Pages are as wide as the pager itself, which is the window on a phone + // but only the detail pane in a split view. Measured, so a rotation or a + // pane resize re-sizes them. + const [pageWidth, setPageWidth] = React.useState(windowWidth); const markRead = useEmailStore((s) => s.markRead); const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails); const deleteEmail = useEmailStore((s) => s.deleteEmail); @@ -262,22 +268,34 @@ function EmailViewer({ route, navigation }: Props) { const goToIndex = React.useCallback((index: number) => { if (index < 0 || index >= emails.length) return; releaseNeighbours(); - listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: true }); + listRef.current?.scrollToOffset({ offset: index * pageWidth, animated: true }); const target = emails[index]; if (target) setActiveEmailId(target.id); - }, [emails, windowWidth, releaseNeighbours]); + }, [emails, pageWidth, releaseNeighbours]); const onMomentumEnd = React.useCallback((e: NativeSyntheticEvent) => { - const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth); + const index = Math.round(e.nativeEvent.contentOffset.x / pageWidth); const target = emails[index]; if (target && target.id !== activeEmailId) setActiveEmailId(target.id); - }, [emails, windowWidth, activeEmailId]); + }, [emails, pageWidth, activeEmailId]); - React.useLayoutEffect(() => { + // A width change (rotation, split-screen, pane resize) leaves the native + // scroll offset where it was in pixels, so the pager would show half of a + // neighbour. Snap back to the open message once the pages have their new + // width, which is when the content size changes; the layout effect covers + // a width change that doesn't change the content size. + const snapToActive = React.useCallback(() => { const index = emails.findIndex((e) => e.id === activeEmailId); - if (index >= 0) listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: false }); + if (index >= 0) listRef.current?.scrollToOffset({ offset: index * pageWidth, animated: false }); + }, [emails, activeEmailId, pageWidth]); + React.useLayoutEffect(() => { + snapToActive(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [windowWidth]); + }, [pageWidth]); + const onPagerLayout = React.useCallback((e: LayoutChangeEvent) => { + const width = Math.round(e.nativeEvent.layout.width); + if (width > 0) setPageWidth((prev) => (prev === width ? prev : width)); + }, []); const pagerExtraData = React.useMemo( () => [cacheVersion, neighboursReady], @@ -642,39 +660,8 @@ function EmailViewer({ route, navigation }: Props) { const bottomBarHeight = 60 + Math.max(insets.bottom, 4); // Track the visible keyboard obstruction so quick reply stays visible and - // the bottom bar hides while typing. On Android edge-to-edge, the IME inset - // is measured from the top of the gesture bar rather than screen bottom, - // so we derive obstruction height from Dimensions.screen. - const [kbObstruction, setKbObstruction] = React.useState(0); - React.useEffect(() => { - const recompute = (endY?: number, height?: number) => { - const screenH = Dimensions.get('screen').height; - if (typeof endY === 'number' && screenH - endY > 0) { - setKbObstruction(screenH - endY); - } else if (typeof height === 'number' && height > 0) { - setKbObstruction(height); - } else { - setKbObstruction(0); - } - }; - const subs = - Platform.OS === 'ios' - ? [ - Keyboard.addListener('keyboardWillChangeFrame', (e) => { - recompute(e.endCoordinates?.screenY, e.endCoordinates?.height); - }), - Keyboard.addListener('keyboardWillHide', () => setKbObstruction(0)), - ] - : [ - Keyboard.addListener('keyboardDidShow', (e) => { - recompute(e.endCoordinates?.screenY, e.endCoordinates?.height); - }), - Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)), - ]; - return () => { - for (const s of subs) s.remove(); - }; - }, []); + // the bottom bar hides while typing. + const kbObstruction = useKeyboardObstruction(insets.bottom); // Drop optional toolbar buttons on narrow screens. const showMarkUnread = windowWidth >= 340; @@ -763,6 +750,8 @@ function EmailViewer({ route, navigation }: Props) { item.id} @@ -770,7 +759,7 @@ function EmailViewer({ route, navigation }: Props) { pagingEnabled showsHorizontalScrollIndicator={false} initialScrollIndex={initialIndexRef.current} - getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })} + getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })} windowSize={3} initialNumToRender={1} maxToRenderPerBatch={2} @@ -779,7 +768,7 @@ function EmailViewer({ route, navigation }: Props) { onScrollBeginDrag={releaseNeighbours} onMomentumScrollEnd={onMomentumEnd} renderItem={({ item, index }) => ( - + + @@ -1437,7 +1426,7 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: }, [visible, slideY, overlayOpacity]); return ( - + diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 4d83da7d..57bdabb9 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -65,6 +65,7 @@ import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; 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'; // A file row carries the display name alongside the rest of the node. interface FileRow extends FileNode { @@ -1148,7 +1149,7 @@ function PromptModal(props: PromptModalProps) { const c = useColors(); const styles = React.useMemo(() => makePromptStyles(c), [c]); return ( - + @@ -1339,7 +1340,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: if (!target) return null; const currentParent = target.parentId ?? null; return ( - + @@ -1394,7 +1395,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang if (!visible) return null; const keys: FilesSortKey[] = ['name', 'size', 'modified']; return ( - + diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index c09b2169..861bb165 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -25,6 +25,7 @@ import { draftContextFromEmail } from '../lib/draft-context'; import { formatQuoteDate } from '../lib/quote-header'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; type Props = NativeStackScreenProps; @@ -322,7 +323,7 @@ export default function ScheduledScreen({ navigation }: Props) { )} {/* Per-message actions */} - setActionsFor(null)}> + setActionsFor(null)}> setActionsFor(null)}> {}}> @@ -345,7 +346,7 @@ export default function ScheduledScreen({ navigation }: Props) { {/* Reschedule presets */} - setRescheduleFor(null)}> + setRescheduleFor(null)}> setRescheduleFor(null)}> {}}> {t('email_list.reschedule_send', 'Reschedule')} @@ -375,7 +376,7 @@ export default function ScheduledScreen({ navigation }: Props) { {customStage !== null && Platform.OS === 'ios' && ( - setCustomStage(null)}> + setCustomStage(null)}> setCustomStage(null)}> {}}>