diff --git a/src/components/compose/RecipientPicker.tsx b/src/components/compose/RecipientPicker.tsx new file mode 100644 index 00000000..6282bbca --- /dev/null +++ b/src/components/compose/RecipientPicker.tsx @@ -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({ + 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(null); + const showList = suggestions.length > 0 || (!!onSearchServer && !!input.trim()); + + return ( + inputRef.current?.focus()} + > + + + + + + + {label} + + {t('common.done', 'Done')} + + + + {recipients.map((r, i) => ( + onRemove(i)} + /> + ))} + { + if (input.trim()) onSubmitInput(); + else onDone(); + }} + blurOnSubmit={false} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + importantForAutofill="no" + textContentType="none" + /> + + {showList ? ( + + ) : ( + + )} + + + + ); +} + +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 }, + }); +} diff --git a/src/components/compose/RecipientSuggestionList.tsx b/src/components/compose/RecipientSuggestionList.tsx new file mode 100644 index 00000000..972fc93b --- /dev/null +++ b/src/components/compose/RecipientSuggestionList.tsx @@ -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 ( + + {recipient.group && } + + {label} + + + + + + ); +} + +/** + * 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 ( + + + {suggestions.map((s, i) => ( + onPick(s)} + style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]} + > + + {s.group + ? + : {getEmailInitials(s.name.trim(), s.email)}} + + + + {s.name || s.email} + + {s.group ? ( + + {t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })} + + ) : !!s.name && ( + {s.email} + )} + + + ))} + {onSearchServer && ( + { if (!searchingServer) onSearchServer(); }} + disabled={searchingServer} + accessibilityRole="button" + accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }} + style={({ pressed }) => [ + styles.suggestionRow, + suggestions.length > 0 && styles.suggestionSearchRowDivided, + pressed && styles.suggestionRowPressed, + ]} + > + + {searchingServer + ? + : } + + + {searchingServer + ? t('email_composer.autocomplete_searching', 'Searching...') + : t('email_composer.autocomplete_search_server', 'Search the server')} + + + )} + + + ); +} + +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 }, + }); +} diff --git a/src/lib/__tests__/recipient-picker.test.ts b/src/lib/__tests__/recipient-picker.test.ts new file mode 100644 index 00000000..4d9a8aeb --- /dev/null +++ b/src/lib/__tests__/recipient-picker.test.ts @@ -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(' { + 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\(\);/); + }); +}); diff --git a/src/lib/recipient-picker.ts b/src/lib/recipient-picker.ts new file mode 100644 index 00000000..2f5aebe6 --- /dev/null +++ b/src/lib/recipient-picker.ts @@ -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; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 6e9ada68..24adeb92 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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 ( - - {recipient.group && } - - {label} - - - - - - ); -} - -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 ( - - - {suggestions.map((s, i) => ( - onPick(s)} - style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]} - > - - {s.group - ? - : {initialsOf(s.name, s.email)}} - - - - {s.name || s.email} - - {s.group ? ( - - {t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })} - - ) : !!s.name && ( - {s.email} - )} - - - ))} - {onSearchServer && ( - { if (!searchingServer) onSearchServer(); }} - disabled={searchingServer} - accessibilityRole="button" - accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }} - style={({ pressed }) => [ - styles.suggestionRow, - suggestions.length > 0 && styles.suggestionSearchRowDivided, - pressed && styles.suggestionRowPressed, - ]} - > - - {searchingServer - ? - : } - - - {searchingServer - ? t('email_composer.autocomplete_searching', 'Searching...') - : t('email_composer.autocomplete_search_server', 'Search the server')} - - - )} - - - ); -} - 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(null); const plainBodyRef = React.useRef(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(null); + const pickerClosedAtRef = React.useRef(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>>({}); + const [activeFieldBottom, setActiveFieldBottom] = React.useState(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 ( - + { fieldRowRefs.current[field] = view; }} + > {label} {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" /> - {activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && ( - 0 || (canSearchServer && !!input.trim())) && ( + { @@ -3566,6 +3521,29 @@ export default function ComposeScreen({ route, navigation }: Props) { + {pickerField && ( + changeFieldInput(pickerField, text)} + onSubmitInput={() => addTyped(pickerField)} + suggestions={suggestions} + onPick={pickSuggestion} + onSearchServer={canSearchServer ? () => { void searchServer(); } : undefined} + searchingServer={searchingServer} + onRemove={(index) => removeChip(pickerField, index)} + onDone={closeRecipientPicker} + /> + )} +