Adds a "Send email to group" action (To / Cc / Bcc) that opens the composer
pre-filled with the group's members in the chosen field, preserving each
member's display name. It is available both in the group context menu (between
"Edit Group" and "Delete") and in the group detail panel's header (shown when
the group has at least one member with an email). The single-contact "Send
email" button in the contact detail panel uses the same path.
Routing is internal, not via mailto:. Contacts is its own route and the composer
lives in the mail route, so the handoff stashes the recipients
(savePendingMailto) and does a client-side router.push("/"); the main route's
existing consumePendingMailto effect opens the composer in the current account.
This avoids the OS mailto handler (which could open a different mail app) and
the protocol round-trip's full-page reload, which dropped the in-memory
per-account JMAP clients of a multi-account session (a logout).
- contacts/page.tsx: openComposeInApp(recipients, field) shared helper;
handleComposeGroupFromSidebar (deduped "Name <email>" members, empty -> toast)
and handleComposeContact; wired to the sidebar, group detail, contact detail.
- contact-group-detail.tsx: onComposeGroup(field) prop + To/Cc/Bcc header control
(shown when the group has emailable members).
- contacts-sidebar.tsx: onComposeGroup(groupId, field) prop + "Send email to
group" submenu between Edit and Delete.
- contact-detail.tsx: onCompose() prop; the button is no longer a mailto: link.
- mailto.ts: recipient splitter is quote-aware (reuses the composer's
splitRecipients) so a comma in a display name survives — still useful for real
OS mailto: links.
- i18n: contacts.groups.send_email{,_to,_cc,_bcc} and no_member_emails across all
locales.
Display names round-trip via formatRecipient -> parseRecipientList.
140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useTranslations } from "next-intl";
|
|
import { Users, Pencil, Trash2, UserMinus, Mail } from "lucide-react";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import type { ContactCard } from "@/lib/jmap/types";
|
|
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
|
|
|
interface ContactGroupDetailProps {
|
|
group: ContactCard;
|
|
members: ContactCard[];
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
onRemoveMember: (memberId: string) => void;
|
|
onSelectMember: (id: string) => void;
|
|
/** Compose an email to every member with the recipients placed in `field`. */
|
|
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
|
|
isMobile?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function ContactGroupDetail({
|
|
group,
|
|
members,
|
|
onEdit,
|
|
onDelete,
|
|
onRemoveMember,
|
|
onSelectMember,
|
|
onComposeGroup,
|
|
isMobile,
|
|
className,
|
|
}: ContactGroupDetailProps) {
|
|
const t = useTranslations("contacts");
|
|
const groupName = getContactDisplayName(group);
|
|
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
|
|
|
return (
|
|
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
|
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
|
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
|
<div className="flex items-center gap-4">
|
|
<div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
|
|
<Users className={cn("text-primary", isMobile ? "w-6 h-6" : "w-7 h-7")} />
|
|
</div>
|
|
<div>
|
|
<h2 className={cn("font-semibold", isMobile ? "text-lg" : "text-xl")}>{groupName}</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("groups.member_count", { count: members.length })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
|
<Pencil className="w-4 h-4 mr-1" />
|
|
{t("form.edit_title")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onDelete}
|
|
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{onComposeGroup && hasEmailMembers && (
|
|
<div className="flex flex-wrap items-center gap-2 mt-4">
|
|
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
|
|
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
|
|
<div className="inline-flex gap-1">
|
|
{(["to", "cc", "bcc"] as const).map((field) => (
|
|
<Button
|
|
key={field}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onComposeGroup(field)}
|
|
className="touch-manipulation"
|
|
>
|
|
{t(`groups.send_email_${field}`)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-6 py-4">
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
|
{t("groups.members_label")}
|
|
</h3>
|
|
{members.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
|
{t("groups.no_members")}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{members.map((member) => {
|
|
const mName = getContactDisplayName(member);
|
|
const mEmail = getContactPrimaryEmail(member);
|
|
return (
|
|
<div
|
|
key={member.id}
|
|
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
|
>
|
|
<button
|
|
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
|
onClick={() => onSelectMember(member.id)}
|
|
>
|
|
<Avatar name={mName} email={mEmail} size="sm" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{mName}</div>
|
|
{mEmail && (
|
|
<div className="text-xs text-muted-foreground truncate">{mEmail}</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"h-8 w-8 transition-opacity",
|
|
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
)}
|
|
onClick={() => onRemoveMember(member.id)}
|
|
>
|
|
<UserMinus className="w-4 h-4 text-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|