437 lines
24 KiB
TypeScript
437 lines
24 KiB
TypeScript
// 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 (
|
||
|
|
<span
|
||
|
|
className="inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white"
|
||
|
|
style={{ width: size, height: size, background: avatarColor(email || name), fontSize: Math.round(size * 0.38) }}
|
||
|
|
>
|
||
|
|
{initials(name || email)}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
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<InviteSignal | null>(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 (
|
||
|
|
<aside className="flex h-full min-h-0 flex-col bg-app-surface">
|
||
|
|
<div className="flex h-[52px] shrink-0 items-center gap-4 border-b border-app-border px-4">
|
||
|
|
{TABS.map(([id, label]) => (
|
||
|
|
<button
|
||
|
|
key={id}
|
||
|
|
onClick={() => onTab(id)}
|
||
|
|
className={`-mb-px border-b-2 py-[15px] text-[15px] ${
|
||
|
|
tab === id ? "border-app-accent font-semibold text-app-fg" : "border-transparent text-app-muted hover:text-app-fg"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
<button className="ml-auto rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg" title={t("common.close")} onClick={onClose}>
|
||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{/* Calendar stays mounted (keeps its state) while hidden, so switching tabs is instant. */}
|
||
|
|
<div className={`min-h-0 flex-1 ${tab === "calendar" ? "" : "hidden"}`}>
|
||
|
|
<PanelCalendar invite={invite} />
|
||
|
|
</div>
|
||
|
|
{tab !== "calendar" && (
|
||
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||
|
|
{tab === "people" && <PeopleTab thread={thread} onInviteAll={inviteAll} />}
|
||
|
|
{tab === "attachments" && <AttachmentsTab thread={thread} />}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</aside>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- 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<HTMLDivElement>(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 <div ref={ref} className="h-full" />;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- footer link (Open X in new tab) --------------------------------------
|
||
|
|
function OpenInNewTab({ label, href }: { label: string; href: string }) {
|
||
|
|
return (
|
||
|
|
<div className="border-t border-app-border p-3 text-center">
|
||
|
|
<button
|
||
|
|
className="inline-flex items-center gap-1.5 text-[13px] text-app-muted hover:text-app-fg"
|
||
|
|
onClick={() => window.open(href, "_blank", "noopener,noreferrer")}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4" /></svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
function MenuItem({ checked, onClick, children }: { checked?: boolean; onClick: () => void; children: React.ReactNode }) {
|
||
|
|
return (
|
||
|
|
<button className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-app-hover" onClick={onClick}>
|
||
|
|
<span className="w-3 shrink-0 text-app-accent">{checked ? "✓" : ""}</span>
|
||
|
|
<span className="flex items-center gap-2">{children}</span>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===========================================================================
|
||
|
|
// 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<PeopleSource>("conversation");
|
||
|
|
const [srcMenu, setSrcMenu] = useState(false);
|
||
|
|
const [moreMenu, setMoreMenu] = useState(false);
|
||
|
|
const [openEmail, setOpenEmail] = useState<string | null>(null);
|
||
|
|
const [openName, setOpenName] = useState("");
|
||
|
|
|
||
|
|
// "In conversation" = unique senders from the open thread.
|
||
|
|
const convPeople = useMemo(() => {
|
||
|
|
const seen = new Map<string, Address>();
|
||
|
|
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<Group[]>("/plugins/contacts/groups"), enabled: source === "autosaved" });
|
||
|
|
const autosavedId = groups.data?.find((g) => g.name === "Autosaved")?.id ?? "";
|
||
|
|
const filterMap: Record<PeopleSource, string> = { 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 <PersonDetail email={openEmail} name={openName} onBack={() => 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 (
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-1 border-b border-app-border px-4 py-2">
|
||
|
|
<div className="relative">
|
||
|
|
<button className="flex items-center gap-1.5 rounded px-1 py-1 text-[15px] font-semibold hover:text-app-fg" onClick={() => setSrcMenu((m) => !m)}>
|
||
|
|
{SOURCES.find(([s]) => s === source)?.[1]}
|
||
|
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M4 6l4 4 4-4" /></svg>
|
||
|
|
</button>
|
||
|
|
{srcMenu && (
|
||
|
|
<>
|
||
|
|
<div className="fixed inset-0 z-10" onClick={() => setSrcMenu(false)} />
|
||
|
|
<div className="absolute left-0 z-20 mt-1 min-w-[170px] rounded-lg border border-app-border bg-app-elevated py-1 text-[13px] shadow-lg">
|
||
|
|
{SOURCES.map(([s, label]) => (
|
||
|
|
<MenuItem key={s} checked={source === s} onClick={() => { setSource(s); setSrcMenu(false); }}>{label}</MenuItem>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="relative ml-auto">
|
||
|
|
<button className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" onClick={() => setMoreMenu((m) => !m)}>
|
||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></svg>
|
||
|
|
</button>
|
||
|
|
{moreMenu && (
|
||
|
|
<>
|
||
|
|
<div className="fixed inset-0 z-10" onClick={() => setMoreMenu(false)} />
|
||
|
|
<div className="absolute right-0 z-20 mt-1 min-w-[190px] rounded-lg border border-app-border bg-app-elevated py-1 text-[13px] shadow-lg">
|
||
|
|
<button className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-app-hover disabled:opacity-40" disabled={!allEmails.length} onClick={() => { setMoreMenu(false); navigate(`/mail?compose=1&to=${encodeURIComponent(allEmails.join(", "))}`); }}>
|
||
|
|
{t("panel.composeAll")}
|
||
|
|
</button>
|
||
|
|
<button className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-app-hover disabled:opacity-40" disabled={!allEmails.length} onClick={() => { setMoreMenu(false); onInviteAll(allEmails); }}>
|
||
|
|
{t("panel.inviteAll")}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="px-3 py-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">{t("panel.senders")}</div>
|
||
|
|
{people.length === 0 ? (
|
||
|
|
<div className="px-4 py-6 text-sm text-app-faint">{t("panel.noPeople")}</div>
|
||
|
|
) : (
|
||
|
|
people.map((p, i) => (
|
||
|
|
<button key={p.primary_email + i} className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-app-hover" onClick={() => { setOpenName(p.display_name); setOpenEmail(p.primary_email); }}>
|
||
|
|
<Avatar name={p.display_name} email={p.primary_email} />
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate font-medium">{p.display_name}</div>
|
||
|
|
<div className="truncate text-[13px] text-app-muted">{p.primary_email}</div>
|
||
|
|
</div>
|
||
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" className="text-app-faint"><path d="M6 4l4 4-4 4" /></svg>
|
||
|
|
</button>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- 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<ContactDetail>(`/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 }) => (
|
||
|
|
<button className="flex flex-col items-center gap-1.5 disabled:opacity-40" disabled={disabled} onClick={onClick}>
|
||
|
|
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-app-border text-app-fg">{icon}</span>
|
||
|
|
<span className="text-[12px] text-app-muted">{label}</span>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full flex-col">
|
||
|
|
<div className="flex items-center gap-2 border-b border-app-border px-3 py-2">
|
||
|
|
<button className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg" onClick={onBack}>
|
||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M15 6l-6 6 6 6" /></svg>
|
||
|
|
</button>
|
||
|
|
<span className="ml-auto text-app-faint">{c?.starred ? "★" : "☆"}</span>
|
||
|
|
{contactId && <button className="text-[13px] text-app-accent" onClick={() => window.open("/p/contacts", "_blank", "noopener")}>{t("common.edit")}</button>}
|
||
|
|
</div>
|
||
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Avatar name={display} email={primary} size={56} />
|
||
|
|
<div className="text-[20px] font-semibold">{display}</div>
|
||
|
|
</div>
|
||
|
|
<div className="mt-4 flex justify-around">
|
||
|
|
<ActionBtn label={t("panel.call")} disabled={!c?.phones?.length} onClick={() => c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 2 6a2 2 0 0 1 2-2" /></svg>} />
|
||
|
|
<ActionBtn label={t("panel.message")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z" /></svg>} />
|
||
|
|
<ActionBtn label={t("panel.compose")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg>} />
|
||
|
|
<ActionBtn label={t("panel.invite")} onClick={() => window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-6">
|
||
|
|
<div className="mb-1 flex items-center justify-between">
|
||
|
|
<div className="text-[15px] font-semibold">{t("panel.contactDetails")}</div>
|
||
|
|
{contactId && <button className="text-[13px] text-app-accent hover:underline" onClick={() => window.open("/p/contacts", "_blank", "noopener")}>{t("panel.showAll")}</button>}
|
||
|
|
</div>
|
||
|
|
<div className="border-t border-app-border py-2 text-sm">
|
||
|
|
<a href={`mailto:${primary}`} className="text-app-accent hover:underline">{primary}</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-5">
|
||
|
|
<div className="mb-1 text-[15px] font-semibold">{t("panel.mailSection")}</div>
|
||
|
|
{mail.isLoading ? (
|
||
|
|
<div className="border-t border-app-border py-3 text-sm text-app-faint">{t("common.loading")}</div>
|
||
|
|
) : (mail.data?.threads ?? []).length === 0 ? (
|
||
|
|
<div className="border-t border-app-border py-3 text-sm text-app-faint">{t("panel.noMatches")}</div>
|
||
|
|
) : (
|
||
|
|
(mail.data?.threads ?? []).slice(0, 5).map((h) => (
|
||
|
|
<button key={h.thread.id} className="flex w-full gap-2 border-t border-app-border py-2 text-left hover:bg-app-hover" onClick={() => navigate(`/mail/t/${h.thread.id}`)}>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate text-[13px] font-semibold">{h.thread.subject || t("panel.noSubject")}</div>
|
||
|
|
<div className="truncate text-[12px] text-app-muted">{h.thread.snippet}</div>
|
||
|
|
</div>
|
||
|
|
</button>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
<button className="mt-3 flex items-center gap-2 rounded bg-app-hover px-3 py-1.5 text-[13px] hover:opacity-90" onClick={() => navigate(`/mail?q=${encodeURIComponent(email)}`)}>
|
||
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||
|
|
{t("panel.findAll", { name: display.split(" ")[0] || display })}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-5">
|
||
|
|
<div className="mb-1 text-[15px] font-semibold">{t("panel.eventsSection")}</div>
|
||
|
|
<div className="border-t border-app-border py-3 text-sm text-app-faint">{t("panel.noMatches")}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<OpenInNewTab label={t("panel.openContact")} href="/p/contacts" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===========================================================================
|
||
|
|
// 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 <div className="px-4 py-6 text-sm text-app-faint">{t("panel.openConversation")}</div>;
|
||
|
|
if (q.isLoading) return <div className="px-4 py-6 text-sm text-app-faint">{t("common.loading")}</div>;
|
||
|
|
const groups = q.data ?? [];
|
||
|
|
if (groups.length === 0) return <div className="px-4 py-6 text-sm text-app-faint">{t("panel.noAttachments")}</div>;
|
||
|
|
|
||
|
|
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 (
|
||
|
|
<div className="px-3 py-2">
|
||
|
|
{groups.map(({ msg, atts }) => {
|
||
|
|
const previewable = atts.filter((a) => isPreviewable(a.filename));
|
||
|
|
return (
|
||
|
|
<div key={msg.id} className="mb-3">
|
||
|
|
<div className="mb-1.5 flex items-center justify-between px-1">
|
||
|
|
<span className="truncate text-[14px] font-semibold text-app-accent">{senderName(msg)}</span>
|
||
|
|
<span className="shrink-0 text-[12px] text-app-muted">{shortDate(msg.date)}</span>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
{atts.map((a) => {
|
||
|
|
const canPreview = isPreviewable(a.filename);
|
||
|
|
return (
|
||
|
|
<div key={a.id} className="flex items-center gap-3 rounded-lg border border-app-border px-3 py-2">
|
||
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" className="shrink-0 text-app-muted"><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" /></svg>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
{canPreview ? (
|
||
|
|
<button
|
||
|
|
className="block max-w-full truncate text-left text-[13px] font-medium text-app-accent hover:underline"
|
||
|
|
title={a.filename}
|
||
|
|
onClick={() => setPreview({ messageId: msg.id, list: previewable, index: previewable.findIndex((p) => p.id === a.id) })}
|
||
|
|
>
|
||
|
|
{a.filename}
|
||
|
|
</button>
|
||
|
|
) : (
|
||
|
|
<div className="truncate text-[13px] font-medium" title={a.filename}>{a.filename}</div>
|
||
|
|
)}
|
||
|
|
<div className="text-[12px] text-app-muted">{fmtSize(a.size_bytes)}</div>
|
||
|
|
</div>
|
||
|
|
<a href={api.attachmentDownloadUrl(msg.id, a.id)} className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" title={t("panel.download")}>
|
||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16" /></svg>
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{preview && (
|
||
|
|
<Suspense fallback={null}>
|
||
|
|
<AttachmentPreview
|
||
|
|
messageId={preview.messageId}
|
||
|
|
attachments={preview.list}
|
||
|
|
index={preview.index}
|
||
|
|
onIndex={(i) => setPreview((p) => (p ? { ...p, index: i } : p))}
|
||
|
|
onClose={() => setPreview(null)}
|
||
|
|
/>
|
||
|
|
</Suspense>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|