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:
@@ -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