mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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,
|
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||||||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||||||
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
|
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
|
||||||
Users, Tag, Type, Highlighter, Trash2, Search, Settings,
|
Tag, Type, Highlighter, Trash2, Settings,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
@@ -28,7 +28,6 @@ import RichTextEditor, {
|
|||||||
} from '../components/RichTextEditor';
|
} from '../components/RichTextEditor';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||||
import { getEmailInitials } from '../lib/avatar-utils';
|
|
||||||
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
||||||
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||||
import { useLayout } from '../lib/use-layout';
|
import { useLayout } from '../lib/use-layout';
|
||||||
@@ -84,6 +83,9 @@ import {
|
|||||||
addMentionedRecipient, buildMentionCandidates, mentionHref, mentionSuggestions, type MentionCandidate,
|
addMentionedRecipient, buildMentionCandidates, mentionHref, mentionSuggestions, type MentionCandidate,
|
||||||
} from '../lib/recipient-mentions';
|
} from '../lib/recipient-mentions';
|
||||||
import { MentionSuggestionList } from '../components/compose/MentionSuggestionList';
|
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 {
|
import {
|
||||||
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||||||
} from '../lib/compose-login';
|
} from '../lib/compose-login';
|
||||||
@@ -218,118 +220,6 @@ function quoteLines(text: string): string {
|
|||||||
return text.split('\n').map((l) => `> ${l}`).join('\n');
|
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({
|
function AttachmentChip({
|
||||||
attachment, onRemove, onPress,
|
attachment, onRemove, onPress,
|
||||||
}: {
|
}: {
|
||||||
@@ -766,7 +656,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
}), [t]);
|
}), [t]);
|
||||||
// The label column grows with the system font size: at 1.3 "Betreff"
|
// The label column grows with the system font size: at 1.3 "Betreff"
|
||||||
// wrapped into "Betref" / "f" in a fixed 56 dp column.
|
// 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 fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]);
|
||||||
const subjectRef = React.useRef<TextInput>(null);
|
const subjectRef = React.useRef<TextInput>(null);
|
||||||
const plainBodyRef = 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) =>
|
const inputFor = (field: Field | null) =>
|
||||||
field === 'to' ? toInput : field === 'cc' ? ccInput : field === 'bcc' ? bccInput : '';
|
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(
|
const alreadySelected = React.useMemo(
|
||||||
() => new Set(
|
() => new Set(
|
||||||
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
||||||
@@ -869,7 +768,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
const pickSuggestion = (s: RecipientSuggestion) => {
|
const pickSuggestion = (s: RecipientSuggestion) => {
|
||||||
isPickingSuggestion.current = true;
|
isPickingSuggestion.current = true;
|
||||||
const field = activeField ?? 'to';
|
const field = suggestField ?? 'to';
|
||||||
let recipient: Recipient;
|
let recipient: Recipient;
|
||||||
if (s.group) {
|
if (s.group) {
|
||||||
const members = getGroupRecipients(s.group.id)
|
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')
|
: 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 renderRecipientField = (field: Field, label: string, placeholder: string, zIndex: number) => {
|
||||||
const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients;
|
const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients;
|
||||||
const input = inputFor(field);
|
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;
|
const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field;
|
||||||
return (
|
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>
|
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text>
|
||||||
<View style={styles.recipientField}>
|
<View style={styles.recipientField}>
|
||||||
{list.map((r, i) => (
|
{list.map((r, i) => (
|
||||||
@@ -2796,18 +2750,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={input}
|
value={input}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
// A separator ends the entry: commit immediately so a pasted
|
changeFieldInput(field, text);
|
||||||
// list turns into chips as it lands.
|
measureFieldRow(field);
|
||||||
setInput(text);
|
}}
|
||||||
if (/[,;\n]$/.test(text) && text.trim().length > 1) {
|
onFocus={() => {
|
||||||
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected));
|
setActiveField(field);
|
||||||
if (valid.length) {
|
// A phone in landscape: no room for suggestions above the
|
||||||
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]);
|
// keyboard, so the full-screen picker takes the typing (U-069).
|
||||||
setInput(invalid.join(' '));
|
if (needsRecipientPicker({ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null })) {
|
||||||
}
|
openRecipientPicker(field);
|
||||||
|
} else {
|
||||||
|
measureFieldRow(field);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => setActiveField(field)}
|
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!isPickingSuggestion.current) {
|
if (!isPickingSuggestion.current) {
|
||||||
@@ -2835,8 +2790,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
textContentType="none"
|
textContentType="none"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
|
{activeField === field && !pickerField && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
|
||||||
<SuggestionList
|
<RecipientSuggestionList
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
onPick={pickSuggestion}
|
onPick={pickSuggestion}
|
||||||
onPressIn={() => {
|
onPressIn={() => {
|
||||||
@@ -3566,6 +3521,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</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
|
<ComposerOptionsSheet
|
||||||
visible={optionsSheetOpen}
|
visible={optionsSheetOpen}
|
||||||
options={composerOptions}
|
options={composerOptions}
|
||||||
@@ -3693,19 +3671,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
paddingVertical: 4,
|
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: {
|
recipientInput: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
@@ -3714,42 +3679,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
recipientInputInvalid: { color: c.error },
|
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: {
|
ccToggle: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
|
|||||||
Reference in New Issue
Block a user