diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 169c5698..316596bc 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -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({ > setAttendees((prev) => [...prev, a])} + onAdd={(a) => setAttendees((prev) => withAttendee(prev, a))} onRemove={(email) => setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase())) } diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx index 80e43ff6..6cfe8f5e 100644 --- a/src/components/calendar/ParticipantInput.tsx +++ b/src/components/calendar/ParticipantInput.tsx @@ -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 )} - - + {/* Above the field, not below it: below, the keyboard covered the + list (the editor keeps only the focused field in view). */} {filteredSuggestions.length > 0 && ( )} + + ); } diff --git a/src/components/calendar/__tests__/participant-double-add.test.ts b/src/components/calendar/__tests__/participant-double-add.test.ts new file mode 100644 index 00000000..7bc970ee --- /dev/null +++ b/src/components/calendar/__tests__/participant-double-add.test.ts @@ -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(' a.email.trim().toLowerCase() === key)) return attendees; + return [...attendees, added]; +} + type ParticipationStatus = Participant['participationStatus']; /**