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:
@@ -37,6 +37,14 @@ _INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "applicati
|
|||||||
_DOC_TEMPLATE = """<!doctype html><html><head><meta charset="utf-8">
|
_DOC_TEMPLATE = """<!doctype html><html><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
|
/* Self-hosted Lato so plain-text bodies use the app font (the sandboxed iframe
|
||||||
|
can't inherit the parent's web fonts). Served from the frontend's /fonts. */
|
||||||
|
@font-face{{font-family:"Lato";src:url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||||
|
font-weight:400;font-style:normal;font-display:swap}}
|
||||||
|
@font-face{{font-family:"Lato";src:url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||||
|
font-weight:700;font-style:normal;font-display:swap}}
|
||||||
|
@font-face{{font-family:"Lato";src:url("/fonts/Lato-Italic.ttf") format("truetype");
|
||||||
|
font-weight:400;font-style:italic;font-display:swap}}
|
||||||
html,body{{margin:0;padding:0}}
|
html,body{{margin:0;padding:0}}
|
||||||
body{{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px;
|
body{{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px;
|
||||||
line-height:1.5;color:#1a1a1a;background:#fff;padding:14px;word-break:break-word}}
|
line-height:1.5;color:#1a1a1a;background:#fff;padding:14px;word-break:break-word}}
|
||||||
@@ -45,6 +53,10 @@ _DOC_TEMPLATE = """<!doctype html><html><head><meta charset="utf-8">
|
|||||||
blockquote{{margin:0 0 0 12px;padding-left:12px;border-left:3px solid #e0e0e0;color:#555}}
|
blockquote{{margin:0 0 0 12px;padding-left:12px;border-left:3px solid #e0e0e0;color:#555}}
|
||||||
a{{color:#2563eb}}
|
a{{color:#2563eb}}
|
||||||
pre{{white-space:pre-wrap;word-wrap:break-word}}
|
pre{{white-space:pre-wrap;word-wrap:break-word}}
|
||||||
|
/* Plain-text emails: proportional Lato instead of the monospace <pre> default. */
|
||||||
|
.wm-plaintext{{white-space:pre-wrap;word-wrap:break-word;
|
||||||
|
font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||||
|
font-size:15px;line-height:1.55}}
|
||||||
</style></head><body>{body}</body></html>"""
|
</style></head><body>{body}</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
@@ -112,7 +124,7 @@ async def message_html(
|
|||||||
return HTMLResponse(content=_wrap(html_part.content))
|
return HTMLResponse(content=_wrap(html_part.content))
|
||||||
text_part = next((p for p in parts if p.kind == "text"), None)
|
text_part = next((p for p in parts if p.kind == "text"), None)
|
||||||
body = html_lib.escape(text_part.content) if text_part else ""
|
body = html_lib.escape(text_part.content) if text_part else ""
|
||||||
return HTMLResponse(content=_wrap(f"<pre>{body}</pre>"))
|
return HTMLResponse(content=_wrap(f'<div class="wm-plaintext">{body}</div>'))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
|
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
|
||||||
|
|||||||
@@ -83,12 +83,21 @@ async def list_threads(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
filter: str = Query("all"), # all | unread | pinned
|
filter: str = Query("all"), # all | unread | pinned
|
||||||
order: str = Query("desc"), # desc (newest first) | asc (oldest first)
|
order: str = Query("desc"), # desc (newest first) | asc (oldest first)
|
||||||
|
ids: str = Query(""), # comma-separated thread ids (e.g. a plugin's label view)
|
||||||
) -> ThreadListOut:
|
) -> ThreadListOut:
|
||||||
# Collect WHERE conditions once so the page query and the total-count query
|
# Collect WHERE conditions once so the page query and the total-count query
|
||||||
# stay in lockstep (offset pagination needs an accurate grand total).
|
# stay in lockstep (offset pagination needs an accurate grand total).
|
||||||
conditions: list[ColumnElement[bool]] = [Thread.user_id == user.id]
|
conditions: list[ColumnElement[bool]] = [Thread.user_id == user.id]
|
||||||
|
|
||||||
if view.startswith("account:"):
|
if ids:
|
||||||
|
# Explicit id set (ignores view/folder) — used by plugin-driven views like
|
||||||
|
# "all mail with label X", which supply the matching thread ids.
|
||||||
|
try:
|
||||||
|
wanted = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
|
||||||
|
except ValueError:
|
||||||
|
raise_api_error(400, "bad_ids", "invalid thread id in ids")
|
||||||
|
conditions.append(Thread.id.in_(wanted or [uuid.uuid4()]))
|
||||||
|
elif view.startswith("account:"):
|
||||||
# A single account's inbox: threads with a message in THIS account's
|
# A single account's inbox: threads with a message in THIS account's
|
||||||
# inbox mailbox. Keeps the per-account view consistent with the unified
|
# inbox mailbox. Keeps the per-account view consistent with the unified
|
||||||
# Inbox (archived/trashed/sent threads don't leak in).
|
# Inbox (archived/trashed/sent threads don't leak 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 { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||||
import type { Address, ThreadSummary } from "../../lib/types";
|
import type { Address, ThreadSummary } from "../../lib/types";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
|
import { LabelChips } from "../mail/LabelChips";
|
||||||
|
|
||||||
function timeLabel(iso: string): string {
|
function timeLabel(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -86,6 +89,15 @@ export function MessageList({
|
|||||||
// every row shows a checkbox (selection mode) until all are cleared.
|
// every row shows a checkbox (selection mode) until all are cleared.
|
||||||
const checkMode = checkedIds.size > 0;
|
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({
|
const virtualizer = useVirtualizer({
|
||||||
count: threads.length,
|
count: threads.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
@@ -208,11 +220,12 @@ export function MessageList({
|
|||||||
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
|
{/* 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="ml-6 flex min-w-0 flex-1 flex-col justify-center">
|
||||||
<div
|
<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"
|
unread ? "font-black text-app-fg" : "text-app-fg"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{thread.subject}
|
<LabelChips labels={labelMap[thread.id] ?? []} />
|
||||||
|
<span className="truncate">{thread.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
|
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
|
||||||
</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 { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
@@ -9,6 +9,7 @@ import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
|||||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
|
import { LabelChips } from "../mail/LabelChips";
|
||||||
import { MessageBody } from "./MessageBody";
|
import { MessageBody } from "./MessageBody";
|
||||||
|
|
||||||
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
||||||
@@ -146,14 +147,25 @@ function ActionsMenu({
|
|||||||
onPrint,
|
onPrint,
|
||||||
onDelete,
|
onDelete,
|
||||||
replyDisabled = false,
|
replyDisabled = false,
|
||||||
|
allLabels,
|
||||||
|
messageLabels,
|
||||||
|
onToggleLabel,
|
||||||
|
onCreateLabel,
|
||||||
}: {
|
}: {
|
||||||
onReply: (kind: string) => void;
|
onReply: (kind: string) => void;
|
||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
replyDisabled?: boolean;
|
replyDisabled?: boolean;
|
||||||
|
allLabels: string[];
|
||||||
|
messageLabels: string[];
|
||||||
|
onToggleLabel: (label: string) => void;
|
||||||
|
onCreateLabel: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -170,6 +182,9 @@ function ActionsMenu({
|
|||||||
window.removeEventListener("blur", onBlur);
|
window.removeEventListener("blur", onBlur);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); }
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const item = (label: string, onClick: () => void, disabled = false) => (
|
const item = (label: string, onClick: () => void, disabled = false) => (
|
||||||
<button
|
<button
|
||||||
@@ -205,6 +220,62 @@ function ActionsMenu({
|
|||||||
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
|
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
|
||||||
{item(t("mail.print"), onPrint)}
|
{item(t("mail.print"), onPrint)}
|
||||||
<div className="my-1 border-t border-app-border" />
|
<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)}
|
{item(t("mail.delete"), onDelete)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -230,6 +301,10 @@ function MessageItem({
|
|||||||
onPrint,
|
onPrint,
|
||||||
onDelete,
|
onDelete,
|
||||||
innerRef,
|
innerRef,
|
||||||
|
allLabels,
|
||||||
|
messageLabels,
|
||||||
|
onToggleLabel,
|
||||||
|
onCreateLabel,
|
||||||
}: {
|
}: {
|
||||||
message: MessageEnvelope;
|
message: MessageEnvelope;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@@ -248,6 +323,10 @@ function MessageItem({
|
|||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
innerRef?: (el: HTMLDivElement | null) => void;
|
innerRef?: (el: HTMLDivElement | null) => void;
|
||||||
|
allLabels: string[];
|
||||||
|
messageLabels: string[];
|
||||||
|
onToggleLabel: (label: string) => void;
|
||||||
|
onCreateLabel: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
@@ -363,6 +442,10 @@ function MessageItem({
|
|||||||
onPrint={onPrint}
|
onPrint={onPrint}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
replyDisabled={!canReply}
|
replyDisabled={!canReply}
|
||||||
|
allLabels={allLabels}
|
||||||
|
messageLabels={messageLabels}
|
||||||
|
onToggleLabel={onToggleLabel}
|
||||||
|
onCreateLabel={onCreateLabel}
|
||||||
/>
|
/>
|
||||||
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
|
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
|
||||||
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
|
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
|
||||||
@@ -447,6 +530,63 @@ export function ThreadView({
|
|||||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
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({
|
const move = useMutation({
|
||||||
mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) =>
|
mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) =>
|
||||||
api.moveMessage(id, mailboxId),
|
api.moveMessage(id, mailboxId),
|
||||||
@@ -545,7 +685,10 @@ export function ThreadView({
|
|||||||
return (
|
return (
|
||||||
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
<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">
|
<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">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
<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">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
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])}
|
onPrint={() => void printMessages(subject, [message])}
|
||||||
onDelete={() => deleteMessage(message)}
|
onDelete={() => deleteMessage(message)}
|
||||||
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
|
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 && (
|
{isNewest && (
|
||||||
<div data-testid="composer-anchor">
|
<div data-testid="composer-anchor">
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ export const en = {
|
|||||||
"mail.emptySpam": "Empty spam",
|
"mail.emptySpam": "Empty spam",
|
||||||
"mail.more": "More",
|
"mail.more": "More",
|
||||||
"mail.markAllRead": "Mark all as read",
|
"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.toggle": "Toggle side panel",
|
||||||
"panel.calendar": "Calendar",
|
"panel.calendar": "Calendar",
|
||||||
|
|||||||
@@ -276,6 +276,36 @@ export const api = {
|
|||||||
adoptDraft: (messageId: string) =>
|
adoptDraft: (messageId: string) =>
|
||||||
apiFetch<DraftOut>(`/drafts/adopt/${messageId}`, { method: "POST" }),
|
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 ---
|
// --- attachments ---
|
||||||
messageAttachments: (messageId: string) =>
|
messageAttachments: (messageId: string) =>
|
||||||
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
|
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { api } from "../lib/api";
|
|||||||
import { setTimeFormat } from "../lib/timeFormat";
|
import { setTimeFormat } from "../lib/timeFormat";
|
||||||
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||||
import { usePluginPages } from "../plugins/manifest";
|
|
||||||
import { PluginSlot } from "../plugins/PluginSlot";
|
import { PluginSlot } from "../plugins/PluginSlot";
|
||||||
|
|
||||||
function quotaLabel(q: QuotaOut): string {
|
function quotaLabel(q: QuotaOut): string {
|
||||||
@@ -48,7 +47,6 @@ const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
|
|||||||
|
|
||||||
export function MailLayout() {
|
export function MailLayout() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const pluginPages = usePluginPages();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const match = useMatch("/mail/t/:threadId");
|
const match = useMatch("/mail/t/:threadId");
|
||||||
@@ -117,10 +115,17 @@ export function MailLayout() {
|
|||||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||||
const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
|
const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
|
||||||
const orderParam: "asc" | "desc" = listOrder === "newest" ? "desc" : "asc";
|
const orderParam: "asc" | "desc" = listOrder === "newest" ? "desc" : "asc";
|
||||||
|
const labelView = view.startsWith("label:") ? view.slice("label:".length) : null;
|
||||||
const threadsQuery = useQuery({
|
const threadsQuery = useQuery({
|
||||||
queryKey: ["threads", view, filter, orderParam, page],
|
queryKey: ["threads", view, filter, orderParam, page],
|
||||||
queryFn: () =>
|
queryFn: async () => {
|
||||||
api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam }),
|
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,
|
enabled: !isDrafts && !searching,
|
||||||
placeholderData: keepPreviousData, // keep the current page visible while the next loads
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-screen bg-app-bg text-app-fg">
|
<div className="flex h-screen bg-app-bg text-app-fg">
|
||||||
<BackfillBanner />
|
<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). */}
|
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
|
||||||
{!collapsed && <PluginSlot name="sidebar.section" />}
|
{!collapsed && <PluginSlot name="sidebar.section" />}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -719,6 +734,14 @@ export function MailLayout() {
|
|||||||
</svg>
|
</svg>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
{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")}>
|
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
|
||||||
{t("filter.all")}
|
{t("filter.all")}
|
||||||
</FilterTab>
|
</FilterTab>
|
||||||
@@ -728,6 +751,8 @@ export function MailLayout() {
|
|||||||
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
|
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
|
||||||
{t("filter.pinned")}
|
{t("filter.pinned")}
|
||||||
</FilterTab>
|
</FilterTab>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="-mr-1.5 ml-auto flex items-center gap-1">
|
<div className="-mr-1.5 ml-auto flex items-center gap-1">
|
||||||
{(currentFolder === "trash" || currentFolder === "junk") && (
|
{(currentFolder === "trash" || currentFolder === "junk") && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -249,13 +249,18 @@ const CSS = `
|
|||||||
.cal-panelfoot button{display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;font:inherit;font-size:13px}
|
.cal-panelfoot button{display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;font:inherit;font-size:13px}
|
||||||
.cal-panelfoot button:hover{color:rgb(var(--app-fg))}
|
.cal-panelfoot button:hover{color:rgb(var(--app-fg))}
|
||||||
.cal-panelcal{position:relative}
|
.cal-panelcal{position:relative}
|
||||||
|
.cal-panelmenu{position:relative}
|
||||||
|
.cal-panelcalbtn{display:flex;align-items:center;gap:6px;background:none;border:0;outline:none;color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px}
|
||||||
|
.cal-panelcalbtn:hover{color:rgb(var(--app-muted))}
|
||||||
|
.cal-ddsec{padding:5px 10px 2px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:rgb(var(--app-faint))}
|
||||||
|
.cal-ddsep{border-top:1px solid rgb(var(--app-border));margin:4px 0}
|
||||||
.cal-embed .cal-searchbox{margin:0}
|
.cal-embed .cal-searchbox{margin:0}
|
||||||
.cal-embed .cal-form{right:8px;width:auto;top:52px;max-height:calc(100% - 64px)}
|
.cal-embed .cal-form{right:8px;width:auto;top:52px;max-height:calc(100% - 64px)}
|
||||||
.cal-embed .cal-pop{position:fixed}
|
.cal-embed .cal-pop{position:fixed}
|
||||||
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
|
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
|
||||||
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
|
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
|
||||||
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex}
|
.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex;outline:none}
|
||||||
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
.cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
.cal-searchbox{flex:1;max-width:520px;margin:0 auto;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
|
.cal-searchbox{flex:1;max-width:520px;margin:0 auto;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
|
||||||
.cal-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
|
.cal-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
|
||||||
@@ -385,12 +390,16 @@ const CSS = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export default function mount(el, ctx, props) {
|
export default function mount(el, ctx, props) {
|
||||||
if (!document.getElementById("cal-styles")) {
|
// Always (re)write the stylesheet so a newer bundle version's CSS wins — the
|
||||||
const style = document.createElement("style");
|
// singleton element would otherwise keep a stale copy from an earlier mount,
|
||||||
style.id = "cal-styles";
|
// leaving new classes (e.g. .cal-panelcalbtn) unstyled = default button frame.
|
||||||
style.textContent = CSS;
|
let calStyle = document.getElementById("cal-styles");
|
||||||
document.head.appendChild(style);
|
if (!calStyle) {
|
||||||
|
calStyle = document.createElement("style");
|
||||||
|
calStyle.id = "cal-styles";
|
||||||
|
document.head.appendChild(calStyle);
|
||||||
}
|
}
|
||||||
|
calStyle.textContent = CSS;
|
||||||
|
|
||||||
// Embed mode: the SAME app runs compactly inside Mail's right panel — reusing the
|
// Embed mode: the SAME app runs compactly inside Mail's right panel — reusing the
|
||||||
// agenda, mini-month, event popover, event form and search results verbatim, so
|
// agenda, mini-month, event popover, event form and search results verbatim, so
|
||||||
@@ -417,6 +426,9 @@ export default function mount(el, ctx, props) {
|
|||||||
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
|
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
|
||||||
searchOpen: false, // embed: search box revealed
|
searchOpen: false, // embed: search box revealed
|
||||||
panelCalOpen: false, // embed: "All calendars" dropdown open
|
panelCalOpen: false, // embed: "All calendars" dropdown open
|
||||||
|
panelMenuOpen: false, // embed: "..." view-options menu open
|
||||||
|
panelMini: "month", // embed mini calendar: month | week
|
||||||
|
panelView: "agenda", // embed agenda: day | 3days | agenda
|
||||||
me: "",
|
me: "",
|
||||||
quota: "",
|
quota: "",
|
||||||
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
||||||
@@ -878,8 +890,9 @@ export default function mount(el, ctx, props) {
|
|||||||
function renderEmbed() {
|
function renderEmbed() {
|
||||||
el.innerHTML = `<div class="cal-app cal-embed">
|
el.innerHTML = `<div class="cal-app cal-embed">
|
||||||
${panelBar()}
|
${panelBar()}
|
||||||
|
${state.search ? "" : `<div class="cal-embed-mini">${miniMonth()}</div>`}
|
||||||
<div class="cal-embed-body">
|
<div class="cal-embed-body">
|
||||||
${state.search ? searchPanelHtml() : `<div class="cal-embed-mini">${miniMonth()}</div>${agenda()}`}
|
${state.search ? searchPanelHtml() : agenda()}
|
||||||
</div>
|
</div>
|
||||||
${panelFoot()}
|
${panelFoot()}
|
||||||
${state.composing ? formHtml() : ""}
|
${state.composing ? formHtml() : ""}
|
||||||
@@ -895,12 +908,28 @@ export default function mount(el, ctx, props) {
|
|||||||
const cur = state.hidden.size && state.calendars.length - state.hidden.size === 1
|
const cur = state.hidden.size && state.calendars.length - state.hidden.size === 1
|
||||||
? state.calendars.find((c) => !state.hidden.has(c.id))
|
? state.calendars.find((c) => !state.hidden.has(c.id))
|
||||||
: null;
|
: null;
|
||||||
|
// Inline resets guarantee no default-button frame/background/focus-ring even if
|
||||||
|
// an older injected stylesheet is missing these classes.
|
||||||
|
const plain = "border:0;background:none;outline:none;box-shadow:none";
|
||||||
return `<div class="cal-panelbar">
|
return `<div class="cal-panelbar">
|
||||||
<button class="cal-iconbtn" data-act="new" title="New event"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
|
<button class="cal-iconbtn" style="${plain}" data-act="new" title="New event"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
|
||||||
<button class="cal-iconbtn${state.searchOpen ? " on" : ""}" data-act="panel-search" title="Search"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></button>
|
<button class="cal-iconbtn${state.searchOpen ? " on" : ""}" style="${plain}" data-act="panel-search" title="Search"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></button>
|
||||||
|
<div class="cal-dd cal-panelmenu">
|
||||||
|
<button class="cal-iconbtn" style="${plain}" data-act="panel-menu" title="Options"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></button>
|
||||||
|
${state.panelMenuOpen ? `<div class="cal-ddpop" style="left:0;min-width:170px">
|
||||||
|
<div class="cal-ddsec">Mini calendar</div>
|
||||||
|
<button data-panelmini="week">${state.panelMini === "week" ? "✓ " : " "}Week</button>
|
||||||
|
<button data-panelmini="month">${state.panelMini === "month" ? "✓ " : " "}Month</button>
|
||||||
|
<div class="cal-ddsep"></div>
|
||||||
|
<div class="cal-ddsec">View</div>
|
||||||
|
<button data-panelview="day">${state.panelView === "day" ? "✓ " : " "}Day</button>
|
||||||
|
<button data-panelview="3days">${state.panelView === "3days" ? "✓ " : " "}3 days</button>
|
||||||
|
<button data-panelview="agenda">${state.panelView === "agenda" ? "✓ " : " "}Agenda</button>
|
||||||
|
</div>` : ""}
|
||||||
|
</div>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<div class="cal-dd cal-panelcal">
|
<div class="cal-dd cal-panelcal">
|
||||||
<button class="cal-iconbtn" data-act="panelcal" style="width:auto;gap:6px;padding:6px 8px;font-size:13px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>${cur ? esc(cur.name) : "All calendars"} <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
|
<button class="cal-panelcalbtn" style="display:flex;align-items:center;gap:6px;${plain};color:rgb(var(--app-fg));cursor:pointer;font:inherit;font-size:13px;padding:6px 4px" data-act="panelcal"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>${cur ? esc(cur.name) : "All calendars"} <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
|
||||||
${state.panelCalOpen ? `<div class="cal-ddpop" style="right:0;left:auto">
|
${state.panelCalOpen ? `<div class="cal-ddpop" style="right:0;left:auto">
|
||||||
<button data-panelcal="all">${!cur ? "✓ " : ""}All calendars</button>
|
<button data-panelcal="all">${!cur ? "✓ " : ""}All calendars</button>
|
||||||
${state.calendars.map((c) => `<button data-panelcal="${c.id}">${cur && cur.id === c.id ? "✓ " : ""}<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${esc(c.color)};margin-right:4px"></span>${esc(c.name)}</button>`).join("")}
|
${state.calendars.map((c) => `<button data-panelcal="${c.id}">${cur && cur.id === c.id ? "✓ " : ""}<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${esc(c.color)};margin-right:4px"></span>${esc(c.name)}</button>`).join("")}
|
||||||
@@ -1003,11 +1032,14 @@ export default function mount(el, ctx, props) {
|
|||||||
|
|
||||||
function miniMonth() {
|
function miniMonth() {
|
||||||
const m = state.mini;
|
const m = state.mini;
|
||||||
const gs = startOfWeek(startOfMonth(m));
|
// Embed "Mini calendar" option: Week shows just the anchor's week row.
|
||||||
|
const week = state.embed && state.panelMini === "week";
|
||||||
|
const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
|
||||||
|
const cellCount = week ? 7 : 42;
|
||||||
let cells = "";
|
let cells = "";
|
||||||
for (let i = 0; i < 42; i++) {
|
for (let i = 0; i < cellCount; i++) {
|
||||||
const d = addDays(gs, i);
|
const d = addDays(gs, i);
|
||||||
const cls = [d.getMonth() !== m.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
|
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" ");
|
||||||
cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
|
cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
|
||||||
}
|
}
|
||||||
return `<div class="cal-mini">
|
return `<div class="cal-mini">
|
||||||
@@ -1123,7 +1155,7 @@ export default function mount(el, ctx, props) {
|
|||||||
const when = ev.all_day ? "All day" : `${hhmm(ev._s)} – ${hhmm(ev._e)} ${tzAbbr(ev.tz)} (${durLabel})`;
|
const when = ev.all_day ? "All day" : `${hhmm(ev._s)} – ${hhmm(ev._e)} ${tzAbbr(ev.tz)} (${durLabel})`;
|
||||||
const left = Math.min(rect.right + 8, window.innerWidth - 302);
|
const left = Math.min(rect.right + 8, window.innerWidth - 302);
|
||||||
const top = Math.min(Math.max(rect.top, 8), window.innerHeight - 230);
|
const top = Math.min(Math.max(rect.top, 8), window.innerHeight - 230);
|
||||||
return `<div class="cal-popoverlay" style="position:absolute;inset:0;z-index:59" data-act="pop-close"></div>
|
return `<div class="cal-popoverlay" style="position:fixed;inset:0;z-index:59" data-act="pop-close"></div>
|
||||||
<div class="cal-pop" style="left:${left}px;top:${top}px">
|
<div class="cal-pop" style="left:${left}px;top:${top}px">
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="cap">${esc(cal ? cal.name : "")}</div>
|
<div class="cap">${esc(cal ? cal.name : "")}</div>
|
||||||
@@ -1258,15 +1290,18 @@ export default function mount(el, ctx, props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function agenda() {
|
function agenda() {
|
||||||
const start = startOfMonth(state.anchor);
|
// Embed "View" option: Agenda = the month; Day/3 days = a short run from the anchor.
|
||||||
|
const short = state.embed && state.panelView !== "agenda";
|
||||||
|
const count = short ? (state.panelView === "day" ? 1 : 3) : 30;
|
||||||
|
const start = short ? startOfDay(state.anchor) : startOfMonth(state.anchor);
|
||||||
const evs = visibleEvents();
|
const evs = visibleEvents();
|
||||||
const rightAction = state.embed
|
const rightAction = state.embed
|
||||||
? `<button class="cal-agenda-today" data-act="mini-today">TODAY</button>`
|
? `<button class="cal-agenda-today" data-act="mini-today">TODAY</button>`
|
||||||
: `<button class="cal-iconbtn" data-act="panel">✕</button>`;
|
: `<button class="cal-iconbtn" data-act="panel">✕</button>`;
|
||||||
let html = `<div class="cal-mainhead"><span class="cal-title">${MONTHS[start.getMonth()]} <span>${start.getFullYear()}</span></span><div style="flex:1"></div>${rightAction}</div><div class="cal-agenda">`;
|
let html = `<div class="cal-mainhead"><span class="cal-title">${MONTHS[start.getMonth()]} <span>${start.getFullYear()}</span></span><div style="flex:1"></div>${rightAction}</div><div class="cal-agenda">`;
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const d = addDays(start, i);
|
const d = addDays(start, i);
|
||||||
if (d.getMonth() !== start.getMonth()) break;
|
if (!short && d.getMonth() !== start.getMonth()) break;
|
||||||
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
|
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
|
||||||
html += `<div class="day"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
|
html += `<div class="day"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
|
||||||
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
|
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
|
||||||
@@ -1292,8 +1327,11 @@ export default function mount(el, ctx, props) {
|
|||||||
state.panelCalOpen = false; render();
|
state.panelCalOpen = false; render();
|
||||||
});
|
});
|
||||||
act("open-newtab", () => window.open("/p/calendar", "_blank", "noopener"));
|
act("open-newtab", () => window.open("/p/calendar", "_blank", "noopener"));
|
||||||
if (state.panelCalOpen) {
|
act("panel-menu", (e) => { e.stopPropagation(); state.panelMenuOpen = !state.panelMenuOpen; render(); });
|
||||||
setTimeout(() => { const close = () => { state.panelCalOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0);
|
on("[data-panelmini]", "click", (e) => { e.stopPropagation(); state.panelMini = e.currentTarget.getAttribute("data-panelmini"); state.panelMenuOpen = false; render(); });
|
||||||
|
on("[data-panelview]", "click", (e) => { e.stopPropagation(); state.panelView = e.currentTarget.getAttribute("data-panelview"); state.panelMenuOpen = false; render(); });
|
||||||
|
if (state.panelCalOpen || state.panelMenuOpen) {
|
||||||
|
setTimeout(() => { const close = () => { state.panelCalOpen = false; state.panelMenuOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0);
|
||||||
}
|
}
|
||||||
// --- search ---
|
// --- search ---
|
||||||
on(".cal-search", "input", (e) => {
|
on(".cal-search", "input", (e) => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""``labels`` — per-message labels, built entirely on slots + KV (SPEC §11, §12 M3).
|
"""``labels`` — per-message labels, built entirely on slots + KV (SPEC §11, §12 M3).
|
||||||
|
|
||||||
No plugin tables and no core diffs: labels live in the namespaced KV store (keyed
|
No plugin tables and no core diffs: labels live in the namespaced KV store (keyed
|
||||||
per user, so isolation is by construction), surfaced through a ``message.actions``
|
per user, so isolation is by construction). The labelling UI now lives in Mail's
|
||||||
slot (add/remove on a message) and a ``sidebar.section`` slot (list all labels).
|
own Actions menu + label chips (which read the plugin's API directly); only the
|
||||||
|
``sidebar.section`` slot (list all labels) remains as a plugin surface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,7 +17,6 @@ plugin = Plugin(
|
|||||||
router=router,
|
router=router,
|
||||||
ui_manifest=UiManifest(
|
ui_manifest=UiManifest(
|
||||||
slots=[
|
slots=[
|
||||||
Slot(name="message.actions", bundle="/api/plugins/labels/ui/actions.js"),
|
|
||||||
Slot(name="sidebar.section", bundle="/api/plugins/labels/ui/sidebar.js"),
|
Slot(name="sidebar.section", bundle="/api/plugins/labels/ui/sidebar.js"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ class LabelsIn(BaseModel):
|
|||||||
labels: list[str]
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class IdsIn(BaseModel):
|
||||||
|
ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class NameIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _remember(kv: object, labels: list[str]) -> None:
|
||||||
|
"""Merge ``labels`` into the user's known-label set."""
|
||||||
|
known = set(await kv.get(_ALL_KEY, [])) # type: ignore[attr-defined]
|
||||||
|
if not labels or set(labels) <= known:
|
||||||
|
return
|
||||||
|
known.update(labels)
|
||||||
|
await kv.set(_ALL_KEY, sorted(known)) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/message/{message_id}/labels", response_model=list[str])
|
@router.get("/message/{message_id}/labels", response_model=list[str])
|
||||||
async def get_labels(message_id: str, user_id: CurrentUserId) -> list[str]:
|
async def get_labels(message_id: str, user_id: CurrentUserId) -> list[str]:
|
||||||
return await _SDK.kv(user_id).get(message_id, [])
|
return await _SDK.kv(user_id).get(message_id, [])
|
||||||
@@ -37,17 +54,77 @@ async def set_labels(message_id: str, body: LabelsIn, user_id: CurrentUserId) ->
|
|||||||
labels = sorted({name.strip() for name in body.labels if name.strip()})
|
labels = sorted({name.strip() for name in body.labels if name.strip()})
|
||||||
kv = _SDK.kv(user_id)
|
kv = _SDK.kv(user_id)
|
||||||
await kv.set(message_id, labels)
|
await kv.set(message_id, labels)
|
||||||
known = set(await kv.get(_ALL_KEY, []))
|
await _remember(kv, labels)
|
||||||
known.update(labels)
|
|
||||||
await kv.set(_ALL_KEY, sorted(known))
|
|
||||||
return {"status": "ok", "labels": labels}
|
return {"status": "ok", "labels": labels}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages/labels")
|
||||||
|
async def batch_message_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]:
|
||||||
|
"""Labels for a set of messages (the reading pane aggregates the thread union)."""
|
||||||
|
kv = _SDK.kv(user_id)
|
||||||
|
return {mid: await kv.get(mid, []) for mid in body.ids}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/thread/{thread_id}/labels", dependencies=[Depends(require_csrf)])
|
||||||
|
async def set_thread_labels(
|
||||||
|
thread_id: str, body: LabelsIn, user_id: CurrentUserId
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""The thread's aggregated labels (union of its messages), written by the reading
|
||||||
|
pane so the message list — which has no message ids — can display them."""
|
||||||
|
labels = sorted({name.strip() for name in body.labels if name.strip()})
|
||||||
|
kv = _SDK.kv(user_id)
|
||||||
|
await kv.set(f"t:{thread_id}", labels)
|
||||||
|
await _remember(kv, labels)
|
||||||
|
return {"status": "ok", "labels": labels}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/threads/labels")
|
||||||
|
async def batch_thread_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]:
|
||||||
|
kv = _SDK.kv(user_id)
|
||||||
|
out = {tid: await kv.get(f"t:{tid}", []) for tid in body.ids}
|
||||||
|
return {tid: labels for tid, labels in out.items() if labels}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/labels", response_model=list[str])
|
@router.get("/labels", response_model=list[str])
|
||||||
async def all_labels(user_id: CurrentUserId) -> list[str]:
|
async def all_labels(user_id: CurrentUserId) -> list[str]:
|
||||||
return await _SDK.kv(user_id).get(_ALL_KEY, [])
|
return await _SDK.kv(user_id).get(_ALL_KEY, [])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/labels/{name}/threads", response_model=list[str])
|
||||||
|
async def label_threads(name: str, user_id: CurrentUserId) -> list[str]:
|
||||||
|
"""Thread ids whose aggregate labels include ``name`` — powers the label view
|
||||||
|
in the mail list. Scans the per-thread label keys (``t:<thread_id>``)."""
|
||||||
|
items = await _SDK.kv(user_id).items()
|
||||||
|
return [
|
||||||
|
key[2:]
|
||||||
|
for key, labels in items.items()
|
||||||
|
if key.startswith("t:") and isinstance(labels, list) and name in labels
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/labels", dependencies=[Depends(require_csrf)])
|
||||||
|
async def create_label(body: NameIn, user_id: CurrentUserId) -> list[str]:
|
||||||
|
name = body.name.strip()
|
||||||
|
kv = _SDK.kv(user_id)
|
||||||
|
if name:
|
||||||
|
await _remember(kv, [name])
|
||||||
|
return await kv.get(_ALL_KEY, [])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/labels/{name}", dependencies=[Depends(require_csrf)])
|
||||||
|
async def delete_label(name: str, user_id: CurrentUserId) -> list[str]:
|
||||||
|
"""Delete a label everywhere: drop it from the known set and unhook it from every
|
||||||
|
message (``<message_id>``) and thread (``t:<thread_id>``) it was attached to."""
|
||||||
|
kv = _SDK.kv(user_id)
|
||||||
|
for key, labels in (await kv.items()).items():
|
||||||
|
if key == _ALL_KEY or not isinstance(labels, list) or name not in labels:
|
||||||
|
continue
|
||||||
|
await kv.set(key, [n for n in labels if n != name])
|
||||||
|
known = [n for n in await kv.get(_ALL_KEY, []) if n != name]
|
||||||
|
await kv.set(_ALL_KEY, known)
|
||||||
|
return known
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ui/{filename}")
|
@router.get("/ui/{filename}")
|
||||||
async def bundle(filename: str) -> FileResponse:
|
async def bundle(filename: str) -> FileResponse:
|
||||||
if filename not in _BUNDLES:
|
if filename not in _BUNDLES:
|
||||||
|
|||||||
@@ -1,20 +1,129 @@
|
|||||||
// labels · sidebar.section slot (SPEC §11.3). Lists the user's known labels;
|
// labels · sidebar.section slot (SPEC §11.3). A collapsible "LABELS" section that
|
||||||
// refreshes when the message.actions slot signals a change (same-document event).
|
// lines up with the folder rows above: monocolor tag icons, a + to create (inline
|
||||||
// Styling uses the plugin UI kit (.wm-* classes, see docs/plugins.md).
|
// popover), a hover "−" per label to delete (removed from all mail after a confirm),
|
||||||
|
// and click-to-filter the mail list. Refreshes on `webmail:labels-changed`, which
|
||||||
|
// this bundle and Mail's Actions menu both dispatch. Themed via rgb(var(--app-*)).
|
||||||
|
|
||||||
|
const TAG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="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>`;
|
||||||
|
const chev = (open) => `<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(${open ? 0 : -90}deg)"><path d="M4 6l4 4 4-4"/></svg>`;
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById("lb-styles")) return;
|
||||||
|
const s = document.createElement("style");
|
||||||
|
s.id = "lb-styles";
|
||||||
|
s.textContent = `
|
||||||
|
.lb-sec{position:relative;padding:4px 0 0}
|
||||||
|
.lb-head{display:flex;align-items:center;gap:5px;padding:10px 8px 2px 12px}
|
||||||
|
.lb-headbtn{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:rgb(var(--app-faint));font:inherit;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}
|
||||||
|
.lb-add{margin-left:auto;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:2px 6px;border-radius:4px;font-size:15px;line-height:1}
|
||||||
|
.lb-add:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
.lb-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px}
|
||||||
|
.lb-row{position:relative;display:flex;align-items:center}
|
||||||
|
.lb-item{flex:1;min-width:0;display:flex;align-items:center;gap:10px;text-align:left;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:6px 12px;border-radius:8px;font:inherit;font-size:15px}
|
||||||
|
.lb-item:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
.lb-item .ic{color:rgb(var(--app-faint));flex:0 0 auto;display:inline-flex}
|
||||||
|
.lb-item .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.lb-del{position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border:0;border-radius:5px;background:rgb(var(--app-hover));color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1}
|
||||||
|
.lb-row:hover .lb-del{opacity:1}
|
||||||
|
.lb-del:hover{background:rgb(var(--app-active));color:#ef4444}
|
||||||
|
.lb-empty{color:rgb(var(--app-faint));padding:5px 12px;font-size:13px}
|
||||||
|
.lb-ovl{position:fixed;inset:0;z-index:60}
|
||||||
|
.lb-pop{position:absolute;z-index:61;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.4);padding:10px;width:210px}
|
||||||
|
.lb-popmsg{font-size:13px;color:rgb(var(--app-fg));margin-bottom:8px}
|
||||||
|
.lb-popinput{width:100%;box-sizing:border-box;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:6px;padding:6px 8px;color:rgb(var(--app-fg));font:inherit;font-size:13px;outline:none}
|
||||||
|
.lb-popinput:focus{border-color:rgb(var(--app-accent))}
|
||||||
|
.lb-popbtns{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}
|
||||||
|
.lb-btn{border:0;border-radius:6px;padding:5px 12px;cursor:pointer;font:inherit;font-size:13px;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
.lb-btn-primary{background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));font-weight:600}
|
||||||
|
.lb-btn-danger{background:#ef4444;color:#fff;font-weight:600}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
export default function mount(el, ctx) {
|
export default function mount(el, ctx) {
|
||||||
el.innerHTML = `
|
injectStyles();
|
||||||
<div style="padding:8px 12px 0">
|
let collapsed = localStorage.getItem("labels.collapsed") !== "0"; // collapsed by default
|
||||||
<div class="wm-faint" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;padding-bottom:4px">Labels</div>
|
let labels = [];
|
||||||
<ul data-list class="wm-list"></ul>
|
let adding = null; // null | { x, y }
|
||||||
|
let deleting = null; // null | { name, x, y }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
el.innerHTML = `<div class="lb-sec">
|
||||||
|
<div class="lb-head">
|
||||||
|
<button class="lb-headbtn" data-act="toggle">Labels ${chev(!collapsed)}</button>
|
||||||
|
<button class="lb-add" data-act="add" title="Create label">+</button>
|
||||||
|
</div>
|
||||||
|
${collapsed ? "" : `<ul class="lb-list">${
|
||||||
|
labels.length
|
||||||
|
? labels.map((name) => `<li class="lb-row">
|
||||||
|
<button class="lb-item" data-label="${esc(name)}"><span class="ic">${TAG}</span><span class="nm">${esc(name)}</span></button>
|
||||||
|
<button class="lb-del" data-del="${esc(name)}" title="Delete label">−</button>
|
||||||
|
</li>`).join("")
|
||||||
|
: `<li class="lb-empty">No labels yet</li>`
|
||||||
|
}</ul>`}
|
||||||
|
${adding ? `<div class="lb-ovl" data-act="popclose"></div><div class="lb-pop" style="position:fixed;left:${Math.max(8, Math.min(adding.x, window.innerWidth - 226))}px;top:${adding.y + 6}px">
|
||||||
|
<input class="lb-popinput" data-newlabel placeholder="Label name"/>
|
||||||
|
<div class="lb-popbtns"><button class="lb-btn" data-act="add-cancel">Cancel</button><button class="lb-btn lb-btn-primary" data-act="add-ok">Create</button></div>
|
||||||
|
</div>` : ""}
|
||||||
|
${deleting ? `<div class="lb-ovl" data-act="popclose"></div><div class="lb-pop" style="left:${Math.max(8, Math.min(deleting.x, window.innerWidth - 226))}px;top:${deleting.y + 6}px;position:fixed">
|
||||||
|
<div class="lb-popmsg">Delete “${esc(deleting.name)}”? It will be removed from all mail.</div>
|
||||||
|
<div class="lb-popbtns"><button class="lb-btn" data-act="del-cancel">Cancel</button><button class="lb-btn lb-btn-danger" data-act="del-ok">Delete</button></div>
|
||||||
|
</div>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
const list = el.querySelector("[data-list]");
|
wire();
|
||||||
|
if (adding) { const inp = el.querySelector("[data-newlabel]"); if (inp) inp.focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire() {
|
||||||
|
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
||||||
|
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||||||
|
|
||||||
|
act("toggle", () => {
|
||||||
|
collapsed = !collapsed;
|
||||||
|
try { localStorage.setItem("labels.collapsed", collapsed ? "1" : "0"); } catch { /* ignore */ }
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
act("add", (e) => { const r = e.currentTarget.getBoundingClientRect(); adding = { x: r.right - 210, y: r.bottom }; deleting = null; render(); });
|
||||||
|
act("add-cancel", () => { adding = null; render(); });
|
||||||
|
act("popclose", () => { adding = null; deleting = null; render(); });
|
||||||
|
act("add-ok", createLabel);
|
||||||
|
on("[data-newlabel]", "keydown", (e) => {
|
||||||
|
if (e.key === "Enter") createLabel();
|
||||||
|
else if (e.key === "Escape") { adding = null; render(); }
|
||||||
|
});
|
||||||
|
on("[data-label]", "click", (e) => ctx.navigate(`/mail?label=${encodeURIComponent(e.currentTarget.getAttribute("data-label"))}`));
|
||||||
|
on("[data-del]", "click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
deleting = { name: e.currentTarget.getAttribute("data-del"), x: r.right - 210, y: r.bottom };
|
||||||
|
adding = false;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
act("del-cancel", () => { deleting = null; render(); });
|
||||||
|
act("del-ok", deleteLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLabel() {
|
||||||
|
const inp = el.querySelector("[data-newlabel]");
|
||||||
|
const name = inp ? inp.value.trim() : "";
|
||||||
|
if (!name) { adding = null; render(); return; }
|
||||||
|
await ctx.api.pluginFetch("/labels", { method: "POST", body: JSON.stringify({ name }) });
|
||||||
|
adding = null;
|
||||||
|
window.dispatchEvent(new Event("webmail:labels-changed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLabel() {
|
||||||
|
const name = deleting && deleting.name;
|
||||||
|
deleting = null;
|
||||||
|
if (!name) { render(); return; }
|
||||||
|
await ctx.api.pluginFetch(`/labels/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||||
|
window.dispatchEvent(new Event("webmail:labels-changed"));
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const labels = await ctx.api.pluginFetch("/labels");
|
try { labels = await ctx.api.pluginFetch("/labels"); } catch { labels = []; }
|
||||||
list.innerHTML = labels.length
|
render();
|
||||||
? labels.map((name) => `<li>🏷 ${esc(name)}</li>`).join("")
|
|
||||||
: `<li class="wm-faint">No labels yet</li>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
@@ -23,8 +132,4 @@ export default function mount(el, ctx) {
|
|||||||
window.removeEventListener("webmail:labels-changed", refresh);
|
window.removeEventListener("webmail:labels-changed", refresh);
|
||||||
el.innerHTML = "";
|
el.innerHTML = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 827 KiB |
Reference in New Issue
Block a user