feat: suggest contact groups and recent recipients when inviting people to an event

This commit is contained in:
Linus Rath
2026-10-31 04:38:20 +01:00
parent baa3ff5956
commit e988dc2928
3 changed files with 104 additions and 37 deletions
+28 -37
View File
@@ -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) => (
<Pressable
key={s.email}
key={s.group ? `group:${s.group.id}` : s.email}
style={({ pressed }) => [
styles.suggestionRow,
pressed && styles.suggestionRowPressed,
]}
onPress={() => addParticipant(s.email, s.name)}
onPress={() => pickSuggestion(s)}
>
{s.name && <Text style={styles.suggestionName}>{s.name}</Text>}
<Text style={styles.suggestionEmail}>{s.email}</Text>
{!!s.name && <Text style={styles.suggestionName}>{s.name}</Text>}
<Text style={styles.suggestionEmail}>
{s.group
? t('calendar.participants.group_members', '{count, plural, one {# member} other {# members}}', { count: s.group.memberCount })
: s.email}
</Text>
</Pressable>
))}
</ScrollView>
@@ -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<string, { name: string; email: string }[]>;
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' }]);
});
});
@@ -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<string>,
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<string>,
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;
});
}