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
+149 -2
View File
@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
@@ -9,6 +9,7 @@ import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
import { Composer, type ComposerInit } from "../compose/Composer";
import { Icon } from "../Icon";
import { LabelChips } from "../mail/LabelChips";
import { MessageBody } from "./MessageBody";
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
@@ -146,14 +147,25 @@ function ActionsMenu({
onPrint,
onDelete,
replyDisabled = false,
allLabels,
messageLabels,
onToggleLabel,
onCreateLabel,
}: {
onReply: (kind: string) => void;
onPrint: () => void;
onDelete: () => void;
replyDisabled?: boolean;
allLabels: string[];
messageLabels: string[];
onToggleLabel: (label: string) => void;
onCreateLabel: (name: string) => void;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const [labelsOpen, setLabelsOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
@@ -170,6 +182,9 @@ function ActionsMenu({
window.removeEventListener("blur", onBlur);
};
}, [open]);
useEffect(() => {
if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); }
}, [open]);
const item = (label: string, onClick: () => void, disabled = false) => (
<button
@@ -205,6 +220,62 @@ function ActionsMenu({
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
{item(t("mail.print"), onPrint)}
<div className="my-1 border-t border-app-border" />
{/* Labels submenu: multi-select checkboxes (menu stays open) + create new. */}
<button
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
onClick={() => setLabelsOpen((v) => !v)}
>
{t("mail.labels")}
<span className="text-[11px] text-app-muted">{labelsOpen ? "▾" : "▸"}</span>
</button>
{labelsOpen && (
<div className="max-h-52 overflow-auto border-y border-app-border bg-app-surface/40">
{allLabels.map((l) => (
<label
key={l}
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs text-app-fg hover:bg-app-hover"
>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-app-accent"
checked={messageLabels.includes(l)}
onChange={() => onToggleLabel(l)}
/>
<span className="truncate">{l}</span>
</label>
))}
{allLabels.length === 0 && (
<div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div>
)}
{creating ? (
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newName.trim()) {
onCreateLabel(newName.trim());
setNewName("");
setCreating(false);
} else if (e.key === "Escape") {
setCreating(false);
setNewName("");
}
}}
placeholder={t("mail.labelName")}
className="m-1 w-[calc(100%-0.5rem)] rounded border border-app-border bg-app-surface px-2 py-1 text-xs outline-none focus:border-app-accent"
/>
) : (
<button
className="block w-full px-3 py-1.5 text-left text-xs text-app-accent hover:bg-app-hover"
onClick={() => setCreating(true)}
>
{t("mail.createLabel")}
</button>
)}
</div>
)}
<div className="my-1 border-t border-app-border" />
{item(t("mail.delete"), onDelete)}
</div>
)}
@@ -230,6 +301,10 @@ function MessageItem({
onPrint,
onDelete,
innerRef,
allLabels,
messageLabels,
onToggleLabel,
onCreateLabel,
}: {
message: MessageEnvelope;
expanded: boolean;
@@ -248,6 +323,10 @@ function MessageItem({
onPrint: () => void;
onDelete: () => void;
innerRef?: (el: HTMLDivElement | null) => void;
allLabels: string[];
messageLabels: string[];
onToggleLabel: (label: string) => void;
onCreateLabel: (name: string) => void;
}) {
const t = useT();
const [showDetails, setShowDetails] = useState(false);
@@ -363,6 +442,10 @@ function MessageItem({
onPrint={onPrint}
onDelete={onDelete}
replyDisabled={!canReply}
allLabels={allLabels}
messageLabels={messageLabels}
onToggleLabel={onToggleLabel}
onCreateLabel={onCreateLabel}
/>
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
@@ -447,6 +530,63 @@ export function ThreadView({
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
},
});
// --- labels: per-message storage + a thread union for display (chips + list) ---
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const threadId = messages[0]?.thread_id ?? null;
const allLabelsQuery = useQuery({ queryKey: ["labels-all"], queryFn: api.labelsAll });
const msgLabelsQuery = useQuery({
queryKey: ["message-labels", messageIds],
queryFn: () => api.messageLabelsBatch(messageIds),
enabled: messageIds.length > 0,
});
const allLabels = allLabelsQuery.data ?? [];
const msgLabels = msgLabelsQuery.data ?? {};
const threadLabels = useMemo(
() => [...new Set(Object.values(msgLabels).flat())].sort((a, b) => a.localeCompare(b)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[msgLabelsQuery.data],
);
// Recompute + persist the thread's label union (list chips read this), then tell
// the sidebar + other queries a label change happened.
const persistUnionAndNotify = async () => {
const fresh = await api.messageLabelsBatch(messageIds);
const union = [...new Set(Object.values(fresh).flat())].sort((a, b) => a.localeCompare(b));
if (threadId) await api.setThreadLabels(threadId, union);
};
const onLabelsChanged = () => {
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
window.dispatchEvent(new Event("webmail:labels-changed"));
};
const applyLabels = useMutation({
mutationFn: async ({ messageId, labels }: { messageId: string; labels: string[] }) => {
await api.setMessageLabels(messageId, labels);
await persistUnionAndNotify();
},
onSuccess: onLabelsChanged,
});
const removeThreadLabel = useMutation({
mutationFn: async (label: string) => {
for (const m of messages) {
const cur = msgLabels[m.id] ?? [];
if (cur.includes(label)) await api.setMessageLabels(m.id, cur.filter((x) => x !== label));
}
await persistUnionAndNotify();
},
onSuccess: onLabelsChanged,
});
const toggleLabel = (messageId: string, label: string) => {
const cur = msgLabels[messageId] ?? [];
const next = cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label];
applyLabels.mutate({ messageId, labels: next });
};
const createLabel = async (name: string, messageId: string) => {
await api.labelsCreate(name);
toggleLabel(messageId, name);
};
const move = useMutation({
mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) =>
api.moveMessage(id, mailboxId),
@@ -545,7 +685,10 @@ export function ThreadView({
return (
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
<div className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
</div>
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
@@ -611,6 +754,10 @@ export function ThreadView({
onPrint={() => void printMessages(subject, [message])}
onDelete={() => deleteMessage(message)}
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
allLabels={allLabels}
messageLabels={msgLabels[message.id] ?? []}
onToggleLabel={(l) => toggleLabel(message.id, l)}
onCreateLabel={(name) => void createLabel(name, message.id)}
/>
{isNewest && (
<div data-testid="composer-anchor">