Labels in Mail UI (chips + Actions + sidebar), plain-text Lato, calendar-panel polish

Labels:
- Moved labelling out of the "+label" message slot into Mail: label chips on the
  thread subject bar (removable ✕) and before list subjects, and a Labels submenu
  in the per-message Actions menu (multi-select checkboxes + "Create new…").
  Per-message storage kept; the reading pane writes a thread-union aggregate so the
  list (no message ids) can show chips.
- Sidebar LABELS section rewritten: aligned monocolor tag rows, collapsible
  (collapsed by default), inline "+" create popover, hover "−" delete with a confirm
  popover, and click-to-filter the mail list by label.
- Backend: batch message/thread label reads, thread aggregate, create + delete
  (delete unhooks from all mail), and GET /labels/{name}/threads. Core GET /threads
  gained an `ids` param to power the label view. Removed the plugin-pages list from
  the Mail sidebar.

Plain-text mail now renders in Lato (self-hosted at /fonts, @font-face'd into the
sandboxed message iframe) instead of the monospace <pre> default.

Calendar right-panel (embed mode): restored the "…" view-options menu, pinned the
toolbar + mini-calendar (only the agenda scrolls), plain "All calendars" button
(inline resets), and the event popover now closes on any outside click.

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 20:53:35 +02:00
co-authored by Claude Opus 4.8
parent 1d1a621ce2
commit 0a478a6b26
16 changed files with 574 additions and 75 deletions
+53 -28
View File
@@ -19,7 +19,6 @@ import { api } from "../lib/api";
import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { usePluginPages } from "../plugins/manifest";
import { PluginSlot } from "../plugins/PluginSlot";
function quotaLabel(q: QuotaOut): string {
@@ -48,7 +47,6 @@ const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
export function MailLayout() {
const t = useT();
const pluginPages = usePluginPages();
const navigate = useNavigate();
const queryClient = useQueryClient();
const match = useMatch("/mail/t/:threadId");
@@ -117,10 +115,17 @@ export function MailLayout() {
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
const orderParam: "asc" | "desc" = listOrder === "newest" ? "desc" : "asc";
const labelView = view.startsWith("label:") ? view.slice("label:".length) : null;
const threadsQuery = useQuery({
queryKey: ["threads", view, filter, orderParam, page],
queryFn: () =>
api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam }),
queryFn: async () => {
if (labelView !== null) {
// Label view: the labels plugin supplies the matching thread ids.
const ids = await api.labelThreads(labelView);
return ids.length ? api.threadsByIds(ids) : { threads: [], total: 0, cursor: null };
}
return api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam });
},
enabled: !isDrafts && !searching,
placeholderData: keepPreviousData, // keep the current page visible while the next loads
});
@@ -469,6 +474,31 @@ export function MailLayout() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// Deep link from the sidebar: /mail?label=<name> shows all mail with that label.
useEffect(() => {
const label = searchParams.get("label");
if (label == null) return;
setView(`label:${label}`);
setPage(0);
navigate("/mail");
const next = new URLSearchParams(searchParams);
next.delete("label");
setSearchParams(next, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// Labels changed anywhere (Actions menu, sidebar +): refresh label-driven data.
useEffect(() => {
const onChanged = () => {
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
};
window.addEventListener("webmail:labels-changed", onChanged);
return () => window.removeEventListener("webmail:labels-changed", onChanged);
}, [queryClient]);
return (
<div className="flex h-screen bg-app-bg text-app-fg">
<BackfillBanner />
@@ -587,21 +617,6 @@ export function MailLayout() {
)}
</>
)}
{/* Plugin pages (app.page) get a nav entry; clicking opens /p{route}. */}
{pluginPages.map((p) => (
<SidebarItem
key={`${p.plugin}${p.route}`}
active={false}
collapsed={collapsed}
title={collapsed ? p.nav.label : undefined}
onClick={() => navigate(`/p${p.route}`)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="shrink-0" aria-hidden>
<path d="M10 3.5a1.5 1.5 0 0 1 3 0V5h3.5A1.5 1.5 0 0 1 18 6.5V10h1.5a1.5 1.5 0 0 1 0 3H18v3.5a1.5 1.5 0 0 1-1.5 1.5H13v-1.5a1.5 1.5 0 0 0-3 0V18H6.5A1.5 1.5 0 0 1 5 16.5V13H6.5a1.5 1.5 0 0 0 0-3H5V6.5A1.5 1.5 0 0 1 6.5 5H10V3.5z" />
</svg>
{!collapsed && <span className="flex-1 truncate">{p.nav.label}</span>}
</SidebarItem>
))}
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
{!collapsed && <PluginSlot name="sidebar.section" />}
</nav>
@@ -719,15 +734,25 @@ export function MailLayout() {
</svg>
) : null}
</button>
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
<FilterTab active={filter === "unread"} onClick={() => setFilter("unread")}>
{t("filter.unread")}
</FilterTab>
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
{t("filter.pinned")}
</FilterTab>
{labelView !== null ? (
<span className="flex items-center gap-1.5 rounded bg-app-hover px-2 py-0.5 text-[13px] text-app-fg">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M20.6 13.4l-7.1 7.1a2 2 0 0 1-2.83 0l-6.17-6.17A2 2 0 0 1 3 12.93V5a2 2 0 0 1 2-2h7.93a2 2 0 0 1 1.4.59l6.17 6.17a2 2 0 0 1 0 2.83z" /><circle cx="7.5" cy="7.5" r="1.2" fill="currentColor" stroke="none" /></svg>
{labelView}
<button className="text-app-muted hover:text-app-fg" title={t("common.close")} onClick={() => selectView("folder:inbox")}>✕</button>
</span>
) : (
<>
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
<FilterTab active={filter === "unread"} onClick={() => setFilter("unread")}>
{t("filter.unread")}
</FilterTab>
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
{t("filter.pinned")}
</FilterTab>
</>
)}
<div className="-mr-1.5 ml-auto flex items-center gap-1">
{(currentFolder === "trash" || currentFolder === "junk") && (
<button