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
This commit is contained in:
Linus Rath
2026-10-15 01:45:23 +02:00
parent 4b29cdfd3c
commit bfa02f1177
55 changed files with 555 additions and 222 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.content}>
+2 -1
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
+2 -1
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
+2 -1
View File
@@ -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 (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
+2 -1
View File
@@ -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 (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
+5
View File
@@ -65,6 +65,8 @@ export interface RichTextEditorHandle {
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
/** Drop the caret, which closes the keyboard. */
blur(): void;
}
interface Props {
@@ -176,6 +178,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
blur: () => {
call(`document.activeElement && document.activeElement.blur()`);
},
}), [call]);
const onMessage = (event: WebViewMessageEvent) => {
+4 -3
View File
@@ -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 (
<Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
@@ -270,7 +271,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
onSubmit(name);
};
return (
<Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.promptCard}>
<Text style={styles.sheetTitle}>{title}</Text>
@@ -746,7 +747,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
const tagViewActive = !!filters.keyword;
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
+2 -1
View File
@@ -11,6 +11,7 @@ import { keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface TagSheetProps {
visible: boolean;
@@ -65,7 +66,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
}, [keywords, selectedEmails]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
+2 -1
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
@@ -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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
@@ -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 (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
@@ -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 (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
+6 -3
View File
@@ -60,6 +60,7 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
import type { TranslateFn } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -243,7 +244,7 @@ export function EventDetailSheet({
};
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -613,7 +614,9 @@ function makeStyles(c: ThemePalette) {
borderTopWidth: 1,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm },
// The body shrinks to what is left under the sheet's max height, so the
// action row stays on screen in a short (landscape) window.
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
handleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
@@ -640,7 +643,7 @@ function makeStyles(c: ThemePalette) {
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
closeBtn: { padding: 4 },
body: { maxHeight: 400 },
body: { flexShrink: 1 },
bodyContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
+2 -1
View File
@@ -67,6 +67,7 @@ import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -410,7 +411,7 @@ export function EventModal({
);
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
+2 -1
View File
@@ -17,6 +17,7 @@ import { ImportRefusedError, type ImportResult } from '../../stores/calendar-sto
import { useLocaleStore } from '../../stores/locale-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import type { Locale } from 'date-fns';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -184,7 +185,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
+26 -7
View File
@@ -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<Date>) => (
<View style={{ width, height: PANEL_HEIGHT }}>
<ScrollView
style={{ width, height: panelHeight }}
scrollEnabled={panelHeight < PANEL_HEIGHT}
showsVerticalScrollIndicator={false}
>
<MonthView
currentDate={item}
selectedDate={selectedDate}
@@ -79,9 +90,9 @@ export function MonthDropdown({
weekStartsOn={weekStartsOn}
onSelectDate={onSelectDate}
/>
</View>
</ScrollView>
),
[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 (
<View style={styles.panel}>
<View
style={styles.panel}
onLayout={(e) => {
const w = Math.round(e.nativeEvent.layout.width);
if (w > 0 && w !== measuredWidth) setMeasuredWidth(w);
}}
>
<FlatList
// A new width remounts the pager on the month it was showing.
key={width}
horizontal
pagingEnabled
data={months}
keyExtractor={monthKey}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialScrollIndex={MONTHS_EACH_SIDE}
initialScrollIndex={lastIndexRef.current}
onMomentumScrollEnd={handleMomentumEnd}
showsHorizontalScrollIndicator={false}
initialNumToRender={1}
windowSize={3}
style={{ height: PANEL_HEIGHT }}
style={{ height: panelHeight }}
/>
</View>
);
+33 -9
View File
@@ -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<number | null>(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<string | null>(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<NativeScrollEvent>) => {
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<FlatList<Date[]>>(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;
}
+6 -1
View File
@@ -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 (
@@ -107,6 +107,9 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
)}
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={draft}
onChangeText={setDraft}
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
@@ -5,6 +5,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { Button } from '..';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
@@ -42,7 +43,7 @@ export function RecurrenceScopeDialog({
}, [visible]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.header}>
+2 -1
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
@@ -217,6 +217,7 @@ function TimeGridScrollViewInner({
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
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<string | null>(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 (
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
@@ -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 (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
@@ -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<RootStackParamList>;
@@ -161,7 +162,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
);
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
+2 -1
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
+2 -1
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
@@ -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 (
<Modal visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
+3
View File
@@ -147,6 +147,9 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
return (
<View style={styles.box}>
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={text}
onChangeText={setText}
onFocus={onFocus}
+2 -1
View File
@@ -18,6 +18,7 @@ import type { FileNode, FileNodeRights, 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 = 'read' | 'readWrite' | 'manager';
@@ -155,7 +156,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
+2 -1
View File
@@ -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 (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
+2 -1
View File
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface SieveEditorSheetProps {
visible: boolean;
@@ -62,7 +63,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
}, [script, showSaveWarning, onSave]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable
@@ -12,6 +12,7 @@ import { useContactsStore } from '../../stores/contacts-store';
import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
interface TrustedRow {
email: string;
@@ -189,7 +190,7 @@ export function ContentSendersSettings() {
)}
</SettingsSection>
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={modalOpen}
transparent
animationType="fade"
+2 -1
View File
@@ -25,6 +25,7 @@ import { useLocaleStore } from '../../stores/locale-store';
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import type { Mailbox } from '../../api/types';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const ROLE_ICON: Record<string, any> = {
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
@@ -297,7 +298,7 @@ export function FolderSettings() {
)}
</SettingsSection>
<Modal visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
+2 -1
View File
@@ -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() {
</Pressable>
))}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
+3 -2
View File
@@ -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() {
</SettingsSection>
{/* Edit/Create modal */}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
@@ -270,7 +271,7 @@ export function TemplateSettings() {
</Modal>
{/* Import modal */}
<Modal visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={close} />
</Animated.View>
+2 -1
View File
@@ -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) {
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
<ChevronDown size={14} color={c.mutedForeground} />
</Pressable>
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={open}
transparent
animationType="fade"
@@ -8,6 +8,7 @@ import {
import {
dayIndexIn,
monthFocusRow,
monthRowGeometry,
monthKeyOf,
monthMask,
sampledRow,
@@ -115,3 +116,21 @@ describe('dayIndexIn', () => {
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 });
});
});
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
type Listener = (e: unknown) => void;
const keyboardListeners = new Map<string, Listener>();
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: <T,>(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();
});
});
+16
View File
@@ -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,
+14
View File
@@ -0,0 +1,14 @@
import type { ModalProps } from 'react-native';
/**
* Every RN `<Modal>` 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<ModalProps['supportedOrientations']> = [
'portrait',
'portrait-upside-down',
'landscape',
'landscape-left',
'landscape-right',
];
+77
View File
@@ -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<KeyboardEvent, 'endCoordinates'> | 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;
}
+97 -109
View File
@@ -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<RootStackParamList, 'Compose'>;
@@ -365,7 +368,7 @@ function OptionsSheet({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.modalBackdrop} onPress={onClose}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
@@ -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) {
/>
))}
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]}
placeholder={placeholder}
placeholderTextColor={c.textMuted}
@@ -2283,8 +2264,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
<View style={[styles.header, typingInShortWindow && styles.headerCompact]}>
<Pressable
onPress={onClose}
style={styles.headerBtn}
@@ -2298,7 +2279,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<Text style={styles.headerTitle} numberOfLines={1}>
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
</Text>
{!!draftStatusText && (
{!!draftStatusText && !typingInShortWindow && (
<Text style={styles.headerSubtitle} numberOfLines={1}>{draftStatusText}</Text>
)}
</View>
@@ -2386,6 +2367,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
<TextInput
disableFullscreenUI
style={styles.subjectInput}
placeholder={t('email_composer.subject_placeholder', 'Subject')}
placeholderTextColor={c.textMuted}
@@ -2409,6 +2391,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{plainTextMode ? (
<TextInput
disableFullscreenUI
style={styles.plainEditor}
multiline
value={plainBody}
@@ -2431,92 +2414,96 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
</ScrollView>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.formatBar}
contentContainerStyle={styles.formatActions}
keyboardShouldPersistTaps="always"
>
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
<ToolbarButton active={plainTextMode} onPress={() => { 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={<Type size={18} color={plainTextMode ? c.primary : c.textSecondary} />} />
{/* 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 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.formatBar}
contentContainerStyle={styles.formatActions}
keyboardShouldPersistTaps="always"
>
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
<ToolbarButton active={plainTextMode} onPress={() => { 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={<Type size={18} color={plainTextMode ? c.primary : c.textSecondary} />} />
{!plainTextMode && (
<>
<View style={styles.formatSep} />
{!plainTextMode && (
<>
<View style={styles.formatSep} />
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('text')}
label={t('email_composer.toolbar.text_color', 'Text color')}
icon={<Palette size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('background')}
label={t('email_composer.toolbar.background_color', 'Background color')}
icon={<Highlighter size={18} color={c.textSecondary} />} />
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('text')}
label={t('email_composer.toolbar.text_color', 'Text color')}
icon={<Palette size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('background')}
label={t('email_composer.toolbar.background_color', 'Background color')}
icon={<Highlighter size={18} color={c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setTableMenuOpen(true)}
icon={<Table size={18} color={c.textSecondary} />} />
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setTableMenuOpen(true)}
icon={<Table size={18} color={c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton active={selState.link} onPress={onLinkPress}
icon={selState.link
? <Link2Off size={18} color={c.primary} />
: <Link2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => { void insertInlineImage(); }}
icon={<ImageIcon size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
<ToolbarButton active={selState.link} onPress={onLinkPress}
icon={selState.link
? <Link2Off size={18} color={c.primary} />
: <Link2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => { void insertInlineImage(); }}
icon={<ImageIcon size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
<View style={styles.formatSep} />
<View style={styles.formatSep} />
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
icon={<Undo2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
icon={<Redo2 size={18} color={c.textSecondary} />} />
</>
)}
</ScrollView>
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
icon={<Undo2 size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
icon={<Redo2 size={18} color={c.textSecondary} />} />
</>
)}
</ScrollView>
)}
</View>
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={linkPromptVisible}
transparent
animationType="fade"
@@ -2567,7 +2554,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Modal>
{/* Text / background colour palette */}
<Modal visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
@@ -2631,7 +2618,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
/>
{/* Schedule send sheet */}
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={scheduleSheetOpen}
transparent
animationType="fade"
@@ -2670,7 +2657,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Modal>
{customStage !== null && Platform.OS === 'ios' && (
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<DateTimePicker
@@ -2711,7 +2698,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
{/* Template placeholders */}
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={!!placeholderPrompt}
transparent
animationType="fade"
@@ -2766,7 +2753,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Modal>
{/* From options: sub-address tag / From override */}
<Modal visible={fromOptionsOpen} transparent animationType="fade" onRequestClose={() => setFromOptionsOpen(false)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={fromOptionsOpen} transparent animationType="fade" onRequestClose={() => setFromOptionsOpen(false)}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>{t('email_composer.from', 'From')}</Text>
@@ -2882,6 +2869,7 @@ function makeStyles(c: ThemePalette) {
borderBottomColor: c.border,
gap: spacing.sm,
},
headerCompact: { paddingVertical: 0 },
headerBtn: {
width: componentSizes.buttonLg,
height: componentSizes.buttonLg,
+2 -1
View File
@@ -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<RootStackParamList, 'ContactDetail'>;
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
@@ -802,7 +803,7 @@ function MoreActionsSheet({
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
+2 -1
View File
@@ -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<RootStackParamList, 'ContactForm'>;
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
@@ -1802,7 +1803,7 @@ export default function ContactFormScreen() {
};
if (Platform.OS === 'ios') {
return (
<Modal transparent animationType="fade" onRequestClose={() => setDatePickerIndex(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setDatePickerIndex(null)}>
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerIndex(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
+6 -2
View File
@@ -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
<View style={styles.searchInputArea}>
<Search size={16} color={c.textMuted} />
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
ref={searchInputRef}
style={styles.searchInput}
placeholder={t('email_list.search_placeholder', 'Search mail...')}
@@ -1466,7 +1470,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
<ListAttachmentOpener ref={attachmentOpenerRef} />
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={filterMenuOpen}
transparent
animationType="fade"
@@ -1672,7 +1676,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
};
if (Platform.OS === 'ios') {
return (
<Modal transparent animationType="fade" onRequestClose={() => setDatePickerField(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setDatePickerField(null)}>
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerField(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
+34 -45
View File
@@ -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<RootStackParamList, 'EmailThread'>;
@@ -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<NativeScrollEvent>) => {
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) {
<FlatList
ref={listRef}
style={styles.pagerViewport}
onLayout={onPagerLayout}
onContentSizeChange={snapToActive}
data={emails}
extraData={pagerExtraData}
keyExtractor={(item) => 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 }) => (
<View style={{ width: windowWidth }}>
<View style={{ width: pageWidth }}>
<EmailPane
id={item.id}
active={item.id === activeEmailId}
@@ -1288,7 +1277,7 @@ function MoreMenuSheet({
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
@@ -1437,7 +1426,7 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
+5 -4
View File
@@ -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 (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={props.visible}
transparent
animationType="fade"
@@ -1217,7 +1218,7 @@ function ActionsSheet({
const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true);
const modified = modifiedOf(target);
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
@@ -1339,7 +1340,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
if (!target) return null;
const currentParent = target.parentId ?? null;
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
@@ -1394,7 +1395,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
if (!visible) return null;
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
+4 -3
View File
@@ -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<RootStackParamList, 'Scheduled'>;
@@ -322,7 +323,7 @@ export default function ScheduledScreen({ navigation }: Props) {
)}
{/* Per-message actions */}
<Modal visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Text style={styles.cardTitle} numberOfLines={2}>
@@ -345,7 +346,7 @@ export default function ScheduledScreen({ navigation }: Props) {
</Modal>
{/* Reschedule presets */}
<Modal visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
@@ -375,7 +376,7 @@ export default function ScheduledScreen({ navigation }: Props) {
</Modal>
{customStage !== null && Platform.OS === 'ios' && (
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.card} onPress={() => {}}>
<DateTimePicker
+3
View File
@@ -469,6 +469,9 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<View style={styles.searchBar}>
<Search size={16} color={c.textMuted} />
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
style={styles.searchInput}
placeholder={t('email_list.search_placeholder', 'Search mail...')}
placeholderTextColor={c.textMuted}