diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py index 1284224..ab25826 100644 --- a/backend/app/api/messages.py +++ b/backend/app/api/messages.py @@ -37,6 +37,14 @@ _INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "applicati _DOC_TEMPLATE = """ {body}""" @@ -112,7 +124,7 @@ async def message_html( return HTMLResponse(content=_wrap(html_part.content)) text_part = next((p for p in parts if p.kind == "text"), None) body = html_lib.escape(text_part.content) if text_part else "" - return HTMLResponse(content=_wrap(f"
{body}
")) + return HTMLResponse(content=_wrap(f'
{body}
')) @router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)]) diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index 019979b..d4ddd38 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -83,12 +83,21 @@ async def list_threads( offset: int = Query(0, ge=0), filter: str = Query("all"), # all | unread | pinned 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: # Collect WHERE conditions once so the page query and the total-count query # stay in lockstep (offset pagination needs an accurate grand total). 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 # inbox mailbox. Keeps the per-account view consistent with the unified # Inbox (archived/trashed/sent threads don't leak in). diff --git a/frontend/public/fonts/Lato-Bold.ttf b/frontend/public/fonts/Lato-Bold.ttf new file mode 100644 index 0000000..016068b Binary files /dev/null and b/frontend/public/fonts/Lato-Bold.ttf differ diff --git a/frontend/public/fonts/Lato-Italic.ttf b/frontend/public/fonts/Lato-Italic.ttf new file mode 100644 index 0000000..0d0f69e Binary files /dev/null and b/frontend/public/fonts/Lato-Italic.ttf differ diff --git a/frontend/public/fonts/Lato-Regular.ttf b/frontend/public/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..bb2e887 Binary files /dev/null and b/frontend/public/fonts/Lato-Regular.ttf differ diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 4adf0f5..4bd9490 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -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). */}
- {thread.subject} + + {thread.subject}
{thread.snippet}
diff --git a/frontend/src/components/mail/LabelChips.tsx b/frontend/src/components/mail/LabelChips.tsx new file mode 100644 index 0000000..1772d68 --- /dev/null +++ b/frontend/src/components/mail/LabelChips.tsx @@ -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) => ( + + {l} + {onRemove && ( + + )} + + ))} + + ); +} diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index 24f15a7..db10788 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -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(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) => ( + {labelsOpen && ( +
+ {allLabels.map((l) => ( + + ))} + {allLabels.length === 0 && ( +
{t("mail.noLabels")}
+ )} + {creating ? ( + 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" + /> + ) : ( + + )} +
+ )} +
{item(t("mail.delete"), onDelete)}
)} @@ -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). */} @@ -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 (
-

{subject || "(no subject)"}

+
+

{subject || "(no subject)"}

+ removeThreadLabel.mutate(l)} /> +
- setFilter("all")}> - {t("filter.all")} - - setFilter("unread")}> - {t("filter.unread")} - - setFilter("pinned")}> - {t("filter.pinned")} - + {labelView !== null ? ( + + + {labelView} + + + ) : ( + <> + setFilter("all")}> + {t("filter.all")} + + setFilter("unread")}> + {t("filter.unread")} + + setFilter("pinned")}> + {t("filter.pinned")} + + + )}
{(currentFolder === "trash" || currentFolder === "junk") && ( - + + +
+ + ${state.panelMenuOpen ? `
+
Mini calendar
+ + +
+
View
+ + + +
` : ""} +
- + ${state.panelCalOpen ? `
${state.calendars.map((c) => ``).join("")} @@ -1003,11 +1032,14 @@ export default function mount(el, ctx, props) { function miniMonth() { 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 = ""; - for (let i = 0; i < 42; i++) { + for (let i = 0; i < cellCount; 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 += ``; } return `
@@ -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 left = Math.min(rect.right + 8, window.innerWidth - 302); const top = Math.min(Math.max(rect.top, 8), window.innerHeight - 230); - return `
+ return `
${esc(cal ? cal.name : "")}
@@ -1258,15 +1290,18 @@ export default function mount(el, ctx, props) { } 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 rightAction = state.embed ? `` : ``; let html = `
${MONTHS[start.getMonth()]} ${start.getFullYear()}
${rightAction}
`; - for (let i = 0; i < 30; i++) { + for (let i = 0; i < count; 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); html += `
${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}${d.getDate()}
${ dayEvs.length ? dayEvs.map((ev) => `
${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}
`).join("") : `No events` @@ -1292,8 +1327,11 @@ export default function mount(el, ctx, props) { state.panelCalOpen = false; render(); }); act("open-newtab", () => window.open("/p/calendar", "_blank", "noopener")); - if (state.panelCalOpen) { - setTimeout(() => { const close = () => { state.panelCalOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0); + act("panel-menu", (e) => { e.stopPropagation(); state.panelMenuOpen = !state.panelMenuOpen; render(); }); + 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 --- on(".cal-search", "input", (e) => { diff --git a/plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py b/plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py index 7c0a6b8..d689bca 100644 --- a/plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py +++ b/plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py @@ -1,8 +1,9 @@ """``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 -per user, so isolation is by construction), surfaced through a ``message.actions`` -slot (add/remove on a message) and a ``sidebar.section`` slot (list all labels). +per user, so isolation is by construction). The labelling UI now lives in Mail's +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 @@ -16,7 +17,6 @@ plugin = Plugin( router=router, ui_manifest=UiManifest( slots=[ - Slot(name="message.actions", bundle="/api/plugins/labels/ui/actions.js"), Slot(name="sidebar.section", bundle="/api/plugins/labels/ui/sidebar.js"), ], ), diff --git a/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py b/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py index 7848058..e016521 100644 --- a/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py +++ b/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py @@ -27,6 +27,23 @@ class LabelsIn(BaseModel): 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]) async def get_labels(message_id: str, user_id: CurrentUserId) -> list[str]: 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()}) kv = _SDK.kv(user_id) await kv.set(message_id, labels) - known = set(await kv.get(_ALL_KEY, [])) - known.update(labels) - await kv.set(_ALL_KEY, sorted(known)) + await _remember(kv, 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]) async def all_labels(user_id: CurrentUserId) -> list[str]: 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:``).""" + 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 (````) and thread (``t:``) 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}") async def bundle(filename: str) -> FileResponse: if filename not in _BUNDLES: diff --git a/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js b/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js index 84d1bb5..ef4d61f 100644 --- a/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js +++ b/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js @@ -1,20 +1,129 @@ -// labels · sidebar.section slot (SPEC §11.3). Lists the user's known labels; -// refreshes when the message.actions slot signals a change (same-document event). -// Styling uses the plugin UI kit (.wm-* classes, see docs/plugins.md). +// labels · sidebar.section slot (SPEC §11.3). A collapsible "LABELS" section that +// lines up with the folder rows above: monocolor tag icons, a + to create (inline +// 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 = ``; +const chev = (open) => ``; +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) { - el.innerHTML = ` -
-
Labels
-
    + injectStyles(); + let collapsed = localStorage.getItem("labels.collapsed") !== "0"; // collapsed by default + let labels = []; + let adding = null; // null | { x, y } + let deleting = null; // null | { name, x, y } + + function render() { + el.innerHTML = `
    +
    + + +
    + ${collapsed ? "" : `
      ${ + labels.length + ? labels.map((name) => `
    • + + +
    • `).join("") + : `
    • No labels yet
    • ` + }
    `} + ${adding ? `
    + +
    +
    ` : ""} + ${deleting ? `
    +
    Delete “${esc(deleting.name)}”? It will be removed from all mail.
    +
    +
    ` : ""}
    `; - 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() { - const labels = await ctx.api.pluginFetch("/labels"); - list.innerHTML = labels.length - ? labels.map((name) => `
  • 🏷 ${esc(name)}
  • `).join("") - : `
  • No labels yet
  • `; + try { labels = await ctx.api.pluginFetch("/labels"); } catch { labels = []; } + render(); } refresh(); @@ -23,8 +132,4 @@ export default function mount(el, ctx) { window.removeEventListener("webmail:labels-changed", refresh); el.innerHTML = ""; }; - - function esc(s) { - return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); - } } diff --git a/screenshots/labels.jpg b/screenshots/labels.jpg new file mode 100644 index 0000000..a78c793 Binary files /dev/null and b/screenshots/labels.jpg differ