Menu
Public repository / master
AkurAI-Mail / src/framework/reader-island.js
Latest change 3af23b0e937520000e1aa43c7e2eb9de2675abd1 - OLI-248: webmail list and reader as framework islands on a signals store by Ólafur Búi Ólafsson
// reader-island.js — the message reader column as a framework island.
//
// `mountReader(root, store)` re-renders the reader whenever the selected message, its
// conversation signals, the VIP list or the folder list change. The message body is the
// server's HTML rendering (`bodyHtml`), which the API has already sanitized.
import { effect, signal } from "./vendor/signals.js";
import { h, replace } from "./dom.js";
import { icon } from "./icons.js";
import { attachmentSizeLabel, displaySignals, moveTargets, provenanceLabel, signalTypeLabel } from "./reader.js";
import { extractEmail } from "../lib/webmail/recipients.js";
/** @type {Record<string, { icon: string, class: string, when: (value: any) => boolean }>} */
const SIGNAL_ICONS = {
replyNeeded: { icon: "MessageCircle", class: "icon-amber", when: (value) => !!value },
followUpDue: { icon: "Clock", class: "icon-blue", when: (value) => !!value },
priority: { icon: "Flag", class: "icon-red", when: (value) => value === "high" },
deadlineDetected: { icon: "Bell", class: "icon-amber", when: (value) => !!value },
};
/**
* @param {string} title
* @param {string} body
* @param {string} iconName
*/
const emptyState = (title, body, iconName) => h("div", { class: "reader-empty" }, h("div", { class: "empty-icon" }, icon(iconName, { size: 25, strokeWidth: 1.5 })), h("strong", null, title), h("p", null, body));
/**
* @param {HTMLElement} root
* @param {ReturnType<typeof import("./store.js").createStore>} store
*/
export function mountReader(root, store) {
const moveMenuOpen = signal(false);
/**
* @param {string} name icon
* @param {string} label accessible name and tooltip
* @param {string} text visible caption
* @param {() => void} onclick
* @param {{ class?: string, fill?: string }} [extra]
*/
const toolbarButton = (name, label, text, onclick, extra = {}) =>
h("button", { class: `toolbar-button ${extra.class ?? ""}`.trim(), "aria-label": label, title: label, onclick }, icon(name, { size: 18, strokeWidth: 1.8, fill: extra.fill }), h("span", null, text));
/** @param {any} message */
function toolbar(message) {
const isVip = store.vipSenders().includes(extractEmail(message.from || ""));
const targets = moveTargets(store.folderList());
const menuWrap = h("div", { class: "menu-wrap", role: "presentation",
onkeydown: (/** @type {KeyboardEvent} */ event) => { if (event.key === "Escape") moveMenuOpen(false); },
onfocusout: (/** @type {FocusEvent} */ event) => { const next = /** @type {Node | null} */ (event.relatedTarget); if (!next || !menuWrap.contains(next)) moveMenuOpen(false); } },
h("button", { class: "toolbar-button", "aria-haspopup": "menu", "aria-expanded": String(moveMenuOpen()), "aria-label": "Move message", title: "Move message", onclick: () => moveMenuOpen(!moveMenuOpen()) }, icon("FolderInput", { size: 18, strokeWidth: 1.8 }), h("span", null, "Move")),
moveMenuOpen() ? h("div", { class: "popup-menu", role: "menu" }, targets.map((item) => h("button", { role: "menuitem", onclick: () => { moveMenuOpen(false); store.post({ action: "message", id: message.id, messageAction: "move", target: item.name }); } }, item.name))) : null);
return h("div", { class: "reader-toolbar", role: "toolbar", "aria-label": "Message actions" },
h("button", { class: "icon-button mobile-back", "aria-label": "Back to messages", title: "Back to messages", onclick: () => store.selected(null) }, icon("ArrowLeft", { size: 18 })),
toolbarButton("Archive", "Archive message", "Archive", () => store.messageAction("archive")),
menuWrap,
toolbarButton("ShieldAlert", "Mark as junk", "Junk", () => store.messageAction("spam")),
toolbarButton("Trash2", "Delete message", "Delete", () => store.deletePending(true), { class: "destructive" }),
h("span", { class: "toolbar-divider" }),
toolbarButton("Reply", "Reply", "Reply", () => store.reply(false)),
toolbarButton("ReplyAll", "Reply all", "Reply all", () => store.reply(true)),
toolbarButton("Forward", "Forward", "Forward", () => store.forward()),
h("span", { class: "toolbar-divider" }),
toolbarButton("Star", message.flagged ? "Remove flag" : "Flag message", "Flag", () => store.messageAction(message.flagged ? "unstar" : "star"), { fill: message.flagged ? "currentColor" : "none" }),
toolbarButton("ShieldAlert", isVip ? "Remove VIP" : "Mark sender VIP", "VIP", () => store.toggleVip(message.from), { class: isVip ? "vip-active" : "", fill: isVip ? "currentColor" : "none" }),
);
}
/** @param {any} convSignal */
function signalsPanel(convSignal) {
const rows = displaySignals(convSignal).filter(([, sig]) => sig && !sig.dismissed);
if (!rows.length) return null;
return h("div", { class: "signals-panel" }, rows.map(([type, sig]) => {
const meta = SIGNAL_ICONS[type];
return h("div", { class: "signal-row" },
h("div", { class: "signal-info" },
meta && meta.when(sig.value) ? icon(meta.icon, { size: 15, class: meta.class }) : null,
h("span", { class: "signal-label" }, signalTypeLabel(type)),
h("span", { class: "signal-prov" }, provenanceLabel(sig.provenance)),
h("span", { class: "signal-conf" }, `${Math.round((sig.confidence ?? 0) * 100)}%`)),
h("div", { class: "signal-evidence" }, (sig.evidence ?? []).map((/** @type {string} */ ev) => h("span", null, ev))),
h("div", { class: "signal-actions" },
h("button", { class: "subtle-button", onclick: () => store.correctSignal(convSignal.conversationId, type, "dismiss") }, "Dismiss"),
type === "priority" ? h("button", { class: "subtle-button", onclick: () => store.correctSignal(convSignal.conversationId, type, "correct", sig.value === "high" ? "normal" : "high") }, "Fix") : null));
}));
}
/** @param {any} message */
function article(message) {
const body = h("div", { class: "message-body" });
body.innerHTML = message.bodyHtml ?? "";
const date = store.selectedDate();
return h("article", { class: "reader-card" },
h("header", { class: "reader-head" },
h("div", { class: "reader-title" }, h("h1", null, message.subject || "(no subject)"), h("p", null, date ? date.toLocaleString() : message.date ?? "")),
h("dl", null,
h("div", null, h("dt", null, "From"), h("dd", null, message.from || "(unknown)")),
h("div", null, h("dt", null, "To"), h("dd", null, message.to || store.current)))),
message.spamReasons?.length ? h("div", { class: "warning" }, icon("ShieldAlert", { size: 18 }), h("span", null, h("strong", null, "Spam signals"), message.spamReasons.join(", "))) : null,
body,
message.attachments?.length ? h("section", { class: "attachments", "aria-label": "Attachments" },
h("strong", null, icon("Paperclip", { size: 17 }), " Attachments"),
h("div", null, message.attachments.map((/** @type {any} */ file) =>
h("button", { class: "attachment", onclick: async () => {
const link = await store.downloadAttachment(file);
if (link) { const a = h("a", { href: link.href, download: link.filename }); a.click(); }
} }, icon("FileText", { size: 18 }), h("span", null, h("b", null, file.filename), h("small", null, attachmentSizeLabel(file.size))), icon("Download", { size: 17 }))))) : null,
);
}
effect(() => {
const message = store.selected();
moveMenuOpen();
store.folderList(); store.vipSenders();
const convSignal = store.signalForSelected();
let content;
if (message) content = [toolbar(message), signalsPanel(convSignal), article(message)];
else if (store.isFirstRun()) content = emptyState("Welcome to your inbox", "New mail lands here as it arrives. Try composing your first message or importing existing mail from Settings.", "PenLine");
else if (store.isNoResults()) content = emptyState("No matching messages", "Nothing here matches your search or filter. Try a different sender, subject, or phrase.", "Inbox");
else if (store.isEmptyMailbox()) content = emptyState("This folder is empty", "Nothing has landed here yet.", "Inbox");
else content = emptyState("Choose a conversation", "Select a message to read it here. Your inbox stays visible while you work.", "Star");
replace(root, content);
});
return { moveMenuOpen };
}