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;
+ });
+}