AkurAI Build
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 };
}