diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx index ef234f87..2b03c95e 100644 --- a/src/components/calendar/ParticipantInput.tsx +++ b/src/components/calendar/ParticipantInput.tsx @@ -10,9 +10,10 @@ import { import { X } from '../icons'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useContactsStore } from '../../stores/contacts-store'; +import { useContactsStore, type RecipientSuggestion } from '../../stores/contacts-store'; import { useLocaleStore } from '../../stores/locale-store'; import type { Attendee } from '../../lib/calendar-participants'; +import { participantSuggestions, participantsFromSuggestion } from '../../lib/calendar-participant-suggestions'; interface ParticipantInputProps { attendees: Attendee[]; @@ -20,28 +21,10 @@ interface ParticipantInputProps { onRemove: (email: string) => void; } -interface Suggestion { - email: string; - name?: string; -} - function emailRegex(): RegExp { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/; } -function flattenContactEmails(): Suggestion[] { - const contacts = useContactsStore.getState().contacts; - const out: Suggestion[] = []; - for (const c of contacts) { - const name = c.name?.full || undefined; - if (!c.emails) continue; - for (const e of Object.values(c.emails)) { - if (e.address) out.push({ email: e.address, name }); - } - } - return out; -} - // Attendee rows only: the organizer participant is added by // buildParticipantMap on save (see lib/calendar-participants). export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInputProps) { @@ -49,25 +32,29 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const [draft, setDraft] = React.useState(''); - const [allSuggestions] = React.useState(() => flattenContactEmails()); + // Contacts, groups and recent Sent recipients, as the composer offers them. + const getAutocomplete = useContactsStore((s) => s.getAutocomplete); + const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients); + const contactsVersion = useContactsStore((s) => s.contacts); + const recentVersion = useContactsStore((s) => s.recentRecipients); const existingEmails = React.useMemo(() => { return new Set(attendees.map((a) => a.email.toLowerCase())); }, [attendees]); - const filteredSuggestions = React.useMemo(() => { - const q = draft.trim().toLowerCase(); - if (!q || q.length < 2) return []; - return allSuggestions - .filter((s) => { - if (existingEmails.has(s.email.toLowerCase())) return false; - return ( - s.email.toLowerCase().includes(q) || - s.name?.toLowerCase().includes(q) - ); - }) - .slice(0, 6); - }, [draft, allSuggestions, existingEmails]); + const filteredSuggestions = React.useMemo( + () => participantSuggestions(draft, existingEmails, getAutocomplete, getGroupRecipients), + // contactsVersion/recentVersion re-run the lookup when the store loads. + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft, existingEmails, getAutocomplete, getGroupRecipients, contactsVersion, recentVersion], + ); + + const pickSuggestion = (s: RecipientSuggestion) => { + for (const person of participantsFromSuggestion(s, existingEmails, getGroupRecipients)) { + if (emailRegex().test(person.email.trim())) onAdd({ name: person.name, email: person.email.trim() }); + } + setDraft(''); + }; const addParticipant = (email: string, name?: string) => { const trimmed = email.trim(); @@ -135,15 +122,19 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu > {filteredSuggestions.map((s) => ( [ styles.suggestionRow, pressed && styles.suggestionRowPressed, ]} - onPress={() => addParticipant(s.email, s.name)} + onPress={() => pickSuggestion(s)} > - {s.name && {s.name}} - {s.email} + {!!s.name && {s.name}} + + {s.group + ? t('calendar.participants.group_members', '{count, plural, one {# member} other {# members}}', { count: s.group.memberCount }) + : s.email} + ))} diff --git a/src/lib/__tests__/calendar-participant-suggestions.test.ts b/src/lib/__tests__/calendar-participant-suggestions.test.ts new file mode 100644 index 00000000..64482631 --- /dev/null +++ b/src/lib/__tests__/calendar-participant-suggestions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { participantSuggestions, participantsFromSuggestion } from '../calendar-participant-suggestions'; +import type { RecipientSuggestion } from '../../stores/contacts-store'; + +const all: RecipientSuggestion[] = [ + { name: 'Team Alpha', email: '', group: { id: 'g1', memberCount: 2 } }, + { name: 'Ana', email: 'ana@example.com' }, + { name: 'Recent Ben', email: 'ben@example.com' }, +]; +const getAutocomplete = (q: string) => all.filter((s) => (s.name + s.email).toLowerCase().includes(q.toLowerCase()) || q === 'a'); +const members = { g1: [{ name: 'Ana', email: 'ana@example.com' }, { name: 'Cem', email: 'cem@example.com' }] } as Record; +const getGroupRecipients = (id: string) => members[id] ?? []; + +describe('participantSuggestions', () => { + it('offers contacts, groups and recent recipients, not the people already invited', () => { + const shown = participantSuggestions('an', new Set(['ben@example.com']), () => all, getGroupRecipients); + expect(shown.map((s) => s.name)).toEqual(['Team Alpha', 'Ana']); + }); + + it('drops a group whose members are all invited, and waits for two letters', () => { + const shown = participantSuggestions('team', new Set(['ana@example.com', 'cem@example.com']), getAutocomplete, getGroupRecipients); + expect(shown).toEqual([]); + expect(participantSuggestions('t', new Set(), getAutocomplete, getGroupRecipients)).toEqual([]); + }); +}); + +describe('participantsFromSuggestion', () => { + it('invites the members of a picked group who are not invited yet', () => { + expect(participantsFromSuggestion(all[0], new Set(['ana@example.com']), getGroupRecipients)) + .toEqual([{ name: 'Cem', email: 'cem@example.com' }]); + expect(participantsFromSuggestion(all[2], new Set(), getGroupRecipients)) + .toEqual([{ name: 'Recent Ben', email: 'ben@example.com' }]); + }); +}); diff --git a/src/lib/calendar-participant-suggestions.ts b/src/lib/calendar-participant-suggestions.ts new file mode 100644 index 00000000..f0a67c63 --- /dev/null +++ b/src/lib/calendar-participant-suggestions.ts @@ -0,0 +1,42 @@ +import type { EmailAddress } from '../api/types'; +import type { RecipientSuggestion } from '../stores/contacts-store'; + +/** + * What the event's participant field suggests for `query`: the composer's + * autocomplete (contacts, contact groups and recent Sent recipients, webmail + * participant-input.tsx), without the people already invited and groups + * whose members all are. + */ +export function participantSuggestions( + query: string, + invited: ReadonlySet, + getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[], + getGroupRecipients: (groupId: string) => EmailAddress[], + limit = 6, +): RecipientSuggestion[] { + const q = query.trim(); + if (q.length < 2) return []; + return getAutocomplete(q, 16) + .filter((s) => (s.group + ? getGroupRecipients(s.group.id).some((m) => !invited.has(m.email.toLowerCase())) + : !invited.has(s.email.toLowerCase()))) + .slice(0, limit); +} + +/** The people a picked suggestion invites: the person, or the group's members not yet invited. */ +export function participantsFromSuggestion( + suggestion: RecipientSuggestion, + invited: ReadonlySet, + getGroupRecipients: (groupId: string) => EmailAddress[], +): { name: string; email: string }[] { + const people = suggestion.group + ? getGroupRecipients(suggestion.group.id).map((m) => ({ name: m.name ?? '', email: m.email })) + : [{ name: suggestion.name, email: suggestion.email }]; + const seen = new Set(invited); + return people.filter((p) => { + const key = p.email.trim().toLowerCase(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +}