feat: add recipient popover component for enhanced contact interaction
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import DOMPurify from "dompurify";
|
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 { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -48,10 +48,18 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
Phone,
|
||||||
|
Building,
|
||||||
|
MapPin,
|
||||||
|
StickyNote,
|
||||||
|
PanelRightClose,
|
||||||
|
Send,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-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 { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
@@ -60,6 +68,7 @@ import { EmailIdentityBadge } from "./email-identity-badge";
|
|||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||||
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -158,6 +167,227 @@ const formatRecipients = (
|
|||||||
return displayRecipients.join(', ');
|
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, string | number>) => 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 (
|
||||||
|
<span key={r.email + index} className="inline-flex items-center">
|
||||||
|
{index > 0 && <span className="text-muted-foreground mr-1">,</span>}
|
||||||
|
<RecipientPopover
|
||||||
|
name={r.name}
|
||||||
|
email={r.email}
|
||||||
|
displayLabel={isMe ? t('recipient_me') : undefined}
|
||||||
|
onViewContact={onViewContact}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="w-[320px] shrink-0 border-l border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground truncate">Contact</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
aria-label="Close sidebar"
|
||||||
|
>
|
||||||
|
<PanelRightClose className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Profile section */}
|
||||||
|
<div className="px-4 pt-5 pb-4 flex flex-col items-center text-center">
|
||||||
|
<Avatar
|
||||||
|
name={name || email}
|
||||||
|
email={primaryEmail}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 min-w-0 w-full">
|
||||||
|
<div className="font-semibold text-base truncate">
|
||||||
|
{name || email}
|
||||||
|
</div>
|
||||||
|
{name && (
|
||||||
|
<div className="text-sm text-muted-foreground truncate mt-0.5">
|
||||||
|
{primaryEmail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orgs.length > 0 && orgs[0].name && (
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center justify-center gap-1 mt-1">
|
||||||
|
<Building className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{orgs[0].name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="px-4 pb-4 flex items-center justify-center gap-2">
|
||||||
|
<a
|
||||||
|
href={`mailto:${primaryEmail}`}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||||
|
title="Send email"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(primaryEmail)}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||||
|
title="Copy email"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details sections */}
|
||||||
|
{contact && (
|
||||||
|
<div className="px-4 pb-4 space-y-4">
|
||||||
|
{/* Emails */}
|
||||||
|
{emails.length > 0 && (
|
||||||
|
<SidebarSection icon={Mail} title="Emails">
|
||||||
|
{emails.map((e, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 group">
|
||||||
|
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline truncate">
|
||||||
|
{e.address}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(e.address)}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phones */}
|
||||||
|
{phones.length > 0 && (
|
||||||
|
<SidebarSection icon={Phone} title="Phones">
|
||||||
|
{phones.map((p, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 group">
|
||||||
|
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||||
|
{p.number}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(p.number)}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Organizations */}
|
||||||
|
{orgs.length > 1 && (
|
||||||
|
<SidebarSection icon={Building} title="Organizations">
|
||||||
|
{orgs.map((o, i) => (
|
||||||
|
<div key={i} className="text-sm">
|
||||||
|
{o.name}
|
||||||
|
{o.units && o.units.length > 0 && (
|
||||||
|
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Addresses */}
|
||||||
|
{addresses.length > 0 && (
|
||||||
|
<SidebarSection icon={MapPin} title="Addresses">
|
||||||
|
{addresses.map((a, i) => (
|
||||||
|
<div key={i} className="text-sm text-muted-foreground">
|
||||||
|
{[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<SidebarSection icon={StickyNote} title="Notes">
|
||||||
|
{notes.map((n, i) => (
|
||||||
|
<p key={i} className="text-sm text-muted-foreground whitespace-pre-wrap">{n.note}</p>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No contact found message */}
|
||||||
|
{!contact && (
|
||||||
|
<div className="px-4 pb-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Not in your contacts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{title}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pl-5.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmailViewer({
|
export function EmailViewer({
|
||||||
email,
|
email,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -211,6 +441,31 @@ export function EmailViewer({
|
|||||||
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
||||||
const [showSourceModal, setShowSourceModal] = useState(false);
|
const [showSourceModal, setShowSourceModal] = useState(false);
|
||||||
const currentColor = getCurrentColor(email?.keywords);
|
const currentColor = getCurrentColor(email?.keywords);
|
||||||
|
|
||||||
|
// Contact sidebar state
|
||||||
|
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(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<Set<string>>(
|
const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState<Set<string>>(
|
||||||
() => {
|
() => {
|
||||||
if (typeof window === 'undefined') return new Set();
|
if (typeof window === 'undefined') return new Set();
|
||||||
@@ -631,8 +886,10 @@ export function EmailViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={email.id}
|
key={email.id}
|
||||||
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||||
>
|
>
|
||||||
|
{/* Main email content */}
|
||||||
|
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||||
{/* Loading overlay when fetching new email */}
|
{/* Loading overlay when fetching new email */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
|
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
|
||||||
@@ -953,13 +1210,11 @@ export function EmailViewer({
|
|||||||
{email.to && email.to.length > 0 && (
|
{email.to && email.to.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-1 text-sm">
|
<div className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
<span className="text-muted-foreground">{t('recipient_to_prefix')}</span>
|
<span className="text-muted-foreground">{t('recipient_to_prefix')}</span>
|
||||||
<span className="text-foreground">
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
{formatRecipients(email.to, currentUserEmail, t)}
|
|
||||||
</span>
|
|
||||||
{email.to.length > 2 && (
|
{email.to.length > 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||||
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
|
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
||||||
>
|
>
|
||||||
{t('more_count', { count: email.to.length - 2 })}
|
{t('more_count', { count: email.to.length - 2 })}
|
||||||
</button>
|
</button>
|
||||||
@@ -970,10 +1225,10 @@ export function EmailViewer({
|
|||||||
{email.cc && email.cc.length > 0 && (
|
{email.cc && email.cc.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-1 text-sm">
|
<div className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
<span className="text-muted-foreground">CC:</span>
|
<span className="text-muted-foreground">CC:</span>
|
||||||
<span className="text-foreground">
|
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
|
{email.cc.length > 2 && (
|
||||||
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
|
<span className="text-muted-foreground text-sm">+{email.cc.length - 2}</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1311,9 +1566,7 @@ export function EmailViewer({
|
|||||||
{email.to && email.to.length > 0 && (
|
{email.to && email.to.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span>→ {t('recipient_to_prefix')}</span>
|
<span>→ {t('recipient_to_prefix')}</span>
|
||||||
<span className="text-foreground">
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
{formatRecipients(email.to, currentUserEmail, t)}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1321,10 +1574,10 @@ export function EmailViewer({
|
|||||||
{email.cc && email.cc.length > 0 && (
|
{email.cc && email.cc.length > 0 && (
|
||||||
<div className="mt-1 flex items-center gap-1 text-sm">
|
<div className="mt-1 flex items-center gap-1 text-sm">
|
||||||
<span className="text-muted-foreground">CC:</span>
|
<span className="text-muted-foreground">CC:</span>
|
||||||
<span className="text-foreground truncate">
|
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
|
{email.cc.length > 2 && (
|
||||||
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
|
<span className="text-muted-foreground">+{email.cc.length - 2}</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1651,5 +1904,15 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Detail Sidebar - desktop only */}
|
||||||
|
{contactSidebarEmail && !isMobileDevice && (
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email={contactSidebarEmail}
|
||||||
|
contact={sidebarContact}
|
||||||
|
onClose={() => setContactSidebarEmail(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<HTMLButtonElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={handleOpen}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayLabel || name || email}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
position &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="fixed z-50 w-[300px] bg-background rounded-lg shadow-lg border border-border animate-in fade-in-0 zoom-in-95 duration-100"
|
||||||
|
style={{ top: position.top, left: position.left }}
|
||||||
|
>
|
||||||
|
{/* Header with avatar and name */}
|
||||||
|
<div className="px-4 pt-4 pb-3 flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
name={contactName || email}
|
||||||
|
email={email}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-semibold text-sm truncate">
|
||||||
|
{contactName || email}
|
||||||
|
</div>
|
||||||
|
{contactName && contactName !== email && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orgs.length > 0 && orgs[0].name && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate flex items-center gap-1">
|
||||||
|
<Building className="w-3 h-3 shrink-0" />
|
||||||
|
{orgs[0].name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact details */}
|
||||||
|
<div className="px-4 pb-3 space-y-1.5">
|
||||||
|
{/* Show additional emails if contact has them */}
|
||||||
|
{emails.length > 1 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{emails.slice(1).map((e, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Mail className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="truncate">{e.address}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phone numbers */}
|
||||||
|
{phones.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{phones.map((p, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Phone className="w-3 h-3 shrink-0" />
|
||||||
|
<a href={`tel:${p.number}`} className="hover:text-foreground hover:underline truncate">
|
||||||
|
{p.number}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t border-border px-2 py-2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyEmail(email)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
|
||||||
|
title="Copy email"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`mailto:${email}`}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
|
||||||
|
title="Send email"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
{onViewContact && (
|
||||||
|
<button
|
||||||
|
onClick={handleViewContact}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ml-auto"
|
||||||
|
title={contact ? "View contact" : "View details"}
|
||||||
|
>
|
||||||
|
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
||||||
|
{contact ? "View contact" : "View details"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user