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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user