fix: add a typed participant once and show suggestions above the keyboard

This commit is contained in:
Linus Rath
2026-10-31 13:17:18 +01:00
parent 7139390a85
commit 9ec58be4fb
4 changed files with 86 additions and 25 deletions
+2 -1
View File
@@ -67,6 +67,7 @@ import {
participantLimitMessage,
seedAttendees,
userOrganizes,
withAttendee,
type Attendee,
} from '../../lib/calendar-participants';
import type { SeriesEdit } from '../../lib/recurrence-scope-edit';
@@ -880,7 +881,7 @@ export function EventModal({
>
<ParticipantInput
attendees={attendees}
onAdd={(a) => setAttendees((prev) => [...prev, a])}
onAdd={(a) => setAttendees((prev) => withAttendee(prev, a))}
onRemove={(email) =>
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
}
+34 -24
View File
@@ -31,7 +31,14 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [draft, setDraft] = React.useState('');
const [draft, setDraftState] = React.useState('');
// Done submits and then blurs the field; both handlers run before a
// re-render, so they read the text from here, not from `draft`.
const draftRef = React.useRef('');
const setDraft = React.useCallback((value: string) => {
draftRef.current = value;
setDraftState(value);
}, []);
// Contacts, groups and recent Sent recipients, as the composer offers them.
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
@@ -68,7 +75,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
};
const handleSubmit = () => {
if (draft.trim()) addParticipant(draft);
const typed = draftRef.current;
if (typed.trim()) addParticipant(typed);
};
return (
@@ -93,28 +101,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
</View>
)}
<TextInput
accessibilityLabel={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={draft}
onChangeText={setDraft}
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
// The app's own suggestions only: system autofill would cover them.
autoComplete="off"
importantForAutofill="no"
textContentType="none"
onSubmitEditing={handleSubmit}
onBlur={handleSubmit}
returnKeyType="done"
style={styles.input}
/>
{/* Above the field, not below it: below, the keyboard covered the
list (the editor keeps only the focused field in view). */}
{filteredSuggestions.length > 0 && (
<ScrollView
style={styles.suggestions}
@@ -140,6 +128,28 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
))}
</ScrollView>
)}
<TextInput
accessibilityLabel={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={draft}
onChangeText={setDraft}
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
// The app's own suggestions only: system autofill would cover them.
autoComplete="off"
importantForAutofill="no"
textContentType="none"
onSubmitEditing={handleSubmit}
onBlur={handleSubmit}
returnKeyType="done"
style={styles.input}
/>
</View>
);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { withAttendee, type Attendee } from '../../../lib/calendar-participants';
const input = fs.readFileSync(path.resolve(__dirname, '../ParticipantInput.tsx'), 'utf8');
const editor = fs.readFileSync(path.resolve(__dirname, '../EventModal.tsx'), 'utf8');
describe('adding a participant with Done', () => {
it('invites a typed address once although submit and blur both add it', () => {
// Done fires onSubmitEditing, then onBlur, before the editor re-renders:
// two updaters run on the same queue.
const typed: Attendee = { name: '', email: 'userc@example.org' };
let attendees: Attendee[] = [];
for (const updater of [(prev: Attendee[]) => withAttendee(prev, typed), (prev: Attendee[]) => withAttendee(prev, typed)]) {
attendees = updater(attendees);
}
expect(attendees).toEqual([typed]);
});
it('treats an address in other case or with spaces as the same person', () => {
const list = [{ name: 'C', email: 'userc@example.org' }];
expect(withAttendee(list, { name: '', email: ' UserC@Example.org ' })).toBe(list);
expect(withAttendee(list, { name: '', email: 'userb@example.org' })).toHaveLength(2);
});
it('adds through the dedupe in the editor and reads the typed text from a ref', () => {
expect(editor).toMatch(/onAdd=\{\(a\) => setAttendees\(\(prev\) => withAttendee\(prev, a\)\)\}/);
const submit = input.slice(input.indexOf('const handleSubmit'), input.indexOf('};', input.indexOf('const handleSubmit')));
expect(submit).toMatch(/draftRef\.current/);
});
it('shows the suggestions above the field, where the keyboard does not cover them', () => {
const jsx = input.slice(input.indexOf('return ('));
expect(jsx.indexOf('filteredSuggestions.length > 0')).toBeGreaterThan(-1);
expect(jsx.indexOf('filteredSuggestions.length > 0')).toBeLessThan(jsx.indexOf('<TextInput'));
});
});
+12
View File
@@ -269,6 +269,18 @@ export interface Attendee {
email: string;
}
/**
* `attendees` with `added` at the end, unless that address is invited
* already. Done/Enter on Android submits and then blurs the field, and both
* add what was typed: checking against the latest list (a state updater's
* `prev`) keeps the second add from inviting the same person twice.
*/
export function withAttendee(attendees: Attendee[], added: Attendee): Attendee[] {
const key = added.email.trim().toLowerCase();
if (!key || attendees.some((a) => a.email.trim().toLowerCase() === key)) return attendees;
return [...attendees, added];
}
type ParticipationStatus = Participant['participationStatus'];
/**