Feature: contact groups as single expandable recipient chips

Typing a contact group's name in a recipient field suggested the
individual members, and "send email to group" on the contacts page
filled the field with one chip per member - the group itself never
appeared anywhere.

The autocomplete now offers the group as a single entry (group icon
plus member count), and selecting it - like the contacts-page action -
inserts one chip named after the group that carries a snapshot of its
members. The chip expands into the deduplicated member addresses when
the message is sent or saved as a draft, mirroring how Outlook handles
distribution lists. Expansion happens where the outgoing address lists
are built, so validation and every plugin hook see real addresses.

Group chips survive the composer's string boundaries (draft data, dirty
compare, the contacts-page hand-off) as RFC 5322 group syntax
("Team: a@x, b@y;"). A bare colon reliably opens a group there because
display names containing a colon are always quoted. Typed text only
parses as a group when it carries at least one valid member, so stray
"Subject: hello" input stays a plain recipient.

RecipientSuggestion gains an optional group field; plugins that ignore
it keep working unchanged.
This commit is contained in:
dealerweb
2026-07-09 17:46:06 +02:00
committed by Linus Rath
parent fa31933922
commit c6bd5f645a
6 changed files with 252 additions and 50 deletions
+16 -8
View File
@@ -20,7 +20,7 @@ import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient } from "@/lib/email-composer-utils";
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -513,23 +513,31 @@ export default function ContactsPage() {
}, [router]);
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
// Format each member as "Name <email>" so the composer keeps the display
// name (round-trips via formatRecipient -> parseRecipientList). Dedupe by
// email, case-insensitively; members without an email are skipped.
// Hand the composer a single group chip (RFC 5322 group syntax survives
// the string hand-off) instead of one entry per member - the chip expands
// into the members when the message is sent. Dedupe by email,
// case-insensitively; members without an email are skipped.
const seen = new Set<string>();
const recipients: string[] = [];
const members: Array<{ name?: string; email: string }> = [];
for (const member of getGroupMembers(groupId)) {
const email = getContactPrimaryEmail(member).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
recipients.push(formatRecipient(getContactDisplayName(member), email));
const name = getContactDisplayName(member);
members.push({ name: name && name !== email ? name : undefined, email });
}
if (recipients.length === 0) {
if (members.length === 0) {
toast.error(t("groups.no_member_emails"));
return;
}
openComposeInApp(recipients, field);
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
const chip: Recipient = {
name: (group && getContactDisplayName(group)) || "Group",
email: "",
group: { members },
};
openComposeInApp([formatRecipientEntry(chip)], field);
}, [getGroupMembers, t, openComposeInApp]);
const handleComposeContact = useCallback((contact: ContactCard) => {
+76 -26
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
@@ -26,7 +26,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { useContactStore } from "@/stores/contact-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
@@ -44,6 +44,7 @@ import {
parseRecipient,
parseRecipientList,
formatRecipientList,
expandRecipients,
splitPastedRecipients,
waitForPendingUploads,
extractUserAuthoredText,
@@ -92,6 +93,10 @@ function createChipDragPreview(label: string): HTMLElement {
return preview;
}
// An autocomplete entry: a person, or a contact group (empty email) that
// inserts as a single chip and expands into its members on send.
type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
export interface ComposerDraftData {
to: string;
cc: string;
@@ -823,6 +828,7 @@ export function EmailComposer({
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const getGroupMembers = useContactStore((s) => s.getGroupMembers);
const searchRecipients = useContactStore((s) => s.searchRecipients);
// Whether a Sent mailbox is known so the on-demand server search is worth
// offering (falls back to hiding the "search the server" row otherwise).
@@ -899,7 +905,7 @@ export function EmailComposer({
}
}, [mode]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [autocompleteResults, setAutocompleteResults] = useState<Array<SuggestionItem>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
// Current trimmed query behind the open dropdown, plus the in-flight flag for
@@ -933,7 +939,9 @@ export function EmailComposer({
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
if (fromField === toField) return;
const setters = { to: setTo, cc: setCc, bcc: setBcc };
const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? '');
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
const sameRecipient = (a: Recipient, b: Recipient) =>
a.email === b.email && (a.name ?? '') === (b.name ?? '') && groupKey(a) === groupKey(b);
setters[fromField](prev => {
const idx = prev.findIndex(r => sameRecipient(r, recipient));
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
@@ -961,9 +969,9 @@ export function EmailComposer({
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(query);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email, group: r.group }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email, group: s.group })));
// Keep the dropdown open even without local hits when a server search is
// available, so the "search the server" row stays reachable (OWA-style).
setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
@@ -998,11 +1006,30 @@ export function EmailComposer({
}
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => {
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
setter(prev => [...prev, toRecipient(suggestion)]);
if (suggestion.group) {
// Insert the group as a single chip carrying a snapshot of its members
// (deduped, members without an address skipped). The chip is expanded
// into the members when the message is sent or saved as a draft.
const seen = new Set<string>();
const members: Array<{ name?: string; email: string }> = [];
for (const m of getGroupMembers(suggestion.group.id)) {
const email = getContactPrimaryEmail(m).trim();
const key = email.toLowerCase();
if (!email || seen.has(key)) continue;
seen.add(key);
const name = getContactDisplayName(m);
members.push({ name: name && name !== email ? name : undefined, email });
}
if (members.length > 0) {
setter(prev => [...prev, { name: suggestion.name, email: '', group: { members } }]);
}
} else {
setter(prev => [...prev, toRecipient(suggestion)]);
}
inputSetter('');
setAutocompleteResults([]);
setActiveAutoField(null);
@@ -1303,9 +1330,9 @@ export function EmailComposer({
const saveDraftOnce = async (): Promise<string | null> => {
if (!client || !composerClient) return null;
const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email));
const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email));
const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email));
const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email));
const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email));
const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email));
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
@@ -1474,7 +1501,9 @@ export function EmailComposer({
};
}, []);
const toAddresses = withInput(to, toInput);
// Groups expand here so validation and every outgoing payload see the
// actual member addresses.
const toAddresses = expandRecipients(withInput(to, toInput));
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
@@ -1603,8 +1632,8 @@ export function EmailComposer({
}
}
const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput);
const ccAddresses = expandRecipients(withInput(cc, ccInput));
const bccAddresses = expandRecipients(withInput(bcc, bccInput));
if (!canSend) {
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
@@ -2751,13 +2780,14 @@ export function EmailComposer({
const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
id: string;
results: Array<{ name: string; email: string }>;
results: Array<SuggestionItem>;
selectedIndex: number;
onSelect: (suggestion: { name: string; email: string }) => void;
onSelect: (suggestion: SuggestionItem) => void;
onSearchServer?: () => void;
isSearchingServer?: boolean;
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
const t = useTranslations('email_composer');
const tContacts = useTranslations('contacts');
return (
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
{results.map((r, i) => (
@@ -2776,9 +2806,19 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
onSelect(r);
}}
>
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
{r.group ? (
<span className="shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
</span>
) : (
<Avatar name={r.name} email={r.email} size="sm" className="shrink-0 w-6 h-6 text-[10px]" />
)}
<span className="font-medium truncate">{r.name || r.email}</span>
{r.name && (
{r.group ? (
<span className="text-muted-foreground truncate">
{tContacts('groups.member_count', { count: r.group.memberCount })}
</span>
) : r.name && (
<span className="text-muted-foreground truncate">&lt;{r.email}&gt;</span>
)}
</button>
@@ -2844,10 +2884,10 @@ function RecipientChipInput({
onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void;
onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void;
activeAutoField: 'to' | 'cc' | 'bcc' | null;
autocompleteResults: Array<{ name: string; email: string }>;
autocompleteResults: Array<SuggestionItem>;
autoSelectedIndex: number;
dropdownRef: React.RefObject<HTMLDivElement | null>;
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
onInsertAutocomplete: (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => void;
canSearchServer: boolean;
onServerSearch: () => void;
isSearchingServer: boolean;
@@ -2880,7 +2920,9 @@ function RecipientChipInput({
// Format a recipient for display in a chip / context menu
const formatChipDisplay = (r: Recipient): string =>
r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
r.group
? `${r.name || 'Group'} (${r.group.members.length})`
: r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
// Handle saving an edited chip
const handleSaveEdit = (newValue: string) => {
@@ -2900,10 +2942,10 @@ function RecipientChipInput({
setEditingChip(null);
return;
}
newChip = { name: chip.name, email: trimmedNew };
newChip = { ...chip, email: trimmedNew };
} else {
// Update name, keep email. Empty name clears the display name.
newChip = { name: trimmedNew || undefined, email: chip.email };
newChip = { ...chip, name: trimmedNew || undefined };
}
const newChips = [...chips];
@@ -3066,7 +3108,7 @@ function RecipientChipInput({
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
// Show the address while dragging, matching the email-list drag preview.
const dragPreview = createChipDragPreview(chip.email);
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
@@ -3109,7 +3151,13 @@ function RecipientChipInput({
data-bwignore="true"
/>
) : (
<span className="truncate max-w-[200px]">{chipDisplay}</span>
<span
className="inline-flex items-center gap-1 max-w-[200px]"
title={chip.group ? chip.group.members.map(m => m.email).join(', ') : undefined}
>
{chip.group && <Users className="w-3 h-3 shrink-0" aria-hidden />}
<span className="truncate">{chipDisplay}</span>
</span>
)}
<button
type="button"
@@ -3185,7 +3233,9 @@ function RecipientChipInput({
{formatChipDisplay(contextMenu.data.recipient)}
</div>
<ContextMenuSeparator />
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
{!contextMenu.data.recipient.group && (
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
)}
<ContextMenuItem label={t('recipient_edit_name')} onClick={handleEditName} />
<ContextMenuSeparator />
<ContextMenuItem label={tCommon('delete')} onClick={() => {
+56 -1
View File
@@ -12,7 +12,9 @@ import {
splitPastedRecipients,
waitForPendingUploads,
extractUserAuthoredText,
} from "../email-composer-utils";
formatRecipientEntry,
expandRecipients,
} from '../email-composer-utils';
const FORWARDED_SEPARATOR = "---------- Forwarded message ----------";
@@ -422,3 +424,56 @@ describe("waitForPendingUploads", () => {
expect(result).toBe("cancelled");
});
});
describe('contact group recipients (RFC 5322 group syntax)', () => {
const group = {
name: 'Vertrieb',
email: '',
group: { members: [
{ name: 'Anna Alt', email: 'anna@example.com' },
{ email: 'bob@example.com' },
] },
};
it('formats a group chip as RFC 5322 group syntax', () => {
expect(formatRecipientEntry(group)).toBe('Vertrieb: Anna Alt <anna@example.com>, bob@example.com;');
});
it('round-trips a group through format -> parse', () => {
const parsed = parseRecipientList(formatRecipientList([group, { email: 'solo@example.com' }]));
expect(parsed).toHaveLength(2);
expect(parsed[0].group?.members).toEqual([
{ name: 'Anna Alt', email: 'anna@example.com' },
{ email: 'bob@example.com' },
]);
expect(parsed[0].name).toBe('Vertrieb');
expect(parsed[0].email).toBe('');
expect(parsed[1]).toEqual({ email: 'solo@example.com' });
});
it('quotes group names containing specials and round-trips them', () => {
const tricky = { name: 'Sales, EMEA', email: '', group: { members: [{ email: 'a@x.de' }] } };
const parsed = parseRecipientList(formatRecipientList([tricky]));
expect(parsed[0].name).toBe('Sales, EMEA');
expect(parsed[0].group?.members).toEqual([{ email: 'a@x.de' }]);
});
it('keeps commas inside a group while splitting a mixed list', () => {
const parsed = parseRecipientList('first@x.de, Team: a@x.de, b@x.de;, last@x.de');
expect(parsed.map(r => r.email || r.name)).toEqual(['first@x.de', 'Team', 'last@x.de']);
expect(parsed[1].group?.members).toHaveLength(2);
});
it('expandRecipients flattens groups and dedupes against individuals', () => {
const expanded = expandRecipients([
{ name: 'Anna Alt', email: 'ANNA@example.com' },
group,
{ email: 'bob@example.com' },
]);
expect(expanded.map(r => r.email)).toEqual(['ANNA@example.com', 'bob@example.com']);
});
it('leaves plain recipients untouched by expansion', () => {
expect(expandRecipients([{ name: 'X', email: 'x@y.z' }])).toEqual([{ name: 'X', email: 'x@y.z' }]);
});
});
+90 -4
View File
@@ -109,8 +109,17 @@ export function extractUserAuthoredText(
return text;
}
/** A composer recipient. Display name is optional; email is required. */
export type Recipient = { name?: string; email: string };
/**
* A composer recipient. Display name is optional; email is required - except
* for contact-group chips, which carry their already-resolved members and an
* empty email. Group chips are expanded into their members when the message
* is sent or saved as a draft (see {@link expandRecipients}).
*/
export type Recipient = {
name?: string;
email: string;
group?: { members: Array<{ name?: string; email: string }> };
};
/**
* Splits a recipient string into individual entries on any character in
@@ -127,6 +136,7 @@ export function splitRecipients(value: string, separators = ','): string[] {
let current = '';
let inQuotes = false;
let inAngle = false;
let inGroup = false;
for (const ch of value) {
if (ch === '"') {
inQuotes = !inQuotes;
@@ -137,7 +147,17 @@ export function splitRecipients(value: string, separators = ','): string[] {
} else if (ch === '>' && !inQuotes) {
inAngle = false;
current += ch;
} else if (separators.includes(ch) && !inQuotes && !inAngle) {
} else if (ch === ':' && !inQuotes && !inAngle) {
// RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group,
// separators inside it included, as a single entry. A colon inside a
// display name is always quoted (see NAME_NEEDS_QUOTING), so a bare
// colon reliably opens a group.
inGroup = true;
current += ch;
} else if (ch === ';' && inGroup && !inQuotes && !inAngle) {
inGroup = false;
current += ch;
} else if (separators.includes(ch) && !inQuotes && !inAngle && !inGroup) {
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
current = '';
@@ -177,12 +197,41 @@ function unquoteName(name: string): string {
return trimmed;
}
/** Index of the first colon outside quotes/angle brackets, or -1. */
function findTopLevelColon(value: string): number {
let inQuotes = false;
let inAngle = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (ch === '"') inQuotes = !inQuotes;
else if (ch === '<' && !inQuotes) inAngle = true;
else if (ch === '>' && !inQuotes) inAngle = false;
else if (ch === ':' && !inQuotes && !inAngle) return i;
}
return -1;
}
/**
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
* RFC 5322 group syntax (`Team: a@x, b@y;`) parses into a group chip - it is
* how contact groups round-trip through the composer's string boundaries.
*/
export function parseRecipient(s: string): Recipient {
const trimmed = s.trim();
if (trimmed.endsWith(';')) {
const colon = findTopLevelColon(trimmed);
if (colon !== -1) {
const members = splitRecipients(trimmed.slice(colon + 1, -1))
.map(parseRecipient)
.filter((m) => m.email && !m.group);
// Only accept the group form when it actually carries members - typed
// garbage like "Subject: hello;" stays a plain (invalid) recipient.
if (members.length > 0) {
return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } };
}
}
}
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
if (angleMatch) {
return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() };
@@ -195,9 +244,46 @@ export function parseRecipientList(value: string): Recipient[] {
return splitRecipients(value).map(parseRecipient);
}
/**
* Formats a single composer recipient, using RFC 5322 group syntax for
* contact-group chips so they survive the composer's string boundaries
* (draft data, dirty compare, the contacts-page hand-off).
*/
export function formatRecipientEntry(r: Recipient): string {
if (r.group) {
const name = r.name?.trim() || 'Group';
const quoted = NAME_NEEDS_QUOTING.test(name)
? `"${name.replace(/(["\\])/g, '\\$1')}"`
: name;
const members = r.group.members.map((m) => formatRecipient(m.name, m.email)).join(', ');
return `${quoted}: ${members};`;
}
return formatRecipient(r.name, r.email);
}
/** Serializes a recipient array into a comma-separated string. */
export function formatRecipientList(recipients: Recipient[]): string {
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
return recipients.map(formatRecipientEntry).join(', ');
}
/**
* Expands contact-group chips into their members for sending and
* draft-saving. Deduplicates case-insensitively by address across the whole
* list, keeping the first occurrence - an explicitly added individual wins
* over the same address arriving again via a group.
*/
export function expandRecipients(recipients: Recipient[]): Recipient[] {
const seen = new Set<string>();
const out: Recipient[] = [];
for (const r of recipients) {
for (const entry of r.group ? r.group.members : [r]) {
const key = entry.email.trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push({ name: entry.name, email: entry.email });
}
}
return out;
}
/**
+5
View File
@@ -835,6 +835,11 @@ export interface RecipientSuggestion {
/** Optional source label rendered as a small tag */
source?: string;
avatarUrl?: string;
/**
* Present when the suggestion is a contact group (empty email). Selecting
* it inserts a single group chip that expands into the members on send.
*/
group?: { id: string; memberCount: number };
}
/**
+9 -11
View File
@@ -188,7 +188,7 @@ interface ContactStore {
setActiveTab: (tab: 'all' | 'groups') => void;
clearContacts: () => void;
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
getAutocomplete: (query: string) => Array<{ name: string; email: string; group?: { id: string; memberCount: number } }>;
getGroups: () => ContactCard[];
getIndividuals: () => ContactCard[];
@@ -527,20 +527,18 @@ export const useContactStore = create<ContactStore>()(
if (!query || query.length < 1) return [];
const lower = query.toLowerCase();
const results: Array<{ name: string; email: string }> = [];
const results: Array<{ name: string; email: string; group?: { id: string; memberCount: number } }> = [];
for (const contact of contacts) {
if (contact.kind === 'group') {
// Suggest the group itself as a single entry (Outlook-style);
// the composer turns it into one chip carrying the members.
const groupName = getContactDisplayName(contact);
if (groupName.toLowerCase().includes(lower)) {
const members = get().getGroupMembers(contact.id);
for (const member of members) {
const memberName = getContactDisplayName(member);
const memberEmails = member.emails ? Object.values(member.emails) : [];
for (const emailEntry of memberEmails) {
if (!emailEntry.address) continue;
results.push({ name: memberName, email: emailEntry.address });
}
if (groupName && groupName.toLowerCase().includes(lower)) {
const memberCount = get().getGroupMembers(contact.id)
.filter(m => getContactPrimaryEmail(m).trim()).length;
if (memberCount > 0) {
results.push({ name: groupName, email: '', group: { id: contact.id, memberCount } });
}
}
continue;