AkurAI Build
Menu

Public repository / 4f0b1d374fab4075f2b552b5f250bca5f28afee9

AkurAI-Mail / src/framework/list-island.js

Latest change 3af23b0e937520000e1aa43c7e2eb9de2675abd1 - OLI-248: webmail list and reader as framework islands on a signals store by Ólafur Búi Ólafsson

// list-island.js — the folder column and the windowed message list as a framework island.
//
// `mountList(root, store)` renders into `root` (which holds an <aside data-folders> and a
// <section data-messages>) and keeps both in sync with the store's signals. Only the threads
// near the viewport exist in the DOM (src/lib/webmail/virtual.js does the math), the folder
// tree remembers collapsed folders in localStorage, and the selected row scrolls into view.
import { effect, signal } from "./vendor/signals.js";
import { h, replace } from "./dom.js";
import { icon } from "./icons.js";
import { groupThreads } from "../lib/webmail/threads.js";
import { buildFolderTree, visibleFolders } from "../lib/webmail/folders.js";
import { threadOffsets, visibleRange } from "../lib/webmail/virtual.js";
import { createFolderExpand } from "./folder-expand.js";
import { scrollSelectedIntoView } from "./shortcuts.js";
import { signalColorClass, signalIconName } from "./signal-badge.js";

export const ROW_HEIGHT = 106;
export const CHILD_ROW_HEIGHT = 36;
const OVERSCAN = 6;

/** @type {Record<string, string>} */
const FOLDER_ICONS = { Inbox: "Inbox", Spam: "ShieldAlert", Trash: "Trash2", Sent: "Send", Drafts: "FilePen", Archive: "Archive" };

/** @param {string} name */
export const folderIcon = (name) => FOLDER_ICONS[name] ?? "FileText";

/** @param {{ from?: string, to?: string }} message */
export const senderLabel = (message) => message.from || message.to || "(unknown)";

/**
 * Today's messages show the time, older ones the day.
 * @param {string} value
 * @param {Date} [today]
 */
export function shortDate(value, today = new Date()) {
  const date = new Date(value);
  if (Number.isNaN(date.valueOf())) return value || "";
  if (date.toDateString() === today.toDateString()) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  return date.toLocaleDateString([], { month: "short", day: "numeric" });
}

/**
 * Height of a thread row, counting expanded replies.
 * @param {{ key: string, messages: unknown[] }} thread
 * @param {Record<string, boolean>} expanded
 */
export function threadHeight(thread, expanded) {
  return ROW_HEIGHT + (thread.messages.length > 1 && expanded[thread.key] ? (thread.messages.length - 1) * CHILD_ROW_HEIGHT : 0);
}

/**
 * Which threads to draw for a scroll position, with the padding that keeps the scrollbar honest.
 * @template {{ key: string, messages: unknown[] }} T
 * @param {T[]} threads
 * @param {Record<string, boolean>} expanded
 * @param {number} scrollTop
 * @param {number} viewportHeight
 */
export function listWindow(threads, expanded, scrollTop, viewportHeight) {
  const offsets = threadOffsets(threads, (thread) => threadHeight(thread, expanded));
  const range = visibleRange(offsets, scrollTop, viewportHeight, OVERSCAN);
  return {
    threads: threads.slice(range.start, range.end),
    padTop: offsets[range.start] ?? 0,
    padBottom: Math.max(0, (offsets[threads.length] ?? 0) - (offsets[range.end] ?? 0)),
  };
}

/**
 * The tag row of a list entry, or null when there is nothing to tag.
 * @param {any} latest
 * @param {any} convSignal
 */
function messageTags(latest, convSignal) {
  const tags = [];
  if (latest.attachments?.length) tags.push(h("em", null, icon("Paperclip", { size: 12 }), ` ${latest.attachments.length}`));
  if (latest.spamScore) tags.push(h("em", { class: "spam-tag" }, icon("ShieldAlert", { size: 12 }), " Review"));
  for (const type of ["replyNeeded", "priority"]) {
    const sig = convSignal?.[type];
    const show = type === "replyNeeded" ? sig?.value : sig?.value === "high";
    if (show && !sig.dismissed) tags.push(h("span", { class: `signal-badge compact ${signalColorClass(type, sig.value)}`, title: sig.evidence?.join("; ") ?? "" }, icon(signalIconName(type), { size: 13 })));
  }
  return tags.length ? h("span", { class: "message-tags" }, tags) : null;
}

/**
 * @param {HTMLElement} root
 * @param {import("./store.js").createStore extends (...args: any) => infer R ? R : never} store
 */
export function mountList(root, store) {
  const folderHost = root.querySelector("[data-folders]");
  const listHost = root.querySelector("[data-messages]");
  if (!folderHost || !listHost) throw new Error("list island needs [data-folders] and [data-messages]");

  const expandedThreads = signal(/** @type {Record<string, boolean>} */ ({}));
  const scrollTop = signal(0);
  const viewportHeight = signal(600);
  const folderExpand = createFolderExpand(typeof localStorage === "undefined" ? undefined : localStorage);
  const folderVersion = signal(0);

  /** @param {string} key */
  const toggleThread = (key) => expandedThreads({ ...expandedThreads(), [key]: !expandedThreads()[key] });

  // ── folder column ──
  effect(() => {
    folderVersion();
    const smart = store.smartMailbox();
    const currentFolder = store.folder();
    const rows = visibleFolders(buildFolderTree(store.folderList()), folderExpand.state());
    replace(folderHost, [
      h("div", { class: "folder-summary" }, h("span", null, "Folders"), h("strong", null, `${store.unreadCount()} unread`)),
      h("div", { class: "folder-group" }, rows.map((node) => {
        const open = folderExpand.isOpen(node.path);
        const active = node.path === currentFolder && !smart;
        return h("div", { class: "folder-row", style: { "--depth": String(node.depth) } },
          node.children.length
            ? h("button", { class: "disclosure", "aria-label": `${open ? "Collapse" : "Expand"} ${node.name}`, title: open ? "Collapse" : "Expand", onclick: () => { folderExpand.toggle(node.path); folderVersion(folderVersion() + 1); } }, icon("ChevronRight", { size: 14, class: open ? "open" : "" }))
            : h("span", { class: "disclosure-spacer", "aria-hidden": "true" }),
          h("button", { class: `folder-button${active ? " active" : ""}`, "aria-current": active ? "page" : null, "aria-label": `${node.name}${node.unread ? `, ${node.unread} unread` : ""}`, title: node.name, onclick: () => store.openFolder(node.path) },
            h("span", { class: "folder-name" }, icon(folderIcon(node.name), { size: 17, strokeWidth: 1.8 }), ` ${node.name}`),
            node.unread ? h("em", null, String(node.unread)) : null),
        );
      })),
      h("div", { class: "folder-summary" }, h("span", null, "Smart mailboxes")),
      h("div", { class: "folder-group" }, store.smartMailboxes.map((item) =>
        h("button", { class: smart === item.id ? "active" : null, "aria-current": smart === item.id ? "page" : null, onclick: () => store.chooseSmartMailbox(item.id) },
          h("span", { class: "folder-name" }, icon(item.icon, { size: 17, strokeWidth: 1.8 }), ` ${item.label}`)))),
      h("div", { class: "folder-hint" }, icon("CircleHelp", { size: 15, strokeWidth: 1.8 }), " Press ", h("kbd", null, "/"), " to search"),
    ]);
  });

  // ── message column: static frame, the list body re-renders on its own ──
  const listEl = h("div", { class: "message-list", "data-message-list": "", "aria-live": "polite" });
  listEl.addEventListener("scroll", () => scrollTop(listEl.scrollTop), { passive: true });
  const measure = () => viewportHeight(listEl.clientHeight || 600);
  if (typeof ResizeObserver !== "undefined") new ResizeObserver(measure).observe(listEl);
  const chipsEl = h("div", { class: "filter-chips", role: "group", "aria-label": "Quick filters" });
  const barEl = h("div", { class: "column-bar" });
  const pagerEl = h("div", { class: "pager", "aria-label": "Message pagination" });
  replace(listHost, [barEl, chipsEl, listEl, pagerEl]);

  effect(() => {
    const smart = store.smartMailbox();
    const total = store.totalMessages();
    const title = smart ? store.smartMailboxes.find((item) => item.id === smart)?.label : store.folder();
    replace(barEl, [
      h("div", null, h("span", { class: "eyebrow" }, "Current folder"), h("strong", null, title ?? ""), h("span", { class: "message-count" }, `${total} message${total === 1 ? "" : "s"}`)),
      h("button", { class: "icon-button", "aria-label": "Message list options", title: "Message list options" }, icon("MoreHorizontal", { size: 19 })),
    ]);
  });

  effect(() => {
    const active = store.activeChips();
    replace(chipsEl, [
      store.chipDefs.map((chip) => h("button", { class: `chip${active.has(chip.id) ? " active" : ""}`, "aria-pressed": String(active.has(chip.id)), onclick: () => store.toggleChip(chip.id) }, chip.label)),
      active.size > 0 ? h("button", { class: "chip-clear", onclick: () => store.clearChips() }, icon("X", { size: 13 }), " Clear") : null,
    ]);
  });

  effect(() => {
    const busy = store.busy();
    replace(pagerEl, [
      h("button", { class: "icon-button", "aria-label": "Previous page", title: "Previous page", disabled: store.page() === 0 || busy, onclick: () => store.previousPage() }, icon("ChevronLeft", { size: 18 })),
      h("span", null, `Page ${store.page() + 1} of ${store.pageCount()}`),
      h("button", { class: "icon-button", "aria-label": "Next page", title: "Next page", disabled: !store.nextCursor() || busy, onclick: () => store.nextPage() }, icon("ChevronRight", { size: 18 })),
    ]);
  });

  /** @param {any} thread @param {any} selected @param {Record<string, boolean>} expanded */
  function threadRow(thread, selected, expanded) {
    const latest = thread.latest;
    const convSig = store.signalForMessage(latest.id);
    const isThread = thread.messages.length > 1;
    const open = !!expanded[thread.key];
    const toggle = (/** @type {Event} */ event) => { event.stopPropagation(); toggleThread(thread.key); };
    return h("div", { class: "thread-row", "data-thread": thread.key },
      h("button", { class: `${thread.unreadCount > 0 ? "unread" : ""}${selected?.id === latest.id ? " selected" : ""}`.trim() || null, onclick: () => store.readMessage(latest.id) },
        h("span", { class: "message-dot", "aria-hidden": "true" }),
        h("span", { class: "message-main" },
          h("span", { class: "row-top" }, h("b", null, senderLabel(latest)), h("time", null, shortDate(latest.date))),
          h("span", { class: "subject-line" },
            latest.flagged ? icon("Star", { size: 14, fill: "currentColor", label: "Starred" }) : null,
            store.isVipMessage(latest) ? icon("ShieldAlert", { size: 14, class: "vip-icon", label: "VIP sender" }) : null,
            h("strong", null, latest.subject || "(no subject)"),
            isThread ? h("span", { class: "thread-count", role: "button", tabindex: "0", "aria-expanded": String(open), title: `${thread.messages.length} messages in thread`, onclick: toggle, onkeydown: (/** @type {KeyboardEvent} */ event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(event); } } }, icon("ChevronDown", { size: 12, class: open ? "rotated" : "" }), ` ${thread.messages.length}`) : null),
          h("span", { class: "preview" }, latest.preview ?? ""),
          messageTags(latest, convSig))),
      isThread ? h("div", { class: "thread-actions", "aria-label": "Thread actions" },
        h("button", { onclick: () => store.threadAction(thread, "read") }, "Mark read"),
        h("button", { onclick: () => store.threadAction(thread, "archive") }, "Archive"),
        h("button", { onclick: () => store.threadAction(thread, "delete") }, "Delete"),
        h("button", { onclick: () => store.threadAction(thread, "star") }, "Flag")) : null,
      isThread && open ? h("ul", { class: "thread-messages" }, thread.messages.slice(0, -1).reverse().map((/** @type {any} */ message) =>
        h("li", null, h("button", { class: `${message.unread ? "unread" : ""}${selected?.id === message.id ? " selected" : ""}`.trim() || null, onclick: () => store.readMessage(message.id) },
          h("span", { class: "message-dot", "aria-hidden": "true" }), " ",
          h("span", { class: "row-top" }, h("b", null, senderLabel(message)), h("time", null, shortDate(message.date))))))) : null,
    );
  }

  let lastMessages = store.messages();
  /** @type {string | null} */
  let lastSelectedId = null;
  effect(() => {
    const list = store.messages();
    const threads = groupThreads(list);
    const expanded = expandedThreads();
    const selected = store.selected();
    const query = store.query();
    store.signals(); store.messageSignals(); store.vipSenders();
    // Keep the scroll position stable when a refresh replaces the message array.
    const preserved = list !== lastMessages ? listEl.scrollTop : null;
    lastMessages = list;
    const { threads: visible, padTop, padBottom } = listWindow(threads, expanded, scrollTop(), viewportHeight());
    replace(listEl, [
      h("div", { "aria-hidden": "true", style: { height: `${padTop}px` } }),
      threads.length
        ? visible.map((thread) => threadRow(thread, selected, expanded))
        : h("div", { class: "empty-state" }, h("div", { class: "empty-icon" }, icon("Inbox", { size: 24, strokeWidth: 1.6 })), h("strong", null, query ? "No matching messages" : "Nothing in this folder"), h("p", null, query ? "Try a different sender, subject, or phrase." : "New mail will appear here.")),
      h("div", { "aria-hidden": "true", style: { height: `${padBottom}px` } }),
    ]);
    if (preserved !== null && Math.abs(listEl.scrollTop - preserved) > 1) listEl.scrollTop = preserved;
    // A new selection scrolls into view after this render, never against the previous rows.
    const selectedId = selected?.id ?? null;
    if (selectedId && selectedId !== lastSelectedId) scrollSelectedIntoView(listEl);
    lastSelectedId = selectedId;
  });

  measure();
  return { listEl, measure, toggleThread };
}