mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user