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)}>
{}}>