Mail right panel: Calendar / People / Attachments (shared calendar forms)

New slide-out right panel in Mail (hidden by default, toggle in the reading pane,
resizable, persisted). Three tabs:
- Calendar: mounts the calendar plugin bundle in a new embed:"panel" mode, so the
  agenda, mini-month, event popover, event form and search results are the calendar
  app's OWN code — future changes propagate to both. Events click → real popover;
  New event / Edit / "Invite all" open the shared event form in the panel; search
  shows the calendar's results panel; All-calendars filter + open-in-new-tab.
- People: conversation senders / contact books (All/Recent/VIPs/Autosaved) drilling
  into a contact detail (Call/Message/Compose/Invite, related mail, find-all);
  "Compose to all" / "Invite all to new event".
- Attachments: every attachment in the thread, grouped by sender, with download and
  clickable previews (reuses the mail AttachmentPreview overlay for supported types).

Calendar bundle: mount(el, ctx, props) reads embed/newEventGuests; renderEmbed() +
panelBar/panelFoot; embed-aware agenda (TODAY); form/popover repositioned for the
narrow panel; guest deep-link accepts comma-separated emails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-03 08:38:44 +02:00
co-authored by Claude Opus 4.8
parent 421b44a8ab
commit 1d1a621ce2
13 changed files with 622 additions and 4 deletions
+43
View File
@@ -12,6 +12,7 @@ import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
import { BackfillBanner } from "../components/BackfillBanner";
import { ThreadView } from "../components/thread/ThreadView";
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
@@ -93,12 +94,21 @@ export function MailLayout() {
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
// Collapsed sidebar shows icons only (persisted).
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1");
// Right info panel (Calendar / People / Attachments) — hidden by default.
const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1");
const [rightTab, setRightTab] = useState<PanelTab>(() => (localStorage.getItem("panel.rightTab") as PanelTab) || "calendar");
const [rightW, setRightW] = useState(() => Number(localStorage.getItem("panel.rightW")) || 340);
const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420);
// The list can be dragged up to 75% of the window width.
const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75);
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("panel.collapsed", collapsed ? "1" : "0"), [collapsed]);
useEffect(() => localStorage.setItem("panel.right", rightOpen ? "1" : "0"), [rightOpen]);
useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]);
useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]);
const openPanel = (tab: PanelTab) => { setRightTab(tab); setRightOpen(true); };
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
const isDrafts = view === "folder:drafts";
@@ -930,6 +940,18 @@ export function MailLayout() {
<span className="ml-auto text-[13px] text-app-faint">
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span>
<button
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")}
aria-label={t("panel.toggle")}
aria-pressed={rightOpen}
onClick={() => (rightOpen ? setRightOpen(false) : openPanel(rightTab))}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M15 4v16" />
</svg>
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ThreadView
@@ -944,6 +966,19 @@ export function MailLayout() {
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
{!rightOpen && (
<button
className="absolute right-3 top-3 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("panel.toggle")}
aria-label={t("panel.toggle")}
onClick={() => openPanel(rightTab)}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M15 4v16" />
</svg>
</button>
)}
<EmptyIllustration className="w-56 max-w-[55%]" />
<span className="text-3xl">{t("mail.selectConversation")}</span>
</div>
@@ -967,6 +1002,14 @@ export function MailLayout() {
</div>
)}
</main>
{rightOpen && (
<>
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
<RightPanel tab={rightTab} onTab={setRightTab} onClose={() => setRightOpen(false)} thread={detailQuery.data} />
</div>
</>
)}
</div>
);
}