From bfa02f1177db8397ac7bce9013a873423315194f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:45:23 +0200 Subject: [PATCH] feat: let the app rotate and work in landscape The app was locked to portrait in app.config.js and the hand-kept AndroidManifest.xml. Android 16 ignores that lock on screens 600 dp and wider, so tablets and foldables rotated into layouts that were never tested. This removes the lock in both places, keeps React state across rotation and split-screen resizes (`smallestScreenSize` joins `configChanges`), and fixes what broke in landscape: - the thread pager sizes its pages from its own width and snaps back to the open message after a rotation instead of showing half a neighbour - compose and the thread view share one keyboard tracker; a rotation with the keyboard open closes it, since Android sends no new keyboard frame and the old portrait one blanked the whole landscape composer - in a short window with the keyboard up the composer hides the format bar and tightens its header so a few lines of text stay visible - inputs with suggestions or context around them opt out of Android's full-screen landscape keyboard - month views fit whole rows to the real height, keep the month across a remount, and the month dropdown re-measures and caps its height - the week grid's header follows the remounted list after a rotation - the event sheet's actions stay on screen in landscape - every RN Modal allows all orientations, or iOS forces portrait --- android/app/src/main/AndroidManifest.xml | 2 +- app.config.js | 2 +- src/components/Dialog.tsx | 3 +- src/components/IdentitySheet.tsx | 3 +- src/components/MoveSheet.tsx | 3 +- src/components/PasteSignInLinkModal.tsx | 3 +- src/components/QrScanModal.tsx | 3 +- src/components/RichTextEditor.tsx | 5 + src/components/SidebarDrawer.tsx | 7 +- src/components/TagSheet.tsx | 3 +- src/components/TemplateSheet.tsx | 3 +- src/components/calendar/CalendarEditSheet.tsx | 3 +- .../calendar/CalendarShareSheet.tsx | 3 +- .../calendar/CalendarSidebarDrawer.tsx | 3 +- src/components/calendar/EventDetailSheet.tsx | 9 +- src/components/calendar/EventModal.tsx | 3 +- src/components/calendar/ICalImportSheet.tsx | 3 +- .../calendar/ICalSubscriptionSheet.tsx | 3 +- src/components/calendar/MonthDropdown.tsx | 33 ++- src/components/calendar/MonthScrollView.tsx | 42 +++- src/components/calendar/MonthView.tsx | 7 +- src/components/calendar/ParticipantInput.tsx | 3 + .../calendar/RecurrenceScopeDialog.tsx | 3 +- src/components/calendar/TasksSheet.tsx | 3 +- .../calendar/TimeGridScrollView.tsx | 21 ++ .../contacts/AddressBookPickerSheet.tsx | 3 +- .../contacts/ContactImportSheet.tsx | 3 +- .../contacts/ContactPickerSheet.tsx | 3 +- .../contacts/ContactsSidebarDrawer.tsx | 3 +- src/components/contacts/TagAssignSheet.tsx | 3 +- src/components/email/ActionSheet.tsx | 3 +- .../email/AttachmentPreviewModal.tsx | 3 +- src/components/email/QuickReplyBox.tsx | 3 + src/components/files/ShareSheet.tsx | 3 +- src/components/filters/FilterRuleModal.tsx | 3 +- src/components/filters/SieveEditorSheet.tsx | 3 +- .../settings/ContentSendersSettings.tsx | 3 +- src/components/settings/FolderSettings.tsx | 3 +- src/components/settings/IdentitySettings.tsx | 3 +- src/components/settings/TemplateSettings.tsx | 5 +- .../DeviceSyncCollectionsSheet.tsx | 3 +- src/components/settings/settings-section.tsx | 3 +- .../__tests__/calendar-month-scroll.test.ts | 19 ++ .../use-keyboard-obstruction.test.ts | 104 +++++++++ src/lib/calendar-month-scroll.ts | 16 ++ src/lib/modal-orientations.ts | 14 ++ src/lib/use-keyboard-obstruction.ts | 77 +++++++ src/screens/ComposeScreen.tsx | 206 +++++++++--------- src/screens/ContactDetailScreen.tsx | 3 +- src/screens/ContactFormScreen.tsx | 3 +- src/screens/EmailListScreen.tsx | 8 +- src/screens/EmailThreadScreen.tsx | 79 +++---- src/screens/FilesScreen.tsx | 9 +- src/screens/ScheduledScreen.tsx | 7 +- src/screens/UnifiedInboxScreen.tsx | 3 + 55 files changed, 555 insertions(+), 222 deletions(-) create mode 100644 src/lib/__tests__/use-keyboard-obstruction.test.ts create mode 100644 src/lib/modal-orientations.ts create mode 100644 src/lib/use-keyboard-obstruction.ts 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)}> {}}>