mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 16:06:41 +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.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_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<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.
|
// Android's manifest is maintained by hand and lists the same schemes.
|
||||||
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
|
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
orientation: 'portrait',
|
orientation: 'default',
|
||||||
icon: './assets/icon.png',
|
icon: './assets/icon.png',
|
||||||
userInterfaceStyle: 'automatic',
|
userInterfaceStyle: 'automatic',
|
||||||
newArchEnabled: true,
|
newArchEnabled: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Button from './Button';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface DialogInput {
|
interface DialogInput {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -55,7 +56,7 @@ export default function Dialog({
|
|||||||
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
|
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
|
||||||
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
|
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
|
||||||
return (
|
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.backdrop}>
|
||||||
<View style={styles.dialog}>
|
<View style={styles.dialog}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useColors } from '../theme/colors';
|
|||||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||||
import type { Identity } from '../api/types';
|
import type { Identity } from '../api/types';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface IdentitySheetProps {
|
interface IdentitySheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -44,7 +45,7 @@ export function IdentitySheet({
|
|||||||
}, [visible, slideY, overlayOpacity]);
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useSheetDrag } from '../lib/use-sheet-drag';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { localizeMailboxName } from '../lib/mailbox-label';
|
import { localizeMailboxName } from '../lib/mailbox-label';
|
||||||
import type { Mailbox } from '../api/types';
|
import type { Mailbox } from '../api/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
@@ -76,7 +77,7 @@ export function MoveSheet({
|
|||||||
}, [mailboxes]);
|
}, [mailboxes]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import Button from './Button';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
||||||
import { describeLoginError } from '../lib/login-errors';
|
import { describeLoginError } from '../lib/login-errors';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface PasteSignInLinkModalProps {
|
interface PasteSignInLinkModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -80,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
|
|||||||
const title = t('login.mobile.paste_title', 'Paste sign-in link');
|
const title = t('login.mobile.paste_title', 'Paste sign-in link');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
|||||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface QrScanModalProps {
|
interface QrScanModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -42,7 +43,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{permission?.granted ? (
|
{permission?.granted ? (
|
||||||
<CameraView
|
<CameraView
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface RichTextEditorHandle {
|
|||||||
*/
|
*/
|
||||||
getHtml(timeoutMs?: number): Promise<string>;
|
getHtml(timeoutMs?: number): Promise<string>;
|
||||||
focus(): void;
|
focus(): void;
|
||||||
|
/** Drop the caret, which closes the keyboard. */
|
||||||
|
blur(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -176,6 +178,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
focus: () => {
|
focus: () => {
|
||||||
call(`window.__rne && window.__rne.focus()`);
|
call(`window.__rne && window.__rne.focus()`);
|
||||||
},
|
},
|
||||||
|
blur: () => {
|
||||||
|
call(`document.activeElement && document.activeElement.blur()`);
|
||||||
|
},
|
||||||
}), [call]);
|
}), [call]);
|
||||||
|
|
||||||
const onMessage = (event: WebViewMessageEvent) => {
|
const onMessage = (event: WebViewMessageEvent) => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { useNavigation } from '@react-navigation/native';
|
|||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import type { Mailbox } from '../api/types';
|
import type { Mailbox } from '../api/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
const CHEVRON_SLOT = 20;
|
const CHEVRON_SLOT = 20;
|
||||||
const INDENT_STEP = 12;
|
const INDENT_STEP = 12;
|
||||||
@@ -228,7 +229,7 @@ function ActionSheet({ title, actions, onClose }: { title: string; actions: Shee
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
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} />
|
<Pressable style={styles.sheetOverlay} onPress={onClose} />
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
|
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
|
||||||
@@ -270,7 +271,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
|
|||||||
onSubmit(name);
|
onSubmit(name);
|
||||||
};
|
};
|
||||||
return (
|
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} />
|
<Pressable style={styles.sheetOverlay} onPress={onClose} />
|
||||||
<View style={styles.promptCard}>
|
<View style={styles.promptCard}>
|
||||||
<Text style={styles.sheetTitle}>{title}</Text>
|
<Text style={styles.sheetTitle}>{title}</Text>
|
||||||
@@ -746,7 +747,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
const tagViewActive = !!filters.keyword;
|
const tagViewActive = !!filters.keyword;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { keywordToken, type KeywordDef } from '../stores/keywords-store';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { getEmailTagIds } from '../lib/thread-utils';
|
import { getEmailTagIds } from '../lib/thread-utils';
|
||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface TagSheetProps {
|
interface TagSheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -65,7 +66,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
|
|||||||
}, [keywords, selectedEmails]);
|
}, [keywords, selectedEmails]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useSheetDrag } from '../lib/use-sheet-drag';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
|
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
|
||||||
import { filterTemplates } from '../lib/template-utils';
|
import { filterTemplates } from '../lib/template-utils';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface TemplateSheetProps {
|
interface TemplateSheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -63,7 +64,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
|
|||||||
}, [templates, query]);
|
}, [templates, query]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
|
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
export interface CalendarEditValues {
|
export interface CalendarEditValues {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -69,7 +70,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { Calendar, CalendarRights, Principal } from '../../api/types';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
|
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../
|
|||||||
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
/** A calendar view listed at the top of the drawer. */
|
/** A calendar view listed at the top of the drawer. */
|
||||||
export interface DrawerViewOption {
|
export interface DrawerViewOption {
|
||||||
@@ -151,7 +152,7 @@ export function CalendarSidebarDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
|
|||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
|
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
|
||||||
import type { TranslateFn } from '../../stores/locale-store';
|
import type { TranslateFn } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ export function EventDetailSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
@@ -613,7 +614,9 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: c.border,
|
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: {
|
handleHit: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: spacing.xs,
|
paddingTop: spacing.xs,
|
||||||
@@ -640,7 +643,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||||
closeBtn: { padding: 4 },
|
closeBtn: { padding: 4 },
|
||||||
|
|
||||||
body: { maxHeight: 400 },
|
body: { flexShrink: 1 },
|
||||||
bodyContent: {
|
bodyContent: {
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.lg,
|
paddingBottom: spacing.lg,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { Button } from '..';
|
|||||||
import { ParticipantInput } from './ParticipantInput';
|
import { ParticipantInput } from './ParticipantInput';
|
||||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
|
|
||||||
@@ -410,7 +411,7 @@ export function EventModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
presentationStyle="fullScreen"
|
presentationStyle="fullScreen"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ImportRefusedError, type ImportResult } from '../../stores/calendar-sto
|
|||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
import type { Locale } from 'date-fns';
|
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
|
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||||
@@ -184,7 +185,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type CalendarSubscription,
|
type CalendarSubscription,
|
||||||
} from '../../stores/calendar-subscriptions-store';
|
} from '../../stores/calendar-subscriptions-store';
|
||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
// The webmail's refresh intervals and their labels.
|
// The webmail's refresh intervals and their labels.
|
||||||
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
||||||
@@ -131,7 +132,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
@@ -49,7 +50,13 @@ export function MonthDropdown({
|
|||||||
}: MonthDropdownProps) {
|
}: MonthDropdownProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
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.
|
// The months are fixed while the panel is open; it remounts on each open.
|
||||||
const [months] = React.useState(() => {
|
const [months] = React.useState(() => {
|
||||||
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
|
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
|
||||||
@@ -69,7 +76,11 @@ export function MonthDropdown({
|
|||||||
|
|
||||||
const renderItem = React.useCallback(
|
const renderItem = React.useCallback(
|
||||||
({ item }: ListRenderItemInfo<Date>) => (
|
({ item }: ListRenderItemInfo<Date>) => (
|
||||||
<View style={{ width, height: PANEL_HEIGHT }}>
|
<ScrollView
|
||||||
|
style={{ width, height: panelHeight }}
|
||||||
|
scrollEnabled={panelHeight < PANEL_HEIGHT}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
<MonthView
|
<MonthView
|
||||||
currentDate={item}
|
currentDate={item}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
@@ -79,9 +90,9 @@ export function MonthDropdown({
|
|||||||
weekStartsOn={weekStartsOn}
|
weekStartsOn={weekStartsOn}
|
||||||
onSelectDate={onSelectDate}
|
onSelectDate={onSelectDate}
|
||||||
/>
|
/>
|
||||||
</View>
|
</ScrollView>
|
||||||
),
|
),
|
||||||
[width, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
|
[width, panelHeight, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getItemLayout = React.useCallback(
|
const getItemLayout = React.useCallback(
|
||||||
@@ -90,20 +101,28 @@ export function MonthDropdown({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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
|
<FlatList
|
||||||
|
// A new width remounts the pager on the month it was showing.
|
||||||
|
key={width}
|
||||||
horizontal
|
horizontal
|
||||||
pagingEnabled
|
pagingEnabled
|
||||||
data={months}
|
data={months}
|
||||||
keyExtractor={monthKey}
|
keyExtractor={monthKey}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
initialScrollIndex={MONTHS_EACH_SIDE}
|
initialScrollIndex={lastIndexRef.current}
|
||||||
onMomentumScrollEnd={handleMomentumEnd}
|
onMomentumScrollEnd={handleMomentumEnd}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
initialNumToRender={1}
|
initialNumToRender={1}
|
||||||
windowSize={3}
|
windowSize={3}
|
||||||
style={{ height: PANEL_HEIGHT }}
|
style={{ height: panelHeight }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
|
|||||||
import {
|
import {
|
||||||
dayIndexIn,
|
dayIndexIn,
|
||||||
monthFocusRow,
|
monthFocusRow,
|
||||||
|
monthRowGeometry,
|
||||||
monthKeyOf,
|
monthKeyOf,
|
||||||
monthMask,
|
monthMask,
|
||||||
sampledRow,
|
sampledRow,
|
||||||
@@ -124,8 +125,9 @@ function MonthScrollViewInner({
|
|||||||
const height = e.nativeEvent.layout.height;
|
const height = e.nativeEvent.layout.height;
|
||||||
if (height > 0) setListHeight(height);
|
if (height > 0) setListHeight(height);
|
||||||
}, []);
|
}, []);
|
||||||
const rowHeight = Math.max(MONTH_ROW_HEIGHT_FULL_MIN, Math.floor(listHeight / VISIBLE_ROWS));
|
// A phone in landscape has room for three or four weeks, not six.
|
||||||
const viewportHeight = rowHeight * VISIBLE_ROWS;
|
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
|
// The month in focus: set by navigation, then by what scrolls under the
|
||||||
// sample line.
|
// sample line.
|
||||||
@@ -134,16 +136,28 @@ function MonthScrollViewInner({
|
|||||||
const onVisibleRef = React.useRef(onVisibleDateChange);
|
const onVisibleRef = React.useRef(onVisibleDateChange);
|
||||||
onVisibleRef.current = onVisibleDateChange;
|
onVisibleRef.current = onVisibleDateChange;
|
||||||
const offsetRef = React.useRef(0);
|
const offsetRef = React.useRef(0);
|
||||||
// Row at the top of the viewport; a change of row height (the "show time"
|
// Week at the top of the viewport; a change of row height (the "show time"
|
||||||
// setting) remounts the list there.
|
// setting, a rotation) remounts the list there. Kept as the week, not its
|
||||||
const topRowRef = React.useRef<number | null>(null);
|
// 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(
|
const handleScroll = React.useCallback(
|
||||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const offset = e.nativeEvent.contentOffset.y;
|
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;
|
offsetRef.current = offset;
|
||||||
topRowRef.current = Math.round(offset / rowHeight);
|
|
||||||
const list = weeksRef.current;
|
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 row = sampledRow(offset, viewportHeight, rowHeight, list.length);
|
||||||
const days = list[row];
|
const days = list[row];
|
||||||
if (!days) return;
|
if (!days) return;
|
||||||
@@ -158,15 +172,18 @@ function MonthScrollViewInner({
|
|||||||
[viewportHeight, rowHeight],
|
[viewportHeight, rowHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const markSettled = React.useCallback(() => { settledRef.current = true; }, []);
|
||||||
|
|
||||||
// Navigation inside the window scrolls the focused month's first week to
|
// 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.
|
// the top. The list mounts there already, so its own nonce needs nothing.
|
||||||
const listRef = React.useRef<FlatList<Date[]>>(null);
|
const listRef = React.useRef<FlatList<Date[]>>(null);
|
||||||
const handledNonceRef = React.useRef(focus.nonce);
|
const handledNonceRef = React.useRef(focus.nonce);
|
||||||
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
|
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
|
||||||
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
|
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
|
||||||
|
settledRef.current = false;
|
||||||
initialRef.current = {
|
initialRef.current = {
|
||||||
rowHeight,
|
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(() => {
|
React.useEffect(() => {
|
||||||
@@ -263,12 +280,13 @@ function MonthScrollViewInner({
|
|||||||
onEndReached={onExtendEnd}
|
onEndReached={onExtendEnd}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
|
onScrollBeginDrag={markSettled}
|
||||||
scrollEventThrottle={32}
|
scrollEventThrottle={32}
|
||||||
snapToInterval={rowHeight}
|
snapToInterval={rowHeight}
|
||||||
decelerationRate="fast"
|
decelerationRate="fast"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
initialNumToRender={VISIBLE_ROWS + 2}
|
initialNumToRender={visibleRows + 2}
|
||||||
maxToRenderPerBatch={VISIBLE_ROWS}
|
maxToRenderPerBatch={visibleRows}
|
||||||
windowSize={5}
|
windowSize={5}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -282,3 +300,9 @@ export const MonthScrollView = React.memo(MonthScrollViewInner);
|
|||||||
function weekKey(days: Date[]): string {
|
function weekKey(days: Date[]): string {
|
||||||
return dayKey(days[0]);
|
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;
|
export const MONTH_ROW_HEIGHT_CHIPS = 74;
|
||||||
// Smallest week row of the full-screen month grid.
|
// Smallest week row of the full-screen month grid.
|
||||||
export const MONTH_ROW_HEIGHT_FULL_MIN = 72;
|
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).
|
// Corner radius of event bars (repos/branding/APP.md).
|
||||||
const EVENT_RADIUS = 2;
|
const EVENT_RADIUS = 2;
|
||||||
|
|
||||||
@@ -424,8 +426,11 @@ function MonthViewInner({
|
|||||||
// Today on a clock in the calendar's time zone.
|
// Today on a clock in the calendar's time zone.
|
||||||
const today = displayNow();
|
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
|
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;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
// No full-screen keyboard in landscape: it would hide the suggestions
|
||||||
|
// and the text around the field.
|
||||||
|
disableFullscreenUI
|
||||||
value={draft}
|
value={draft}
|
||||||
onChangeText={setDraft}
|
onChangeText={setDraft}
|
||||||
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
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 { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { Button } from '..';
|
import { Button } from '..';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
|
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export function RecurrenceScopeDialog({
|
|||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
return (
|
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.backdrop}>
|
||||||
<View style={styles.dialog}>
|
<View style={styles.dialog}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||||
|
|
||||||
@@ -230,7 +231,7 @@ export function TasksSheet({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ function TimeGridScrollViewInner({
|
|||||||
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const list = daysRef.current;
|
const list = daysRef.current;
|
||||||
offsetXRef.current = e.nativeEvent.contentOffset.x;
|
offsetXRef.current = e.nativeEvent.contentOffset.x;
|
||||||
|
if (e.nativeEvent.contentOffset.x > 0) scrolledSinceMountRef.current = true;
|
||||||
const col = Math.max(
|
const col = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)),
|
Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)),
|
||||||
@@ -239,7 +240,12 @@ function TimeGridScrollViewInner({
|
|||||||
const visibleHandlerRef = React.useRef(onVisibleDateChange);
|
const visibleHandlerRef = React.useRef(onVisibleDateChange);
|
||||||
visibleHandlerRef.current = onVisibleDateChange;
|
visibleHandlerRef.current = onVisibleDateChange;
|
||||||
const lastVisibleKeyRef = React.useRef<string | null>(null);
|
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[] }) => {
|
const onViewableItemsChanged = React.useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||||
|
if (!scrolledSinceMountRef.current) return;
|
||||||
let first: Date | null = null;
|
let first: Date | null = null;
|
||||||
for (const token of viewableItems) {
|
for (const token of viewableItems) {
|
||||||
const day = token.item as Date;
|
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]);
|
const listStyle = React.useMemo(() => ({ width: listWidth, height: GRID_HEIGHT }), [listWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
import { useContactsStore } from '../../stores/contacts-store';
|
import { useContactsStore } from '../../stores/contacts-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -70,7 +71,7 @@ export default function AddressBookPickerSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Button from '../Button';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
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
|
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||||
// linked into every build (matches the FilesScreen upload flow).
|
// linked into every build (matches the FilesScreen upload flow).
|
||||||
@@ -160,7 +161,7 @@ export default function ContactImportSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useSheetDrag } from '../../lib/use-sheet-drag';
|
|||||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -94,7 +95,7 @@ export default function ContactPickerSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/co
|
|||||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
|
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -60,7 +61,7 @@ export default function TagAssignSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
export interface ActionSheetItem {
|
export interface ActionSheetItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -55,7 +56,7 @@ export function ActionSheet({ visible, title, subtitle, items, onClose, children
|
|||||||
}, [visible, slideY, overlayOpacity]);
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
|||||||
import EmailBodyView from '../EmailBodyView';
|
import EmailBodyView from '../EmailBodyView';
|
||||||
import type { PreviewKind } from '../../lib/attachment-display';
|
import type { PreviewKind } from '../../lib/attachment-display';
|
||||||
import type { EmlPreview } from './use-body-override';
|
import type { EmlPreview } from './use-body-override';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
export interface PreviewItem {
|
export interface PreviewItem {
|
||||||
kind: PreviewKind;
|
kind: PreviewKind;
|
||||||
@@ -160,7 +161,7 @@ export function AttachmentPreviewModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
|
|||||||
return (
|
return (
|
||||||
<View style={styles.box}>
|
<View style={styles.box}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
// No full-screen keyboard in landscape: it would hide the suggestions
|
||||||
|
// and the text around the field.
|
||||||
|
disableFullscreenUI
|
||||||
value={text}
|
value={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { FileNode, FileNodeRights, Principal } from '../../api/types';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
type RolePreset = 'read' | 'readWrite' | 'manager';
|
type RolePreset = 'read' | 'readWrite' | 'manager';
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
FilterComparator,
|
FilterComparator,
|
||||||
FilterActionType,
|
FilterActionType,
|
||||||
} from '../../lib/sieve/types';
|
} from '../../lib/sieve/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
|
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'];
|
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]);
|
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
|
||||||
|
|
||||||
return (
|
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']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
interface SieveEditorSheetProps {
|
interface SieveEditorSheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -62,7 +63,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
|
|||||||
}, [script, showSaveWarning, onSave]);
|
}, [script, showSaveWarning, onSave]);
|
||||||
|
|
||||||
return (
|
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']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useContactsStore } from '../../stores/contacts-store';
|
|||||||
import { useHasContacts } from '../../lib/capabilities';
|
import { useHasContacts } from '../../lib/capabilities';
|
||||||
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
|
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
interface TrustedRow {
|
interface TrustedRow {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -189,7 +190,7 @@ export function ContentSendersSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={modalOpen}
|
visible={modalOpen}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
|||||||
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
|
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
|
||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
import type { Mailbox } from '../../api/types';
|
import type { Mailbox } from '../../api/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
const ROLE_ICON: Record<string, any> = {
|
const ROLE_ICON: Record<string, any> = {
|
||||||
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
|
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
|
||||||
@@ -297,7 +298,7 @@ export function FolderSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</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.modalOverlay}>
|
||||||
<View style={styles.modalSheet}>
|
<View style={styles.modalSheet}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
|||||||
import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/recipients';
|
import { isValidEmail, parseRecipientList, formatRecipient } from '../../lib/recipients';
|
||||||
import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
|
import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
|
||||||
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
|
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
type DraftIdentity = {
|
type DraftIdentity = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -298,7 +299,7 @@ export function IdentitySettings() {
|
|||||||
</Pressable>
|
</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.modalOverlay}>
|
||||||
<View style={styles.modalSheet}>
|
<View style={styles.modalSheet}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
|
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
export function TemplateSettings() {
|
export function TemplateSettings() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
@@ -196,7 +197,7 @@ export function TemplateSettings() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Edit/Create modal */}
|
{/* 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.modalOverlay}>
|
||||||
<View style={styles.modalSheet}>
|
<View style={styles.modalSheet}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
@@ -270,7 +271,7 @@ export function TemplateSettings() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Import 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.modalOverlay}>
|
||||||
<View style={styles.modalSheet}>
|
<View style={styles.modalSheet}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections';
|
import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections';
|
||||||
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
|
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
|
||||||
import { useSyncCollections } from './use-sync-collections';
|
import { useSyncCollections } from './use-sync-collections';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../../lib/modal-orientations';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
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.');
|
: t('settings.device_sync.list_error_server', 'The server could not list them.');
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={close} />
|
<Pressable style={{ flex: 1 }} onPress={close} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import ToggleSwitchComponent from '../ToggleSwitch';
|
import ToggleSwitchComponent from '../ToggleSwitch';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { useSearchHighlight } from './search-highlight';
|
import { useSearchHighlight } from './search-highlight';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors webmail settings-section.tsx:
|
* Mirrors webmail settings-section.tsx:
|
||||||
@@ -193,7 +194,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
|||||||
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
|
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
|
||||||
<ChevronDown size={14} color={c.mutedForeground} />
|
<ChevronDown size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={open}
|
visible={open}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
dayIndexIn,
|
dayIndexIn,
|
||||||
monthFocusRow,
|
monthFocusRow,
|
||||||
|
monthRowGeometry,
|
||||||
monthKeyOf,
|
monthKeyOf,
|
||||||
monthMask,
|
monthMask,
|
||||||
sampledRow,
|
sampledRow,
|
||||||
@@ -115,3 +116,21 @@ describe('dayIndexIn', () => {
|
|||||||
expect(dayIndexIn([], new Date())).toBe(-1);
|
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));
|
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. */
|
/** Row under the sample line for a scroll offset. */
|
||||||
export function sampledRow(
|
export function sampledRow(
|
||||||
offset: number,
|
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 { ownMailboxes } from '../lib/mailbox-tree';
|
||||||
import { getEmailInitials } from '../lib/avatar-utils';
|
import { getEmailInitials } from '../lib/avatar-utils';
|
||||||
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
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 { useLocaleStore } from '../stores/locale-store';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useHasContacts } from '../lib/capabilities';
|
import { useHasContacts } from '../lib/capabilities';
|
||||||
@@ -76,6 +78,7 @@ import {
|
|||||||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||||
import type { EmailAddress, Identity } from '../api/types';
|
import type { EmailAddress, Identity } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
||||||
|
|
||||||
@@ -365,7 +368,7 @@ function OptionsSheet({
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
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.modalBackdrop} onPress={onClose}>
|
||||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||||
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
|
{!!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 timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
// Track the visible keyboard obstruction so the format bar stays above it.
|
// Track the visible keyboard obstruction so the format bar stays above it.
|
||||||
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
|
const kbObstruction = useKeyboardObstruction(insets.bottom, () => editorRef.current?.blur());
|
||||||
// measured from the top of the gesture-nav bar rather than from the true
|
const { shortHeight } = useLayout();
|
||||||
// screen bottom, so we derive obstruction height from `Dimensions.screen`
|
const typingInShortWindow = shortHeight && kbObstruction > 0;
|
||||||
// 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();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
// When the keyboard is up it covers the bottom safe area, so we don't
|
// 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.
|
// need to add it on top — pad by whichever is larger.
|
||||||
const bottomPad = Math.max(kbObstruction, insets.bottom);
|
const bottomPad = Math.max(kbObstruction, insets.bottom);
|
||||||
@@ -2226,6 +2204,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<TextInput
|
<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]}
|
style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
@@ -2283,8 +2264,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
|
||||||
<View style={styles.header}>
|
<View style={[styles.header, typingInShortWindow && styles.headerCompact]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
@@ -2298,7 +2279,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
|
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
|
||||||
</Text>
|
</Text>
|
||||||
{!!draftStatusText && (
|
{!!draftStatusText && !typingInShortWindow && (
|
||||||
<Text style={styles.headerSubtitle} numberOfLines={1}>{draftStatusText}</Text>
|
<Text style={styles.headerSubtitle} numberOfLines={1}>{draftStatusText}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -2386,6 +2367,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
|
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
disableFullscreenUI
|
||||||
style={styles.subjectInput}
|
style={styles.subjectInput}
|
||||||
placeholder={t('email_composer.subject_placeholder', 'Subject')}
|
placeholder={t('email_composer.subject_placeholder', 'Subject')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
@@ -2409,6 +2391,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
{plainTextMode ? (
|
{plainTextMode ? (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
disableFullscreenUI
|
||||||
style={styles.plainEditor}
|
style={styles.plainEditor}
|
||||||
multiline
|
multiline
|
||||||
value={plainBody}
|
value={plainBody}
|
||||||
@@ -2431,92 +2414,96 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<ScrollView
|
{/* A phone in landscape with the keyboard up has room for a few lines
|
||||||
horizontal
|
of text only; the format bar comes back with the keyboard down. */}
|
||||||
showsHorizontalScrollIndicator={false}
|
{!typingInShortWindow && (
|
||||||
style={styles.formatBar}
|
<ScrollView
|
||||||
contentContainerStyle={styles.formatActions}
|
horizontal
|
||||||
keyboardShouldPersistTaps="always"
|
showsHorizontalScrollIndicator={false}
|
||||||
>
|
style={styles.formatBar}
|
||||||
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
|
contentContainerStyle={styles.formatActions}
|
||||||
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
|
keyboardShouldPersistTaps="always"
|
||||||
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
|
>
|
||||||
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
|
<ToolbarButton onPress={() => setTemplateSheetOpen(true)}
|
||||||
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
|
icon={<LayoutTemplate size={18} color={c.textSecondary} />} />
|
||||||
label={plainTextMode
|
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
|
||||||
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
|
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
|
||||||
: t('email_composer.format_plain_text', 'Switch to plain text')}
|
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
|
||||||
icon={<Type size={18} color={plainTextMode ? c.primary : c.textSecondary} />} />
|
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 && (
|
{!plainTextMode && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.formatSep} />
|
<View style={styles.formatSep} />
|
||||||
|
|
||||||
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
|
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
|
||||||
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
|
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
|
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
|
||||||
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
|
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
|
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
|
||||||
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
|
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
|
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
|
||||||
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
|
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton onPress={() => setColorTarget('text')}
|
<ToolbarButton onPress={() => setColorTarget('text')}
|
||||||
label={t('email_composer.toolbar.text_color', 'Text color')}
|
label={t('email_composer.toolbar.text_color', 'Text color')}
|
||||||
icon={<Palette size={18} color={c.textSecondary} />} />
|
icon={<Palette size={18} color={c.textSecondary} />} />
|
||||||
<ToolbarButton onPress={() => setColorTarget('background')}
|
<ToolbarButton onPress={() => setColorTarget('background')}
|
||||||
label={t('email_composer.toolbar.background_color', 'Background color')}
|
label={t('email_composer.toolbar.background_color', 'Background color')}
|
||||||
icon={<Highlighter size={18} color={c.textSecondary} />} />
|
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')}
|
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
|
||||||
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
|
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
|
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
|
||||||
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
|
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')}
|
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
|
||||||
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
|
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
|
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
|
||||||
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
|
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
|
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
|
||||||
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
|
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton onPress={() => setTableMenuOpen(true)}
|
<ToolbarButton onPress={() => setTableMenuOpen(true)}
|
||||||
icon={<Table size={18} color={c.textSecondary} />} />
|
icon={<Table size={18} color={c.textSecondary} />} />
|
||||||
|
|
||||||
<View style={styles.formatSep} />
|
<View style={styles.formatSep} />
|
||||||
|
|
||||||
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
|
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
|
||||||
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
|
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
|
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
|
||||||
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
|
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
|
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
|
||||||
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
|
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}
|
<ToolbarButton active={selState.link} onPress={onLinkPress}
|
||||||
icon={selState.link
|
icon={selState.link
|
||||||
? <Link2Off size={18} color={c.primary} />
|
? <Link2Off size={18} color={c.primary} />
|
||||||
: <Link2 size={18} color={c.textSecondary} />} />
|
: <Link2 size={18} color={c.textSecondary} />} />
|
||||||
<ToolbarButton onPress={() => { void insertInlineImage(); }}
|
<ToolbarButton onPress={() => { void insertInlineImage(); }}
|
||||||
icon={<ImageIcon size={18} color={c.textSecondary} />} />
|
icon={<ImageIcon size={18} color={c.textSecondary} />} />
|
||||||
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
|
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
|
||||||
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
|
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
|
||||||
|
|
||||||
<View style={styles.formatSep} />
|
<View style={styles.formatSep} />
|
||||||
|
|
||||||
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
|
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
|
||||||
icon={<Undo2 size={18} color={c.textSecondary} />} />
|
icon={<Undo2 size={18} color={c.textSecondary} />} />
|
||||||
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
|
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
|
||||||
icon={<Redo2 size={18} color={c.textSecondary} />} />
|
icon={<Redo2 size={18} color={c.textSecondary} />} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={linkPromptVisible}
|
visible={linkPromptVisible}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
@@ -2567,7 +2554,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Text / background colour palette */}
|
{/* 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.modalBackdrop} onPress={() => setColorTarget(null)}>
|
||||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>
|
||||||
@@ -2631,7 +2618,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Schedule send sheet */}
|
{/* Schedule send sheet */}
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={scheduleSheetOpen}
|
visible={scheduleSheetOpen}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
@@ -2670,7 +2657,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{customStage !== null && Platform.OS === 'ios' && (
|
{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.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -2711,7 +2698,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Template placeholders */}
|
{/* Template placeholders */}
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={!!placeholderPrompt}
|
visible={!!placeholderPrompt}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
@@ -2766,7 +2753,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* From options: sub-address tag / From override */}
|
{/* 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.modalBackdrop}>
|
||||||
<View style={styles.modalCard}>
|
<View style={styles.modalCard}>
|
||||||
<Text style={styles.modalTitle}>{t('email_composer.from', 'From')}</Text>
|
<Text style={styles.modalTitle}>{t('email_composer.from', 'From')}</Text>
|
||||||
@@ -2882,6 +2869,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderBottomColor: c.border,
|
borderBottomColor: c.border,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
|
headerCompact: { paddingVertical: 0 },
|
||||||
headerBtn: {
|
headerBtn: {
|
||||||
width: componentSizes.buttonLg,
|
width: componentSizes.buttonLg,
|
||||||
height: 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 { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
||||||
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
||||||
@@ -802,7 +803,7 @@ function MoreActionsSheet({
|
|||||||
}, [visible, slideY, overlayOpacity]);
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
|
|||||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
|
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
|
||||||
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
|
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
|
||||||
@@ -1802,7 +1803,7 @@ export default function ContactFormScreen() {
|
|||||||
};
|
};
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
return (
|
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)} />
|
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerIndex(null)} />
|
||||||
<View style={styles.pickerSheet}>
|
<View style={styles.pickerSheet}>
|
||||||
<View style={styles.pickerHeader}>
|
<View style={styles.pickerHeader}>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
|||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { usePendingMailSearch } from '../navigation/pending-mail-search';
|
import { usePendingMailSearch } from '../navigation/pending-mail-search';
|
||||||
import type { Attachment, Email } from '../api/types';
|
import type { Attachment, Email } from '../api/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
function getSenderName(email: Email, unknownLabel: string): string {
|
function getSenderName(email: Email, unknownLabel: string): string {
|
||||||
return email.from?.[0]?.name || email.from?.[0]?.email || unknownLabel;
|
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}>
|
<View style={styles.searchInputArea}>
|
||||||
<Search size={16} color={c.textMuted} />
|
<Search size={16} color={c.textMuted} />
|
||||||
<TextInput
|
<TextInput
|
||||||
|
// No full-screen keyboard in landscape: it would hide the suggestions
|
||||||
|
// and the text around the field.
|
||||||
|
disableFullscreenUI
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
placeholder={t('email_list.search_placeholder', 'Search mail...')}
|
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)} />
|
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||||
<ListAttachmentOpener ref={attachmentOpenerRef} />
|
<ListAttachmentOpener ref={attachmentOpenerRef} />
|
||||||
|
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={filterMenuOpen}
|
visible={filterMenuOpen}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
@@ -1672,7 +1676,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
};
|
};
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
return (
|
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)} />
|
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerField(null)} />
|
||||||
<View style={styles.pickerSheet}>
|
<View style={styles.pickerSheet}>
|
||||||
<View style={styles.pickerHeader}>
|
<View style={styles.pickerHeader}>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Modal, useWindowDimensions, Animated, Easing, Alert, FlatList,
|
Modal, useWindowDimensions, Animated, Easing, Alert, FlatList,
|
||||||
Keyboard, Dimensions, Platform,
|
Keyboard, Dimensions, Platform,
|
||||||
} from 'react-native';
|
} 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 { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { shareEmailEml } from '../lib/email-export';
|
import { shareEmailEml } from '../lib/email-export';
|
||||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||||
|
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import {
|
import {
|
||||||
findArchiveMailbox, findTrashMailbox, mailboxAccountId, mailboxesOfAccount, mailboxOfEmail,
|
findArchiveMailbox, findTrashMailbox, mailboxAccountId, mailboxesOfAccount, mailboxOfEmail,
|
||||||
@@ -46,6 +47,7 @@ import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-
|
|||||||
import { accountScopedId } from '../lib/thread-utils';
|
import { accountScopedId } from '../lib/thread-utils';
|
||||||
import type { Email, EmailAddress, Identity } from '../api/types';
|
import type { Email, EmailAddress, Identity } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
|
||||||
|
|
||||||
@@ -87,6 +89,10 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
|
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
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 markRead = useEmailStore((s) => s.markRead);
|
||||||
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
||||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
||||||
@@ -262,22 +268,34 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
const goToIndex = React.useCallback((index: number) => {
|
const goToIndex = React.useCallback((index: number) => {
|
||||||
if (index < 0 || index >= emails.length) return;
|
if (index < 0 || index >= emails.length) return;
|
||||||
releaseNeighbours();
|
releaseNeighbours();
|
||||||
listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: true });
|
listRef.current?.scrollToOffset({ offset: index * pageWidth, animated: true });
|
||||||
const target = emails[index];
|
const target = emails[index];
|
||||||
if (target) setActiveEmailId(target.id);
|
if (target) setActiveEmailId(target.id);
|
||||||
}, [emails, windowWidth, releaseNeighbours]);
|
}, [emails, pageWidth, releaseNeighbours]);
|
||||||
|
|
||||||
const onMomentumEnd = React.useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
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];
|
const target = emails[index];
|
||||||
if (target && target.id !== activeEmailId) setActiveEmailId(target.id);
|
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);
|
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
|
// 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(
|
const pagerExtraData = React.useMemo(
|
||||||
() => [cacheVersion, neighboursReady],
|
() => [cacheVersion, neighboursReady],
|
||||||
@@ -642,39 +660,8 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||||
|
|
||||||
// Track the visible keyboard obstruction so quick reply stays visible and
|
// 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
|
// the bottom bar hides while typing.
|
||||||
// is measured from the top of the gesture bar rather than screen bottom,
|
const kbObstruction = useKeyboardObstruction(insets.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();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Drop optional toolbar buttons on narrow screens.
|
// Drop optional toolbar buttons on narrow screens.
|
||||||
const showMarkUnread = windowWidth >= 340;
|
const showMarkUnread = windowWidth >= 340;
|
||||||
@@ -763,6 +750,8 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
<FlatList
|
<FlatList
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
style={styles.pagerViewport}
|
style={styles.pagerViewport}
|
||||||
|
onLayout={onPagerLayout}
|
||||||
|
onContentSizeChange={snapToActive}
|
||||||
data={emails}
|
data={emails}
|
||||||
extraData={pagerExtraData}
|
extraData={pagerExtraData}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
@@ -770,7 +759,7 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
pagingEnabled
|
pagingEnabled
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
initialScrollIndex={initialIndexRef.current}
|
initialScrollIndex={initialIndexRef.current}
|
||||||
getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })}
|
getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })}
|
||||||
windowSize={3}
|
windowSize={3}
|
||||||
initialNumToRender={1}
|
initialNumToRender={1}
|
||||||
maxToRenderPerBatch={2}
|
maxToRenderPerBatch={2}
|
||||||
@@ -779,7 +768,7 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
onScrollBeginDrag={releaseNeighbours}
|
onScrollBeginDrag={releaseNeighbours}
|
||||||
onMomentumScrollEnd={onMomentumEnd}
|
onMomentumScrollEnd={onMomentumEnd}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<View style={{ width: windowWidth }}>
|
<View style={{ width: pageWidth }}>
|
||||||
<EmailPane
|
<EmailPane
|
||||||
id={item.id}
|
id={item.id}
|
||||||
active={item.id === activeEmailId}
|
active={item.id === activeEmailId}
|
||||||
@@ -1288,7 +1277,7 @@ function MoreMenuSheet({
|
|||||||
}, [visible, slideY, overlayOpacity]);
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -1437,7 +1426,7 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
|
|||||||
}, [visible, slideY, overlayOpacity]);
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
|||||||
import Dialog from '../components/Dialog';
|
import Dialog from '../components/Dialog';
|
||||||
import ShareSheet from '../components/files/ShareSheet';
|
import ShareSheet from '../components/files/ShareSheet';
|
||||||
import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal';
|
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.
|
// A file row carries the display name alongside the rest of the node.
|
||||||
interface FileRow extends FileNode {
|
interface FileRow extends FileNode {
|
||||||
@@ -1148,7 +1149,7 @@ function PromptModal(props: PromptModalProps) {
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makePromptStyles(c), [c]);
|
const styles = React.useMemo(() => makePromptStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={props.visible}
|
visible={props.visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
@@ -1217,7 +1218,7 @@ function ActionsSheet({
|
|||||||
const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true);
|
const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true);
|
||||||
const modified = modifiedOf(target);
|
const modified = modifiedOf(target);
|
||||||
return (
|
return (
|
||||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback>
|
||||||
@@ -1339,7 +1340,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
|
|||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
const currentParent = target.parentId ?? null;
|
const currentParent = target.parentId ?? null;
|
||||||
return (
|
return (
|
||||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback>
|
||||||
@@ -1394,7 +1395,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
|
|||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
|
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
|
||||||
return (
|
return (
|
||||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { draftContextFromEmail } from '../lib/draft-context';
|
|||||||
import { formatQuoteDate } from '../lib/quote-header';
|
import { formatQuoteDate } from '../lib/quote-header';
|
||||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
||||||
|
|
||||||
@@ -322,7 +323,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Per-message actions */}
|
{/* 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.modalBackdrop} onPress={() => setActionsFor(null)}>
|
||||||
<Pressable style={styles.card} onPress={() => {}}>
|
<Pressable style={styles.card} onPress={() => {}}>
|
||||||
<Text style={styles.cardTitle} numberOfLines={2}>
|
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||||
@@ -345,7 +346,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Reschedule presets */}
|
{/* 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.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
||||||
<Pressable style={styles.card} onPress={() => {}}>
|
<Pressable style={styles.card} onPress={() => {}}>
|
||||||
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
||||||
@@ -375,7 +376,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{customStage !== null && Platform.OS === 'ios' && (
|
{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.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||||
<Pressable style={styles.card} onPress={() => {}}>
|
<Pressable style={styles.card} onPress={() => {}}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
|
|||||||
@@ -469,6 +469,9 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
<View style={styles.searchBar}>
|
<View style={styles.searchBar}>
|
||||||
<Search size={16} color={c.textMuted} />
|
<Search size={16} color={c.textMuted} />
|
||||||
<TextInput
|
<TextInput
|
||||||
|
// No full-screen keyboard in landscape: it would hide the suggestions
|
||||||
|
// and the text around the field.
|
||||||
|
disableFullscreenUI
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
placeholder={t('email_list.search_placeholder', 'Search mail...')}
|
placeholder={t('email_list.search_placeholder', 'Search mail...')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
|
|||||||
Reference in New Issue
Block a user