feat: pick recipients full screen in phone landscape, where suggestions had no room

This commit is contained in:
Linus Rath
2026-10-31 15:16:07 +01:00
parent 8389db8fcb
commit e1ad5c0945
5 changed files with 567 additions and 180 deletions
+159
View File
@@ -0,0 +1,159 @@
import React from 'react';
import { KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft } from '../icons';
import { SafeAreaModal } from '../SafeAreaModal';
import { useColors } from '../../theme/colors';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset';
import type { RecipientSuggestion } from '../../stores/contacts-store';
import { RecipientChip, RecipientSuggestionList, type ChipRecipient } from './RecipientSuggestionList';
/**
* Full-screen recipient picker (U-069) for where the inline suggestions have
* no room: a phone in landscape with the keyboard up. The field sits at the
* top and the same suggestions fill the space down to the keyboard; Done or
* Back goes back to the composer with what was typed turned into chips.
*/
export function RecipientPicker<R extends ChipRecipient>({
visible, label, placeholder, recipients, isValid, input, onChangeInput, onSubmitInput,
suggestions, onPick, onSearchServer, searchingServer, onRemove, onDone,
}: {
visible: boolean;
/** "To", "Cc" or "Bcc". */
label: string;
placeholder: string;
recipients: R[];
isValid: (r: R) => boolean;
input: string;
onChangeInput: (text: string) => void;
/** Return in the field: turn the typed text into a chip. */
onSubmitInput: () => void;
suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void;
onSearchServer?: () => void;
searchingServer?: boolean;
onRemove: (index: number) => void;
onDone: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const keyboardPadding = useModalKeyboardPadding(visible);
const inputRef = React.useRef<TextInput>(null);
const showList = suggestions.length > 0 || (!!onSearchServer && !!input.trim());
return (
<SafeAreaModal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="fade"
onRequestClose={onDone}
onShow={() => inputRef.current?.focus()}
>
<SafeAreaView style={styles.screen} edges={['top', 'bottom', 'left', 'right']}>
<KeyboardAvoidingView
style={[styles.screen, { paddingBottom: keyboardPadding }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.header}>
<Pressable
onPress={onDone}
hitSlop={10}
style={styles.headerBtn}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">{label}</Text>
<Pressable onPress={onDone} hitSlop={10} style={styles.headerBtn} accessibilityRole="button">
<Text style={styles.done}>{t('common.done', 'Done')}</Text>
</Pressable>
</View>
<View style={styles.field}>
{recipients.map((r, i) => (
<RecipientChip
key={`${r.email}-${i}`}
recipient={r}
invalid={!isValid(r)}
onRemove={() => onRemove(i)}
/>
))}
<TextInput
ref={inputRef}
disableFullscreenUI
accessibilityLabel={label}
style={styles.input}
placeholder={placeholder}
placeholderTextColor={c.textMuted}
value={input}
onChangeText={onChangeInput}
returnKeyType="done"
onSubmitEditing={() => {
if (input.trim()) onSubmitInput();
else onDone();
}}
blurOnSubmit={false}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
importantForAutofill="no"
textContentType="none"
/>
</View>
{showList ? (
<RecipientSuggestionList
fill
suggestions={suggestions}
onPick={onPick}
onSearchServer={onSearchServer}
searchingServer={searchingServer}
/>
) : (
<View style={styles.empty} />
)}
</KeyboardAvoidingView>
</SafeAreaView>
</SafeAreaModal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
screen: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
headerBtn: { minHeight: 40, minWidth: 40, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: c.text, flex: 1 },
done: { ...typography.bodyMedium, color: c.primary },
field: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
input: {
flexGrow: 1,
minWidth: 140,
...typography.body,
color: c.text,
paddingVertical: 8,
},
empty: { flex: 1 },
});
}
@@ -0,0 +1,191 @@
import React from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Search, Users, X } from '../icons';
import { useColors } from '../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { getEmailInitials } from '../../lib/avatar-utils';
import { SUGGESTION_ROW_HEIGHT } from '../../lib/recipient-picker';
import type { RecipientSuggestion } from '../../stores/contacts-store';
/** A recipient chip's data. Group chips carry their resolved members and no email. */
export interface ChipRecipient {
name?: string;
email: string;
group?: { members: Array<{ name?: string; email: string }> };
}
export function RecipientChip({
recipient, invalid, onRemove, onLongPress,
}: {
recipient: ChipRecipient;
invalid?: boolean;
onRemove: () => void;
onLongPress?: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const label = recipient.group
? t('email_composer.group_chip', '{name} ({count})', {
name: recipient.name || t('contacts.group', 'Group'),
count: recipient.group.members.length,
})
: recipient.name || recipient.email;
return (
<Pressable onLongPress={onLongPress} delayLongPress={350} style={[styles.chip, invalid && styles.chipInvalid]}>
{recipient.group && <Users size={12} color={c.textSecondary} />}
<Text style={[styles.chipText, invalid && styles.chipTextInvalid]} numberOfLines={1}>
{label}
</Text>
<Pressable
onPress={onRemove}
// A 12 px icon: the touch area reaches about 40 dp around it.
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
>
<X size={12} color={invalid ? c.error : c.textMuted} />
</Pressable>
</Pressable>
);
}
/**
* The contact suggestions for the address being typed. Inline it drops down
* under the field; `fill` makes it the body of the full-screen recipient
* picker, where it takes the room above the keyboard.
*/
export function RecipientSuggestionList({
suggestions, onPick, onPressIn, onSearchServer, searchingServer, fill = false,
}: {
suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void;
onPressIn?: () => void;
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
onSearchServer?: () => void;
searchingServer?: boolean;
fill?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<View style={fill ? styles.fillBox : styles.suggestionBox}>
<ScrollView
style={fill ? styles.fillScroll : styles.suggestionScroll}
keyboardShouldPersistTaps="always"
nestedScrollEnabled
>
{suggestions.map((s, i) => (
<Pressable
key={`${s.group?.id ?? s.email}-${i}`}
onPressIn={onPressIn}
onPress={() => onPick(s)}
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
>
<View style={styles.suggestionAvatar}>
{s.group
? <Users size={14} color={c.primary} />
: <Text style={styles.suggestionAvatarText}>{getEmailInitials(s.name.trim(), s.email)}</Text>}
</View>
<View style={styles.suggestionText}>
<Text style={styles.suggestionName} numberOfLines={1}>
{s.name || s.email}
</Text>
{s.group ? (
<Text style={styles.suggestionEmail} numberOfLines={1}>
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })}
</Text>
) : !!s.name && (
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
)}
</View>
</Pressable>
))}
{onSearchServer && (
<Pressable
onPressIn={onPressIn}
onPress={() => { if (!searchingServer) onSearchServer(); }}
disabled={searchingServer}
accessibilityRole="button"
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
style={({ pressed }) => [
styles.suggestionRow,
suggestions.length > 0 && styles.suggestionSearchRowDivided,
pressed && styles.suggestionRowPressed,
]}
>
<View style={styles.suggestionSearchIcon}>
{searchingServer
? <ActivityIndicator size="small" color={c.textSecondary} />
: <Search size={16} color={c.textSecondary} />}
</View>
<Text style={styles.suggestionSearchText} numberOfLines={1}>
{searchingServer
? t('email_composer.autocomplete_searching', 'Searching...')
: t('email_composer.autocomplete_search_server', 'Search the server')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.surfaceActive,
maxWidth: 220,
},
chipInvalid: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.error },
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
chipTextInvalid: { color: c.error },
suggestionBox: {
position: 'absolute',
top: '100%',
left: spacing.lg + 56 + spacing.md,
right: spacing.lg,
borderWidth: 1,
borderColor: c.borderLight,
borderRadius: radius.sm,
backgroundColor: c.card,
zIndex: 20,
elevation: 5,
overflow: 'hidden',
},
fillBox: { flex: 1, backgroundColor: c.background },
suggestionRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
minHeight: SUGGESTION_ROW_HEIGHT,
gap: spacing.sm,
},
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionScroll: { maxHeight: 320 },
fillScroll: { flex: 1 },
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
suggestionAvatar: {
width: 32, height: 32,
borderRadius: 16,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
suggestionText: { flex: 1 },
suggestionName: { ...typography.bodyMedium, color: c.text },
suggestionEmail: { ...typography.caption, color: c.textSecondary },
});
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
isPickerReopenTooSoon, MIN_SUGGESTION_ROOM, needsRecipientPicker, PICKER_REOPEN_GRACE_MS, suggestionRoom,
} from '../recipient-picker';
// U-069: in phone landscape the keyboard leaves about 50 dp under the To
// field, so the inline suggestions hid behind it. The composer hands the
// field to a full-screen picker there, and wherever the measured room under
// the field is too small for the list.
describe('needsRecipientPicker', () => {
it('always uses the picker in a phone-landscape window', () => {
// use-layout's shortHeight: a window under 480 dp tall.
expect(needsRecipientPicker({ shortHeight: true, windowHeight: 360, keyboardObstruction: 0, fieldBottom: null })).toBe(true);
});
it('keeps the inline list in portrait, where it has room above the keyboard', () => {
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 844, keyboardObstruction: 300, fieldBottom: 160 })).toBe(false);
});
it('uses the picker when less than two rows fit between field and keyboard', () => {
// A short split-screen window: 520 tall, keyboard 300, field ends at 140.
expect(suggestionRoom(520, 300, 140)).toBe(80);
expect(80).toBeLessThan(MIN_SUGGESTION_ROOM);
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 300, fieldBottom: 140 })).toBe(true);
});
it('waits for the keyboard and a measurement before deciding on the room', () => {
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 0, fieldBottom: 140 })).toBe(false);
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 300, fieldBottom: null })).toBe(false);
});
it('does not reopen the picker from the focus the field gets back as it closes', () => {
expect(isPickerReopenTooSoon(1000, 1000 + PICKER_REOPEN_GRACE_MS - 1)).toBe(true);
expect(isPickerReopenTooSoon(1000, 1000 + PICKER_REOPEN_GRACE_MS)).toBe(false);
expect(isPickerReopenTooSoon(null, 5)).toBe(false);
});
});
describe('ComposeScreen recipient picker wiring', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
const field = source.slice(source.indexOf('const renderRecipientField'), source.indexOf('const composer = ('));
it('opens the picker when an address field takes the focus in a short window', () => {
expect(field).toMatch(/needsRecipientPicker\(\{ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null \}\)[\s\S]{0,40}openRecipientPicker\(field\)/);
});
it('hides the inline list while the picker has the field, and feeds the picker the same suggestions', () => {
expect(field).toMatch(/activeField === field && !pickerField &&/);
expect(source).toMatch(/const suggestField = pickerField \?\? activeField;\s+const suggestionQuery = inputFor\(suggestField\);/);
const picker = source.slice(source.indexOf('<RecipientPicker'), source.indexOf('<ComposerOptionsSheet'));
expect(picker).toMatch(/suggestions=\{suggestions\}/);
expect(picker).toMatch(/onPick=\{pickSuggestion\}/);
expect(picker).toMatch(/onDone=\{closeRecipientPicker\}/);
});
it('turns what was typed into chips when the picker closes', () => {
const close = source.slice(source.indexOf('const closeRecipientPicker'), source.indexOf('const closeRecipientPicker') + 400);
expect(close).toMatch(/if \(inputFor\(field\)\.trim\(\)\) addTyped\(field\);/);
expect(close).toMatch(/pickerClosedAtRef\.current = Date\.now\(\);/);
});
});
+44
View File
@@ -0,0 +1,44 @@
// When the composer's recipient suggestions can't drop down under the field
// (U-069): a phone in landscape has about 50 dp left under To with the
// keyboard up, so the list hid behind the keyboard. There the composer opens
// a full-screen recipient picker instead, whose suggestions take the room
// between its own field and the keyboard.
/** One suggestion row: a 32 dp avatar and 8 dp padding above and below. */
export const SUGGESTION_ROW_HEIGHT = 48;
/** The inline list needs room for at least this much to be of use. */
export const MIN_SUGGESTION_ROOM = 2 * SUGGESTION_ROW_HEIGHT;
/** Room between the bottom of the field and the top of the keyboard (dp). */
export function suggestionRoom(windowHeight: number, keyboardObstruction: number, fieldBottom: number): number {
return Math.max(0, windowHeight - keyboardObstruction - fieldBottom);
}
/**
* Whether the address field should hand over to the full-screen picker.
* A short window (a phone in landscape) always does: the keyboard leaves no
* room. Elsewhere it does once the keyboard is up and the measured room
* under the field is less than two rows (a small split-screen window).
*/
export function needsRecipientPicker(opts: {
shortHeight: boolean;
windowHeight: number;
keyboardObstruction: number;
/** The field row's bottom edge in window coordinates, when measured. */
fieldBottom: number | null;
}): boolean {
if (opts.shortHeight) return true;
if (opts.keyboardObstruction <= 0 || opts.fieldBottom == null) return false;
return suggestionRoom(opts.windowHeight, opts.keyboardObstruction, opts.fieldBottom) < MIN_SUGGESTION_ROOM;
}
/**
* A focus that lands right after the picker closed is the field getting its
* focus back, not the user asking for the picker again.
*/
export const PICKER_REOPEN_GRACE_MS = 600;
export function isPickerReopenTooSoon(closedAt: number | null, now: number): boolean {
return closedAt != null && now - closedAt < PICKER_REOPEN_GRACE_MS;
}
+109 -180
View File
@@ -11,7 +11,7 @@ import {
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type, Highlighter, Trash2, Search, Settings,
Tag, Type, Highlighter, Trash2, Settings,
} from '../components/icons';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
@@ -28,7 +28,6 @@ import RichTextEditor, {
} from '../components/RichTextEditor';
import { useEmailStore } from '../stores/email-store';
import { ownMailboxes } from '../lib/mailbox-tree';
import { getEmailInitials } from '../lib/avatar-utils';
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { useLayout } from '../lib/use-layout';
@@ -84,6 +83,9 @@ import {
addMentionedRecipient, buildMentionCandidates, mentionHref, mentionSuggestions, type MentionCandidate,
} from '../lib/recipient-mentions';
import { MentionSuggestionList } from '../components/compose/MentionSuggestionList';
import { RecipientChip, RecipientSuggestionList } from '../components/compose/RecipientSuggestionList';
import { RecipientPicker } from '../components/compose/RecipientPicker';
import { isPickerReopenTooSoon, needsRecipientPicker } from '../lib/recipient-picker';
import {
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../lib/compose-login';
@@ -218,118 +220,6 @@ function quoteLines(text: string): string {
return text.split('\n').map((l) => `> ${l}`).join('\n');
}
function RecipientChip({
recipient, invalid, onRemove, onLongPress,
}: {
recipient: Recipient;
invalid?: boolean;
onRemove: () => void;
onLongPress?: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const label = recipient.group
? t('email_composer.group_chip', '{name} ({count})', {
name: recipient.name || t('contacts.group', 'Group'),
count: recipient.group.members.length,
})
: recipient.name || recipient.email;
return (
<Pressable onLongPress={onLongPress} delayLongPress={350} style={[styles.chip, invalid && styles.chipInvalid]}>
{recipient.group && <Users size={12} color={c.textSecondary} />}
<Text style={[styles.chipText, invalid && styles.chipTextInvalid]} numberOfLines={1}>
{label}
</Text>
<Pressable
onPress={onRemove}
// A 12 px icon: the touch area reaches about 40 dp around it.
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
>
<X size={12} color={invalid ? c.error : c.textMuted} />
</Pressable>
</Pressable>
);
}
function initialsOf(name: string, email: string): string {
return getEmailInitials(name.trim(), email);
}
function SuggestionList({
suggestions, onPick, onPressIn, onSearchServer, searchingServer,
}: {
suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void;
onPressIn?: () => void;
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
onSearchServer?: () => void;
searchingServer?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<View style={styles.suggestionBox}>
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
{suggestions.map((s, i) => (
<Pressable
key={`${s.group?.id ?? s.email}-${i}`}
onPressIn={onPressIn}
onPress={() => onPick(s)}
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
>
<View style={styles.suggestionAvatar}>
{s.group
? <Users size={14} color={c.primary} />
: <Text style={styles.suggestionAvatarText}>{initialsOf(s.name, s.email)}</Text>}
</View>
<View style={styles.suggestionText}>
<Text style={styles.suggestionName} numberOfLines={1}>
{s.name || s.email}
</Text>
{s.group ? (
<Text style={styles.suggestionEmail} numberOfLines={1}>
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })}
</Text>
) : !!s.name && (
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
)}
</View>
</Pressable>
))}
{onSearchServer && (
<Pressable
onPressIn={onPressIn}
onPress={() => { if (!searchingServer) onSearchServer(); }}
disabled={searchingServer}
accessibilityRole="button"
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
style={({ pressed }) => [
styles.suggestionRow,
suggestions.length > 0 && styles.suggestionSearchRowDivided,
pressed && styles.suggestionRowPressed,
]}
>
<View style={styles.suggestionSearchIcon}>
{searchingServer
? <ActivityIndicator size="small" color={c.textSecondary} />
: <Search size={16} color={c.textSecondary} />}
</View>
<Text style={styles.suggestionSearchText} numberOfLines={1}>
{searchingServer
? t('email_composer.autocomplete_searching', 'Searching...')
: t('email_composer.autocomplete_search_server', 'Search the server')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
}
function AttachmentChip({
attachment, onRemove, onPress,
}: {
@@ -766,7 +656,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}), [t]);
// The label column grows with the system font size: at 1.3 "Betreff"
// wrapped into "Betref" / "f" in a fixed 56 dp column.
const { fontScale } = useWindowDimensions();
const { fontScale, height: windowHeight } = useWindowDimensions();
const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]);
const subjectRef = React.useRef<TextInput>(null);
const plainBodyRef = React.useRef<TextInput>(null);
@@ -823,7 +713,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
const inputFor = (field: Field | null) =>
field === 'to' ? toInput : field === 'cc' ? ccInput : field === 'bcc' ? bccInput : '';
const suggestionQuery = inputFor(activeField);
// The full-screen recipient picker (U-069) for a field whose inline
// suggestions would have no room; it owns the suggestions while open.
const [pickerField, setPickerField] = React.useState<Field | null>(null);
const pickerClosedAtRef = React.useRef<number | null>(null);
// The bottom edge of each address row in window coordinates, measured
// while it has the focus, to tell whether the list fits under it.
const fieldRowRefs = React.useRef<Partial<Record<Field, View | null>>>({});
const [activeFieldBottom, setActiveFieldBottom] = React.useState<number | null>(null);
const suggestField = pickerField ?? activeField;
const suggestionQuery = inputFor(suggestField);
const alreadySelected = React.useMemo(
() => new Set(
[...toRecipients, ...ccRecipients, ...bccRecipients]
@@ -869,7 +768,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const pickSuggestion = (s: RecipientSuggestion) => {
isPickingSuggestion.current = true;
const field = activeField ?? 'to';
const field = suggestField ?? 'to';
let recipient: Recipient;
if (s.group) {
const members = getGroupRecipients(s.group.id)
@@ -2768,13 +2667,68 @@ export default function ComposeScreen({ route, navigation }: Props) {
: draftStatus === 'failed' ? t('email_composer.save_failed', 'Failed to save')
: '';
// Typing in an address field, inline or in the picker. A separator ends
// the entry: commit immediately so a pasted list turns into chips as it
// lands.
const changeFieldInput = (field: Field, text: string) => {
const setInput = inputSetterFor(field);
setInput(text);
if (/[,;\n]$/.test(text) && text.trim().length > 1) {
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected));
if (valid.length) {
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]);
setInput(invalid.join(' '));
}
}
};
const measureFieldRow = (field: Field) => {
fieldRowRefs.current[field]?.measureInWindow((_x, y, _w, h) => {
if (Number.isFinite(y) && Number.isFinite(h)) setActiveFieldBottom(y + h);
});
};
const openRecipientPicker = (field: Field) => {
if (pickerField) return;
if (isPickerReopenTooSoon(pickerClosedAtRef.current, Date.now())) return;
// The picker takes over the field; the inline blur must not commit
// half-typed text behind it.
isPickingSuggestion.current = true;
setPickerField(field);
};
const closeRecipientPicker = () => {
const field = pickerField;
if (!field) return;
if (inputFor(field).trim()) addTyped(field);
pickerClosedAtRef.current = Date.now();
isPickingSuggestion.current = false;
setPickerField(null);
setActiveField(null);
};
// Typing inline where the list has no room under the field (a small
// split-screen window) hands over to the picker too.
React.useEffect(() => {
if (!activeField || pickerField) return;
if (!inputFor(activeField).trim() || suggestions.length === 0) return;
if (needsRecipientPicker({
shortHeight: false, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: activeFieldBottom,
})) {
openRecipientPicker(activeField);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeField, pickerField, suggestions.length, windowHeight, kbObstruction, activeFieldBottom]);
const renderRecipientField = (field: Field, label: string, placeholder: string, zIndex: number) => {
const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients;
const input = inputFor(field);
const setInput = inputSetterFor(field);
const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field;
return (
<View style={[styles.fieldRow, { zIndex }]}>
<View
style={[styles.fieldRow, { zIndex }]}
ref={(view) => { fieldRowRefs.current[field] = view; }}
>
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text>
<View style={styles.recipientField}>
{list.map((r, i) => (
@@ -2796,18 +2750,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
placeholderTextColor={c.textMuted}
value={input}
onChangeText={(text) => {
// A separator ends the entry: commit immediately so a pasted
// list turns into chips as it lands.
setInput(text);
if (/[,;\n]$/.test(text) && text.trim().length > 1) {
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected));
if (valid.length) {
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]);
setInput(invalid.join(' '));
}
changeFieldInput(field, text);
measureFieldRow(field);
}}
onFocus={() => {
setActiveField(field);
// A phone in landscape: no room for suggestions above the
// keyboard, so the full-screen picker takes the typing (U-069).
if (needsRecipientPicker({ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null })) {
openRecipientPicker(field);
} else {
measureFieldRow(field);
}
}}
onFocus={() => setActiveField(field)}
onBlur={() => {
setTimeout(() => {
if (!isPickingSuggestion.current) {
@@ -2835,8 +2790,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
textContentType="none"
/>
</View>
{activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
<SuggestionList
{activeField === field && !pickerField && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
<RecipientSuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
@@ -3566,6 +3521,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</Modal>
{pickerField && (
<RecipientPicker
visible
label={pickerField === 'to' ? t('email_composer.to', 'To') : pickerField === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.bcc', 'Bcc')}
placeholder={pickerField === 'to'
? t('email_composer.to_placeholder', 'Recipient email addresses')
: pickerField === 'cc'
? t('email_composer.cc_placeholder', 'Cc recipients')
: t('email_composer.bcc_placeholder', 'Bcc recipients')}
recipients={pickerField === 'to' ? toRecipients : pickerField === 'cc' ? ccRecipients : bccRecipients}
isValid={chipIsValid}
input={inputFor(pickerField)}
onChangeInput={(text) => changeFieldInput(pickerField, text)}
onSubmitInput={() => addTyped(pickerField)}
suggestions={suggestions}
onPick={pickSuggestion}
onSearchServer={canSearchServer ? () => { void searchServer(); } : undefined}
searchingServer={searchingServer}
onRemove={(index) => removeChip(pickerField, index)}
onDone={closeRecipientPicker}
/>
)}
<ComposerOptionsSheet
visible={optionsSheetOpen}
options={composerOptions}
@@ -3693,19 +3671,6 @@ function makeStyles(c: ThemePalette) {
gap: spacing.xs,
paddingVertical: 4,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.surfaceActive,
maxWidth: 220,
},
chipInvalid: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.error },
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
chipTextInvalid: { color: c.error },
recipientInput: {
flexGrow: 1,
minWidth: 100,
@@ -3714,42 +3679,6 @@ function makeStyles(c: ThemePalette) {
paddingVertical: 6,
},
recipientInputInvalid: { color: c.error },
suggestionBox: {
position: 'absolute',
top: '100%',
left: spacing.lg + 56 + spacing.md,
right: spacing.lg,
borderWidth: 1,
borderColor: c.borderLight,
borderRadius: radius.sm,
backgroundColor: c.card,
zIndex: 20,
elevation: 5,
overflow: 'hidden',
},
suggestionRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionScroll: { maxHeight: 320 },
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
suggestionAvatar: {
width: 32, height: 32,
borderRadius: 16,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
suggestionText: { flex: 1 },
suggestionName: { ...typography.bodyMedium, color: c.text },
suggestionEmail: { ...typography.caption, color: c.textSecondary },
ccToggle: {
paddingHorizontal: spacing.sm,
paddingVertical: 6,