{srcMenu && (
<>
setSrcMenu(false)} />
{SOURCES.map(([s, label]) => (
))}
>
)}
{moreMenu && (
<>
setMoreMenu(false)} />
>
)}
{t("panel.senders")}
{people.length === 0 ? (
{t("panel.noPeople")}
) : (
people.map((p, i) => (
))
)}
);
}
// ---- People: drill-in contact detail --------------------------------------
function PersonDetail({ email, name, onBack }: { email: string; name: string; onBack: () => void }) {
const t = useT();
const navigate = useNavigate();
// Resolve to a saved contact by email (best match), else show a synthetic card.
const suggest = useQuery({ queryKey: ["contact-suggest", email], queryFn: () => apiFetch<{ email: string; contact_id: string }[]>(`/plugins/contacts/suggest?q=${encodeURIComponent(email)}`) });
const contactId = suggest.data?.find((s) => s.email.toLowerCase() === email.toLowerCase())?.contact_id;
const detail = useQuery({ queryKey: ["contact", contactId], enabled: !!contactId, queryFn: () => apiFetch
(`/plugins/contacts/contacts/${contactId}`) });
const mail = useQuery({ queryKey: ["panel-person-mail", email], queryFn: () => api.search(`"${email}"`) });
const c = detail.data;
const display = c?.display_name || name || email;
const primary = c?.default_email || email;
const ActionBtn = ({ icon, label, onClick, disabled }: { icon: React.ReactNode; label: string; onClick: () => void; disabled?: boolean }) => (
);
return (
{c?.starred ? "★" : "☆"}
{contactId &&
}
c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={} />
navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={} />
navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={} />
window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={} />
{t("panel.contactDetails")}
{contactId &&
}
{t("panel.mailSection")}
{mail.isLoading ? (
{t("common.loading")}
) : (mail.data?.threads ?? []).length === 0 ? (
{t("panel.noMatches")}
) : (
(mail.data?.threads ?? []).slice(0, 5).map((h) => (
))
)}
{t("panel.eventsSection")}
{t("panel.noMatches")}
);
}
// ===========================================================================
// Attachments tab
// ===========================================================================
function AttachmentsTab({ thread }: { thread: ThreadDetail | undefined }) {
const t = useT();
const withAtt = (thread?.messages ?? []).filter((m) => m.has_attachments);
const q = useQuery({
queryKey: ["panel-attachments", thread?.id, withAtt.map((m) => m.id).join(",")],
enabled: !!thread && withAtt.length > 0,
queryFn: async () => {
const groups = await Promise.all(withAtt.map(async (m) => ({ msg: m, atts: await api.messageAttachments(m.id) })));
return groups.filter((g) => g.atts.length > 0);
},
});
// Full-screen preview overlay, per message (previewable subset of that message).
const [preview, setPreview] = useState<{ messageId: string; list: AttachmentOut[]; index: number } | null>(null);
if (!thread) return {t("panel.openConversation")}
;
if (q.isLoading) return {t("common.loading")}
;
const groups = q.data ?? [];
if (groups.length === 0) return {t("panel.noAttachments")}
;
const senderName = (m: MessageEnvelope) => m.from_addr[0]?.name || m.from_addr[0]?.email || "";
const shortDate = (iso: string) => new Date(iso).toLocaleDateString("en-GB", { month: "short", day: "numeric" });
return (
{groups.map(({ msg, atts }) => {
const previewable = atts.filter((a) => isPreviewable(a.filename));
return (
{senderName(msg)}
{shortDate(msg.date)}
{atts.map((a) => {
const canPreview = isPreviewable(a.filename);
return (
{canPreview ? (
) : (
{a.filename}
)}
{fmtSize(a.size_bytes)}
);
})}
);
})}
{preview && (
setPreview((p) => (p ? { ...p, index: i } : p))}
onClose={() => setPreview(null)}
/>
)}
);
}