Menu
Public repository / 3af23b0e937520000e1aa43c7e2eb9de2675abd1
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 };
}