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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
import { LabelChips } from "../mail/LabelChips";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
@@ -86,6 +89,15 @@ export function MessageList({
|
||||
// every row shows a checkbox (selection mode) until all are cleared.
|
||||
const checkMode = checkedIds.size > 0;
|
||||
|
||||
// Aggregated labels per thread (written by the reading pane) — shown before the subject.
|
||||
const threadIds = threads.map((th) => th.id);
|
||||
const labelsQuery = useQuery({
|
||||
queryKey: ["thread-labels", threadIds],
|
||||
queryFn: () => api.threadLabelsBatch(threadIds),
|
||||
enabled: threadIds.length > 0,
|
||||
});
|
||||
const labelMap = labelsQuery.data ?? {};
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threads.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
@@ -208,11 +220,12 @@ export function MessageList({
|
||||
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
|
||||
<div className="ml-6 flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div
|
||||
className={`truncate text-[14px] ${
|
||||
className={`flex min-w-0 items-center gap-1.5 text-[14px] ${
|
||||
unread ? "font-black text-app-fg" : "text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{thread.subject}
|
||||
<LabelChips labels={labelMap[thread.id] ?? []} />
|
||||
<span className="truncate">{thread.subject}</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Small muted pills for a message/thread's labels, shown before the subject in the
|
||||
// list and after the subject in the reading pane. With `onRemove`, each pill gets a
|
||||
// clickable ✕ (used in the reading pane to drop a label from the thread).
|
||||
export function LabelChips({
|
||||
labels,
|
||||
onRemove,
|
||||
className = "",
|
||||
}: {
|
||||
labels: string[];
|
||||
onRemove?: (label: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!labels.length) return null;
|
||||
return (
|
||||
<>
|
||||
{labels.map((l) => (
|
||||
<span
|
||||
key={l}
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded bg-app-hover px-1.5 py-0.5 text-[11px] font-medium leading-none text-app-muted ${className}`}
|
||||
title={l}
|
||||
>
|
||||
{l}
|
||||
{onRemove && (
|
||||
<button
|
||||
className="text-app-faint hover:text-app-fg"
|
||||
title="Remove label"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(l);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -99,6 +99,10 @@ export const en = {
|
||||
"mail.emptySpam": "Empty spam",
|
||||
"mail.more": "More",
|
||||
"mail.markAllRead": "Mark all as read",
|
||||
"mail.labels": "Labels",
|
||||
"mail.noLabels": "No labels yet",
|
||||
"mail.createLabel": "Create new…",
|
||||
"mail.labelName": "Label name",
|
||||
|
||||
"panel.toggle": "Toggle side panel",
|
||||
"panel.calendar": "Calendar",
|
||||
|
||||
@@ -276,6 +276,36 @@ export const api = {
|
||||
adoptDraft: (messageId: string) =>
|
||||
apiFetch<DraftOut>(`/drafts/adopt/${messageId}`, { method: "POST" }),
|
||||
|
||||
threadsByIds: (ids: string[]) =>
|
||||
apiFetch<ThreadListOut>(`/threads?ids=${ids.join(",")}&limit=200`),
|
||||
|
||||
// --- labels (labels plugin) ---
|
||||
labelsAll: () => apiFetch<string[]>("/plugins/labels/labels"),
|
||||
labelThreads: (name: string) =>
|
||||
apiFetch<string[]>(`/plugins/labels/labels/${encodeURIComponent(name)}/threads`),
|
||||
labelsCreate: (name: string) =>
|
||||
apiFetch<string[]>("/plugins/labels/labels", { method: "POST", body: JSON.stringify({ name }) }),
|
||||
messageLabelsBatch: (ids: string[]) =>
|
||||
apiFetch<Record<string, string[]>>("/plugins/labels/messages/labels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
setMessageLabels: (id: string, labels: string[]) =>
|
||||
apiFetch<{ status: string; labels: string[] }>(`/plugins/labels/message/${id}/labels`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ labels }),
|
||||
}),
|
||||
threadLabelsBatch: (ids: string[]) =>
|
||||
apiFetch<Record<string, string[]>>("/plugins/labels/threads/labels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
setThreadLabels: (id: string, labels: string[]) =>
|
||||
apiFetch<{ status: string; labels: string[] }>(`/plugins/labels/thread/${id}/labels`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ labels }),
|
||||
}),
|
||||
|
||||
// --- attachments ---
|
||||
messageAttachments: (messageId: string) =>
|
||||
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user