mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: keep folder, calendar and share sheets and compose prompts above the taskbar and keyboard
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
|||||||
} from '../lib/notification-mutes';
|
} from '../lib/notification-mutes';
|
||||||
import { serverSupportsEmailPush } from '../lib/push-notifications';
|
import { serverSupportsEmailPush } from '../lib/push-notifications';
|
||||||
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
|
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
|
||||||
|
import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
|
||||||
|
|
||||||
const CHEVRON_SLOT = 20;
|
const CHEVRON_SLOT = 20;
|
||||||
const INDENT_STEP = 12;
|
const INDENT_STEP = 12;
|
||||||
@@ -242,10 +243,12 @@ interface SheetAction {
|
|||||||
function ActionSheet({ title, actions, onClose }: { title: string; actions: SheetAction[]; onClose: () => void }) {
|
function ActionSheet({ title, actions, onClose }: { title: string; actions: SheetAction[]; onClose: () => void }) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
// The Modal reaches under a tablet's taskbar: keep the last action above it.
|
||||||
|
const bottomInset = useSheetBottomInset();
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} 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, { paddingBottom: spacing.xl + bottomInset }]}>
|
||||||
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
|
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
|
||||||
{actions.map((a) => {
|
{actions.map((a) => {
|
||||||
const Icon = a.icon;
|
const Icon = a.icon;
|
||||||
@@ -278,6 +281,8 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
|
|||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [value, setValue] = React.useState(initial ?? '');
|
const [value, setValue] = React.useState(initial ?? '');
|
||||||
|
// Centred above the keyboard (the field autofocuses) and the taskbar.
|
||||||
|
const bottomInset = useSheetBottomInset();
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const name = value.trim();
|
const name = value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -287,27 +292,29 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
|
|||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} 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.promptCenter, { paddingBottom: bottomInset }]} pointerEvents="box-none">
|
||||||
<Text style={styles.sheetTitle}>{title}</Text>
|
<View style={styles.promptCard}>
|
||||||
{message ? <Text style={styles.promptMessage}>{message}</Text> : null}
|
<Text style={styles.sheetTitle}>{title}</Text>
|
||||||
<TextInput
|
{message ? <Text style={styles.promptMessage}>{message}</Text> : null}
|
||||||
accessibilityLabel={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
|
<TextInput
|
||||||
value={value}
|
accessibilityLabel={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
|
||||||
onChangeText={setValue}
|
value={value}
|
||||||
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
|
onChangeText={setValue}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
|
||||||
style={styles.promptInput}
|
placeholderTextColor={c.textMuted}
|
||||||
autoFocus
|
style={styles.promptInput}
|
||||||
onSubmitEditing={submit}
|
autoFocus
|
||||||
returnKeyType="done"
|
onSubmitEditing={submit}
|
||||||
/>
|
returnKeyType="done"
|
||||||
<View style={styles.promptActions}>
|
/>
|
||||||
<Pressable onPress={onClose} style={styles.promptButton} hitSlop={6}>
|
<View style={styles.promptActions}>
|
||||||
<Text style={styles.promptButtonText}>{t('common.cancel', 'Cancel')}</Text>
|
<Pressable onPress={onClose} style={styles.promptButton} hitSlop={6}>
|
||||||
</Pressable>
|
<Text style={styles.promptButtonText}>{t('common.cancel', 'Cancel')}</Text>
|
||||||
<Pressable onPress={submit} style={styles.promptButton} hitSlop={6} disabled={!value.trim()}>
|
</Pressable>
|
||||||
<Text style={[styles.promptButtonText, { color: c.primary }, !value.trim() && { opacity: 0.5 }]}>{confirmLabel}</Text>
|
<Pressable onPress={submit} style={styles.promptButton} hitSlop={6} disabled={!value.trim()}>
|
||||||
</Pressable>
|
<Text style={[styles.promptButtonText, { color: c.primary }, !value.trim() && { opacity: 0.5 }]}>{confirmLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -1610,10 +1617,13 @@ function makeStyles(c: ThemePalette) {
|
|||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
},
|
},
|
||||||
sheetRowText: { ...typography.body, color: c.text },
|
sheetRowText: { ...typography.body, color: c.text },
|
||||||
|
// Centres the prompt in the space above the keyboard and the navigation bar.
|
||||||
|
promptCenter: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
promptCard: {
|
promptCard: {
|
||||||
position: 'absolute',
|
|
||||||
left: spacing.lg, right: spacing.lg,
|
|
||||||
top: '30%',
|
|
||||||
backgroundColor: c.popover,
|
backgroundColor: c.popover,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ import path from 'node:path';
|
|||||||
const SRC = path.resolve(__dirname, '../..');
|
const SRC = path.resolve(__dirname, '../..');
|
||||||
|
|
||||||
// Sheets in files other agents own; their fix is reported, not made here.
|
// Sheets in files other agents own; their fix is reported, not made here.
|
||||||
const OWNED_ELSEWHERE = new Set([
|
const OWNED_ELSEWHERE = new Set<string>([]);
|
||||||
'components/SidebarDrawer.tsx',
|
|
||||||
'components/calendar/CalendarEditSheet.tsx',
|
|
||||||
'components/sharing/ShareCollectionSheet.tsx',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function sourceFiles(dir: string): string[] {
|
function sourceFiles(dir: string): string[] {
|
||||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||||
@@ -48,6 +44,16 @@ function padsBottom(body: string): boolean {
|
|||||||
return /bottomInset|insets\.bottom/.test(body);
|
return /bottomInset|insets\.bottom/.test(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A body with a text field that opens the keyboard as the Modal opens. */
|
||||||
|
function autoFocuses(body: string): boolean {
|
||||||
|
return /\bautoFocus\b/.test(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Padded by useSheetBottomInset under any name (bottomInset, altInset, ...). */
|
||||||
|
function padsForKeyboard(body: string): boolean {
|
||||||
|
return /[a-zA-Z]*Inset\b|insets\.bottom/.test(body.replace(/\bsheetInsets\b/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
describe('bottom sheets in a Modal', () => {
|
describe('bottom sheets in a Modal', () => {
|
||||||
it('pad their content above the navigation bar or taskbar', () => {
|
it('pad their content above the navigation bar or taskbar', () => {
|
||||||
const offenders = sourceFiles(SRC).flatMap((file) => {
|
const offenders = sourceFiles(SRC).flatMap((file) => {
|
||||||
@@ -61,8 +67,32 @@ describe('bottom sheets in a Modal', () => {
|
|||||||
expect(offenders).toEqual([]);
|
expect(offenders).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A dialog whose field autofocuses opens with the keyboard up. The Modal
|
||||||
|
// window doesn't shrink for it, so a card at a fixed spot (the folder name
|
||||||
|
// prompt at top 30%) or centred in the full screen (Compose's image
|
||||||
|
// description) had its field or buttons under the keyboard.
|
||||||
|
it('keep a dialog with an autofocused field above the keyboard', () => {
|
||||||
|
const offenders = sourceFiles(SRC).flatMap((file) => {
|
||||||
|
const rel = path.relative(SRC, file).split(path.sep).join('/');
|
||||||
|
if (OWNED_ELSEWHERE.has(rel)) return [];
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
return modalBodies(source)
|
||||||
|
.filter((body) => autoFocuses(body) && !padsForKeyboard(body))
|
||||||
|
.map(() => rel);
|
||||||
|
});
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centres the folder name prompt instead of pinning it at 30 %', () => {
|
||||||
|
const source = fs.readFileSync(path.join(SRC, 'components/SidebarDrawer.tsx'), 'utf8');
|
||||||
|
expect(source).not.toMatch(/top:\s*'30%'/);
|
||||||
|
});
|
||||||
|
|
||||||
it('covers the sheets the tablet walkthrough found', () => {
|
it('covers the sheets the tablet walkthrough found', () => {
|
||||||
for (const rel of ['components/settings/FolderSettings.tsx', 'screens/FilesScreen.tsx', 'components/files/ShareSheet.tsx']) {
|
for (const rel of [
|
||||||
|
'components/settings/FolderSettings.tsx', 'screens/FilesScreen.tsx', 'components/files/ShareSheet.tsx',
|
||||||
|
'components/SidebarDrawer.tsx', 'components/calendar/CalendarEditSheet.tsx', 'components/sharing/ShareCollectionSheet.tsx',
|
||||||
|
]) {
|
||||||
const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8')).filter(rendersSheet);
|
const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8')).filter(rendersSheet);
|
||||||
expect(bodies.length, rel).toBeGreaterThan(0);
|
expect(bodies.length, rel).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
@@ -75,5 +105,7 @@ describe('bottom sheets in a Modal', () => {
|
|||||||
expect(rendersSheet(bodies[0])).toBe(true);
|
expect(rendersSheet(bodies[0])).toBe(true);
|
||||||
expect(padsBottom(bodies[0])).toBe(true);
|
expect(padsBottom(bodies[0])).toBe(true);
|
||||||
expect(padsBottom('<Modal><View style={styles.sheet} /></Modal>')).toBe(false);
|
expect(padsBottom('<Modal><View style={styles.sheet} /></Modal>')).toBe(false);
|
||||||
|
expect(padsForKeyboard('<View style={[styles.backdrop, { paddingBottom: spacing.lg + altInset }]}>')).toBe(true);
|
||||||
|
expect(padsForKeyboard('<View style={[styles.backdrop, { paddingLeft: sheetInsets.left }]}>')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const CUSTOM_COLOR_GRID = customColorGrid();
|
|||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
||||||
|
|
||||||
export interface CalendarEditValues {
|
export interface CalendarEditValues {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -147,6 +148,8 @@ interface CalendarEditSheetProps {
|
|||||||
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
|
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
|
||||||
// Centred on a wide window, clear of side insets in landscape.
|
// Centred on a wide window, clear of side insets in landscape.
|
||||||
const sheetInsets = useSheetInsets();
|
const sheetInsets = useSheetInsets();
|
||||||
|
// Create above a tablet's taskbar, the hex field above the keyboard.
|
||||||
|
const bottomInset = useSheetBottomInset(visible);
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
@@ -216,7 +219,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback accessible={false}>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
|
||||||
<Text style={styles.title}>
|
<Text style={styles.title}>
|
||||||
{calendar
|
{calendar
|
||||||
? t('calendar.management.edit_calendar', 'Edit calendar')
|
? t('calendar.management.edit_calendar', 'Edit calendar')
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
||||||
|
|
||||||
/** A role the sheet offers: its rights and the webmail's name for it. */
|
/** A role the sheet offers: its rights and the webmail's name for it. */
|
||||||
export interface SharePreset<R> {
|
export interface SharePreset<R> {
|
||||||
@@ -64,6 +65,8 @@ export function ShareCollectionSheet<R extends object>({
|
|||||||
}: ShareCollectionSheetProps<R>) {
|
}: ShareCollectionSheetProps<R>) {
|
||||||
// Centred on a wide window, clear of side insets in landscape.
|
// Centred on a wide window, clear of side insets in landscape.
|
||||||
const sheetInsets = useSheetInsets();
|
const sheetInsets = useSheetInsets();
|
||||||
|
// Done above a tablet's taskbar, the search field above the keyboard.
|
||||||
|
const bottomInset = useSheetBottomInset();
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
@@ -208,7 +211,7 @@ export function ShareCollectionSheet<R extends object>({
|
|||||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback accessible={false}>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Users size={18} color={c.textMuted} />
|
<Users size={18} color={c.textMuted} />
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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 { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||||
import { useLayout } from '../lib/use-layout';
|
import { useLayout } from '../lib/use-layout';
|
||||||
|
import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
|
||||||
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
|
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
@@ -2084,6 +2085,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// readers skip it; Skip leaves it undescribed.
|
// readers skip it; Skip leaves it undescribed.
|
||||||
const [altQueue, setAltQueue] = React.useState<Array<{ cid: string; src: string }>>([]);
|
const [altQueue, setAltQueue] = React.useState<Array<{ cid: string; src: string }>>([]);
|
||||||
const altTarget = altQueue[0] ?? null;
|
const altTarget = altQueue[0] ?? null;
|
||||||
|
// The field autofocuses: the card centres above the keyboard and the taskbar.
|
||||||
|
const altInset = useSheetBottomInset(!!altTarget);
|
||||||
const [altText, setAltText] = React.useState('');
|
const [altText, setAltText] = React.useState('');
|
||||||
const [altDecorative, setAltDecorative] = React.useState(false);
|
const [altDecorative, setAltDecorative] = React.useState(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -2102,6 +2105,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// ── Link prompt (Modal) ──────────────────────────────────────────────
|
// ── Link prompt (Modal) ──────────────────────────────────────────────
|
||||||
const [linkPromptVisible, setLinkPromptVisible] = React.useState(false);
|
const [linkPromptVisible, setLinkPromptVisible] = React.useState(false);
|
||||||
const [linkPromptValue, setLinkPromptValue] = React.useState('');
|
const [linkPromptValue, setLinkPromptValue] = React.useState('');
|
||||||
|
const linkInset = useSheetBottomInset(linkPromptVisible);
|
||||||
|
|
||||||
const openLinkPrompt = () => {
|
const openLinkPrompt = () => {
|
||||||
setLinkPromptValue('https://');
|
setLinkPromptValue('https://');
|
||||||
@@ -3092,7 +3096,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setLinkPromptVisible(false)}
|
onRequestClose={() => setLinkPromptVisible(false)}
|
||||||
>
|
>
|
||||||
<View style={styles.modalBackdrop}>
|
<View style={[styles.modalBackdrop, { paddingBottom: spacing.lg + linkInset }]}>
|
||||||
<View style={styles.modalCard}>
|
<View style={styles.modalCard}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>
|
||||||
{t('email_composer.add_link', 'Add link')}
|
{t('email_composer.add_link', 'Add link')}
|
||||||
@@ -3143,7 +3147,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={closeAltPrompt}
|
onRequestClose={closeAltPrompt}
|
||||||
>
|
>
|
||||||
<View style={styles.modalBackdrop}>
|
<View style={[styles.modalBackdrop, { paddingBottom: spacing.lg + altInset }]}>
|
||||||
<View style={styles.modalCard}>
|
<View style={styles.modalCard}>
|
||||||
<Text style={styles.modalTitle}>{t('email_composer.image_alt.title', 'Describe this image')}</Text>
|
<Text style={styles.modalTitle}>{t('email_composer.image_alt.title', 'Describe this image')}</Text>
|
||||||
<Text style={styles.modalLabel}>
|
<Text style={styles.modalLabel}>
|
||||||
|
|||||||
Reference in New Issue
Block a user