// Mail's slide-out right panel (hidden by default, toggled like Calendar's right // panel). Three tabs: Calendar (mini-month + agenda + search, from the calendar // plugin), People (conversation senders / contact books, drilling into a contact // detail), and Attachments (every attachment in the open thread). Data comes from // the plugin APIs + the current thread; actions reuse the existing deep links // (/mail?compose=, /mail?q=, /p/calendar?guest=). import { useQuery } from "@tanstack/react-query"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useT } from "../../i18n"; import { api, apiFetch } from "../../lib/api"; import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types"; import { loadBundle } from "../../plugins/bundleLoader"; import { usePluginContext } from "../../plugins/context"; import { isPreviewable } from "../thread/previewKind"; interface InviteSignal { guests: string[]; n: number } // Lazy: the preview bundles pdf.js — loaded only when a preview is opened. const AttachmentPreview = lazy(() => import("../thread/AttachmentPreview").then((m) => ({ default: m.AttachmentPreview })), ); export type PanelTab = "calendar" | "people" | "attachments"; // ---- small helpers --------------------------------------------------------- const AV_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280"]; function avatarColor(key: string) { let h = 0; for (const c of key || "?") h = (h * 31 + c.charCodeAt(0)) >>> 0; return AV_COLORS[h % AV_COLORS.length]; } function initials(name: string) { const p = (name || "").trim().split(/\s+/).filter(Boolean); if (!p.length) return "?"; return ((p[0][0] || "") + (p.length > 1 ? p[p.length - 1][0] : "")).toUpperCase(); } function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) { return ( {initials(name || email)} ); } function fmtSize(b: number) { if (b < 1024) return `${b} B`; if (b < 1024 * 1024) return `${Math.round(b / 1024)} KB`; return `${(b / 1024 / 1024).toFixed(1)} MB`; } interface ContactListItem { id: string; display_name: string; primary_email: string; org: string; starred: boolean } interface ContactDetail { id: string; display_name: string; org: string; title: string; department: string; emails: { value: string; type: string }[]; default_email: string; phones: { value: string; type: string }[]; notes: string; starred: boolean; groups: { id: string; name: string }[]; } interface Group { id: string; name: string; count: number } // =========================================================================== export function RightPanel({ tab, onTab, onClose, thread, }: { tab: PanelTab; onTab: (t: PanelTab) => void; onClose: () => void; thread: ThreadDetail | undefined; }) { const t = useT(); // "Invite all to new event" (People tab) opens the SHARED calendar event form in // the Calendar tab, prefilled with the selected people. const [invite, setInvite] = useState(null); const inviteAll = (guests: string[]) => { onTab("calendar"); setInvite({ guests, n: Date.now() }); }; const TABS: [PanelTab, string][] = [ ["calendar", t("panel.calendar")], ["people", t("panel.people")], ["attachments", t("panel.attachments")], ]; return ( ); } // ---- Calendar tab = the calendar plugin bundle, mounted in "panel" embed mode. // This is the shared surface: the agenda, mini-month, event popover, event form // and search results are the calendar app's own code, so future changes propagate. const CAL_BUNDLE = "/api/plugins/calendar/ui/page.js"; function PanelCalendar({ invite }: { invite: InviteSignal | null }) { const ref = useRef(null); const ctx = usePluginContext("calendar"); // Remount when a new "Invite all" fires so the form opens with those guests. const inviteN = invite?.n ?? 0; useEffect(() => { let alive = true; let cleanup: (() => void) | void; loadBundle(CAL_BUNDLE).then((mount) => { if (!alive || !ref.current) return; ref.current.innerHTML = ""; cleanup = mount(ref.current, ctx, { embed: "panel", newEventGuests: invite?.guests }); }); return () => { alive = false; if (typeof cleanup === "function") cleanup(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ctx, inviteN]); return
; } // ---- footer link (Open X in new tab) -------------------------------------- function OpenInNewTab({ label, href }: { label: string; href: string }) { return (
); } function MenuItem({ checked, onClick, children }: { checked?: boolean; onClick: () => void; children: React.ReactNode }) { return ( ); } // =========================================================================== // People tab // =========================================================================== type PeopleSource = "conversation" | "all" | "recent" | "vip" | "autosaved"; function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined; onInviteAll: (emails: string[]) => void }) { const t = useT(); const navigate = useNavigate(); const [source, setSource] = useState("conversation"); const [srcMenu, setSrcMenu] = useState(false); const [moreMenu, setMoreMenu] = useState(false); const [openEmail, setOpenEmail] = useState(null); const [openName, setOpenName] = useState(""); // "In conversation" = unique senders from the open thread. const convPeople = useMemo(() => { const seen = new Map(); for (const m of thread?.messages ?? []) { const a = m.from_addr[0]; if (a?.email && !seen.has(a.email.toLowerCase())) seen.set(a.email.toLowerCase(), a); } return [...seen.values()].map((a) => ({ id: "", display_name: a.name || a.email, primary_email: a.email })); }, [thread]); const groups = useQuery({ queryKey: ["contact-groups"], queryFn: () => apiFetch("/plugins/contacts/groups"), enabled: source === "autosaved" }); const autosavedId = groups.data?.find((g) => g.name === "Autosaved")?.id ?? ""; const filterMap: Record = { conversation: "", all: "all", recent: "recent", vip: "vip", autosaved: "all" }; const contactList = useQuery({ queryKey: ["panel-contacts", source, autosavedId], enabled: source !== "conversation" && (source !== "autosaved" || !!autosavedId), queryFn: () => apiFetch<{ contacts: ContactListItem[] }>( `/plugins/contacts/contacts?filter=${filterMap[source]}&group=${source === "autosaved" ? autosavedId : ""}&q=`, ), }); const people = source === "conversation" ? convPeople : (contactList.data?.contacts ?? []); const allEmails = people.map((p) => p.primary_email).filter(Boolean); if (openEmail) { return setOpenEmail(null)} />; } const SOURCES: [PeopleSource, string][] = [ ["conversation", t("panel.inConversation")], ["all", t("panel.allContacts")], ["recent", t("panel.recent")], ["vip", t("panel.vips")], ["autosaved", t("panel.autosaved")], ]; return (
{srcMenu && ( <>
setSrcMenu(false)} />
{SOURCES.map(([s, label]) => ( { setSource(s); setSrcMenu(false); }}>{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 && }
{display}
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)} /> )}
); }