mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: pick recipients full screen in phone landscape, where suggestions had no room
This commit is contained in:
@@ -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\(\);/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user