mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: suggest contact groups and recent recipients when inviting people to an event
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user