From 288205fbac4f385dab0d5458b18a06dc64cb614e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:23:57 +0100 Subject: [PATCH] feat: add recipient popover component for enhanced contact interaction --- components/email/email-viewer.tsx | 297 +++++++++++++++++++++++-- components/email/recipient-popover.tsx | 221 ++++++++++++++++++ 2 files changed, 501 insertions(+), 17 deletions(-) create mode 100644 components/email/recipient-popover.tsx diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 10920123..3ea28d0b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; -import { Email } from "@/lib/jmap/types"; +import { Email, ContactCard } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; @@ -48,10 +48,18 @@ import { Brain, Sparkles, Keyboard, + Phone, + Building, + MapPin, + StickyNote, + PanelRightClose, + Send, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; +import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { toast } from "@/stores/toast-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; @@ -60,6 +68,7 @@ import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { findCalendarAttachment } from "@/lib/calendar-invitation"; +import { RecipientPopover } from "./recipient-popover"; interface EmailViewerProps { email: Email | null; @@ -158,6 +167,227 @@ const formatRecipients = ( return displayRecipients.join(', '); }; +// Helper to render clickable recipient elements with popovers +function renderClickableRecipients( + recipients: Array<{ name?: string; email: string }>, + currentUserEmail: string | undefined, + t: (key: string, params?: Record) => string, + onViewContact?: (contact: ContactCard | null, email: string) => void, + maxVisible: number = 2 +) { + const visible = recipients.slice(0, maxVisible); + return visible.map((r, index) => { + const isMe = currentUserEmail && + (r.email.toLowerCase() === currentUserEmail.toLowerCase() || + r.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+')); + + return ( + + {index > 0 && ,} + + + ); + }); +} + +// Contact sidebar panel that slides in from the right on desktop +function ContactSidebarPanel({ + email, + contact, + onClose, +}: { + email: string; + contact: ContactCard | null; + onClose: () => void; +}) { + const name = contact ? getContactDisplayName(contact) : null; + const primaryEmail = contact ? getContactPrimaryEmail(contact) : email; + const emails = contact?.emails ? Object.values(contact.emails) : []; + const phones = contact?.phones ? Object.values(contact.phones) : []; + const orgs = contact?.organizations ? Object.values(contact.organizations) : []; + const addresses = contact?.addresses ? Object.values(contact.addresses) : []; + const notes = contact?.notes ? Object.values(contact.notes) : []; + + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success("Copied!"); + } catch { + toast.error("Failed to copy"); + } + }; + + return ( +
+ {/* Header */} +
+

Contact

+ +
+ + {/* Content */} +
+ {/* Profile section */} +
+ +
+
+ {name || email} +
+ {name && ( +
+ {primaryEmail} +
+ )} + {orgs.length > 0 && orgs[0].name && ( +
+ + {orgs[0].name} +
+ )} +
+
+ + {/* Quick actions */} +
+ + + Email + + +
+ + {/* Details sections */} + {contact && ( +
+ {/* Emails */} + {emails.length > 0 && ( + + {emails.map((e, i) => ( +
+ + {e.address} + + +
+ ))} +
+ )} + + {/* Phones */} + {phones.length > 0 && ( + + {phones.map((p, i) => ( +
+ + {p.number} + + +
+ ))} +
+ )} + + {/* Organizations */} + {orgs.length > 1 && ( + + {orgs.map((o, i) => ( +
+ {o.name} + {o.units && o.units.length > 0 && ( + — {o.units.map(u => u.name).join(", ")} + )} +
+ ))} +
+ )} + + {/* Addresses */} + {addresses.length > 0 && ( + + {addresses.map((a, i) => ( +
+ {[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} +
+ ))} +
+ )} + + {/* Notes */} + {notes.length > 0 && ( + + {notes.map((n, i) => ( +

{n.note}

+ ))} +
+ )} +
+ )} + + {/* No contact found message */} + {!contact && ( +
+

+ Not in your contacts +

+
+ )} +
+
+ ); +} + +function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { + return ( +
+
+ +

{title}

+
+
{children}
+
+ ); +} + export function EmailViewer({ email, isLoading = false, @@ -211,6 +441,31 @@ export function EmailViewer({ const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); const [showSourceModal, setShowSourceModal] = useState(false); const currentColor = getCurrentColor(email?.keywords); + + // Contact sidebar state + const [contactSidebarEmail, setContactSidebarEmail] = useState(null); + const contacts = useContactStore((s) => s.contacts); + const { isMobile: isMobileDevice } = useDeviceDetection(); + + const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { + if (isMobileDevice) return; // no sidebar on mobile + setContactSidebarEmail(recipientEmail); + }; + + const sidebarContact = contactSidebarEmail + ? contacts.find((c) => { + if (!c.emails) return false; + return Object.values(c.emails).some( + (e) => e.address.toLowerCase() === contactSidebarEmail.toLowerCase() + ); + }) ?? null + : null; + + // Close contact sidebar when email changes + useEffect(() => { + setContactSidebarEmail(null); + }, [email?.id]); + const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState>( () => { if (typeof window === 'undefined') return new Set(); @@ -631,8 +886,10 @@ export function EmailViewer({ return (
+ {/* Main email content */} +
{/* Loading overlay when fetching new email */} {isLoading && (
@@ -953,13 +1210,11 @@ export function EmailViewer({ {email.to && email.to.length > 0 && (
{t('recipient_to_prefix')} - - {formatRecipients(email.to, currentUserEmail, t)} - + {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} {email.to.length > 2 && ( @@ -970,10 +1225,10 @@ export function EmailViewer({ {email.cc && email.cc.length > 0 && (
CC: - - {email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")} - {email.cc.length > 2 && ` +${email.cc.length - 2}`} - + {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} + {email.cc.length > 2 && ( + +{email.cc.length - 2} + )}
)}
@@ -1311,9 +1566,7 @@ export function EmailViewer({ {email.to && email.to.length > 0 && ( <> → {t('recipient_to_prefix')} - - {formatRecipients(email.to, currentUserEmail, t)} - + {renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)} )}
@@ -1321,10 +1574,10 @@ export function EmailViewer({ {email.cc && email.cc.length > 0 && (
CC: - - {email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")} - {email.cc.length > 2 && ` +${email.cc.length - 2}`} - + {renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)} + {email.cc.length > 2 && ( + +{email.cc.length - 2} + )}
)}
@@ -1651,5 +1904,15 @@ export function EmailViewer({
)} + + {/* Contact Detail Sidebar - desktop only */} + {contactSidebarEmail && !isMobileDevice && ( + setContactSidebarEmail(null)} + /> + )} + ); } \ No newline at end of file diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx new file mode 100644 index 00000000..1a32a0d2 --- /dev/null +++ b/components/email/recipient-popover.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react"; +import { Avatar } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; +import { toast } from "@/stores/toast-store"; +import type { ContactCard } from "@/lib/jmap/types"; + +interface RecipientPopoverProps { + name?: string; + email: string; + /** Display label override (e.g. "me") */ + displayLabel?: string; + /** Called when user clicks "View contact" — receives the contact and email */ + onViewContact?: (contact: ContactCard | null, email: string) => void; + className?: string; +} + +export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) { + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const contacts = useContactStore((s) => s.contacts); + + // Find matching contact by email + const contact = contacts.find((c) => { + if (!c.emails) return false; + return Object.values(c.emails).some( + (e) => e.address.toLowerCase() === email.toLowerCase() + ); + }); + + const contactName = contact ? getContactDisplayName(contact) : name; + const emails = contact?.emails ? Object.values(contact.emails) : []; + const phones = contact?.phones ? Object.values(contact.phones) : []; + const orgs = contact?.organizations ? Object.values(contact.organizations) : []; + + const handleOpen = () => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const popoverWidth = 300; + const popoverHeight = 250; + + let top = rect.bottom + 4; + let left = rect.left; + + // Keep within viewport + if (left + popoverWidth > window.innerWidth - 8) { + left = window.innerWidth - popoverWidth - 8; + } + if (left < 8) left = 8; + if (top + popoverHeight > window.innerHeight - 8) { + top = rect.top - popoverHeight - 4; + } + + setPosition({ top, left }); + setIsOpen(true); + }; + + const handleClose = () => { + setIsOpen(false); + setPosition(null); + }; + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + const handler = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + handleClose(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [isOpen]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") handleClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [isOpen]); + + const handleViewContact = () => { + if (onViewContact) { + onViewContact(contact ?? null, email); + } + handleClose(); + }; + + const handleCopyEmail = async (addr: string) => { + try { + await navigator.clipboard.writeText(addr); + toast.success("Copied!"); + } catch { + toast.error("Failed to copy"); + } + }; + + return ( + <> + + + {isOpen && + position && + createPortal( +
+ {/* Header with avatar and name */} +
+ +
+
+ {contactName || email} +
+ {contactName && contactName !== email && ( +
+ {email} +
+ )} + {orgs.length > 0 && orgs[0].name && ( +
+ + {orgs[0].name} +
+ )} +
+
+ + {/* Contact details */} +
+ {/* Show additional emails if contact has them */} + {emails.length > 1 && ( +
+ {emails.slice(1).map((e, i) => ( +
+ + {e.address} +
+ ))} +
+ )} + + {/* Phone numbers */} + {phones.length > 0 && ( +
+ {phones.map((p, i) => ( + + ))} +
+ )} +
+ + {/* Actions */} +
+ + + + Email + + {onViewContact && ( + + )} +
+
, + document.body + )} + + ); +}