Menu
Public repository / c3ef99b092caa55fb6a3eed9d3e776734cc99e97
AkurAI-Mail / src/lib/webmail/MessageList.svelte
Latest change c3ef99b092caa55fb6a3eed9d3e776734cc99e97 - OLI-241: framework migration plan; shortcuts slice onto AkurAI-Framework signals runtime by Ólafur Búi Ólafsson
<script lang="ts">
import { scrollSelectedIntoView } from "../../framework/shortcuts.js";
import { ChevronDown, ChevronLeft, ChevronRight, CircleHelp, Inbox, MoreHorizontal, Paperclip, ShieldAlert, Star, X } from "lucide-svelte";
import SignalBadge from "$lib/webmail/SignalBadge.svelte";
import { groupThreads, type Thread } from "$lib/webmail/threads";
import { buildFolderTree, visibleFolders } from "$lib/webmail/folders";
import { threadOffsets, visibleRange } from "$lib/webmail/virtual";
let { folderList, folder, unreadCount, folderIcon, onFolder, messages, totalMessages, query, senderLabel, shortDate, selected, onRead, page, pageCount, nextCursor, busy, onPrevious, onNext, signalForMessage, smartMailboxes = [], smartMailbox = "", onSmartMailbox, chipDefs = [], activeChips = new Set(), onToggleChip, onClearChips, isVipMessage, onThreadAction }: any = $props();
const threads = $derived(groupThreads(messages));
const expandedThreads: Record<string, boolean> = $state({});
function toggleThread(key: string) {
expandedThreads[key] = !expandedThreads[key];
}
// Windowed rendering: only threads near the viewport are in the DOM.
const ROW_HEIGHT = 106;
const CHILD_ROW_HEIGHT = 36;
const OVERSCAN = 6;
let listEl: HTMLDivElement | undefined = $state();
let scrollTop = $state(0);
let viewportHeight = $state(600);
let preservedScroll = 0;
// Keep the scroll position stable when a refresh replaces the message array.
$effect.pre(() => {
void messages;
if (listEl) preservedScroll = listEl.scrollTop;
});
$effect(() => {
void threads;
if (listEl && Math.abs(listEl.scrollTop - preservedScroll) > 1) {
listEl.scrollTop = preservedScroll;
scrollTop = preservedScroll;
}
});
function threadHeight(thread: Thread) {
return ROW_HEIGHT + (thread.messages.length > 1 && expandedThreads[thread.key] ? (thread.messages.length - 1) * CHILD_ROW_HEIGHT : 0);
}
const offsets = $derived(threadOffsets(threads, threadHeight));
const range = $derived(visibleRange(offsets, scrollTop, viewportHeight, OVERSCAN));
const visibleThreads = $derived(threads.slice(range.start, range.end));
const padTop = $derived(offsets[range.start] ?? 0);
const padBottom = $derived(Math.max(0, (offsets[threads.length] ?? 0) - (offsets[range.end] ?? 0)));
// Folder tree with per-folder collapse state remembered in localStorage.
const EXPANDED_KEY = "akurai-mail-folder-expanded";
function loadExpanded(): Record<string, boolean> {
if (typeof localStorage === "undefined") return {};
try {
return JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? "{}");
} catch {
return {};
}
}
const expandedFolders: Record<string, boolean> = $state(loadExpanded());
function toggleFolder(path: string) {
expandedFolders[path] = !(expandedFolders[path] ?? true);
if (typeof localStorage !== "undefined") localStorage.setItem(EXPANDED_KEY, JSON.stringify(expandedFolders));
}
const folderRows = $derived(visibleFolders(buildFolderTree(folderList), expandedFolders));
const columnTitle = $derived(smartMailbox ? smartMailboxes.find((item: any) => item.id === smartMailbox)?.label : folder);
$effect(() => {
if (!selected) return;
scrollSelectedIntoView(document);
});
</script>
<aside class="folder-column" aria-label="Mail folders">
<div class="folder-summary"><span>Folders</span><strong>{unreadCount} unread</strong></div>
<div class="folder-group">
{#each folderRows as node}
{@const Icon = folderIcon(node.name)}
{@const open = expandedFolders[node.path] ?? true}
{@const current = node.path === folder && !smartMailbox}
<div class="folder-row" style="--depth: {node.depth}">
{#if node.children.length}
<button class="disclosure" aria-label={open ? `Collapse ${node.name}` : `Expand ${node.name}`} title={open ? "Collapse" : "Expand"} onclick={() => toggleFolder(node.path)}><ChevronRight size={14} strokeWidth={2} class={open ? "open" : ""} /></button>
{:else}
<span class="disclosure-spacer" aria-hidden="true"></span>
{/if}
<button class="folder-button" class:active={current} aria-current={current ? "page" : undefined} aria-label={`${node.name}${node.unread ? `, ${node.unread} unread` : ""}`} title={node.name} onclick={() => onFolder(node.path)}>
<span class="folder-name"><Icon size={17} strokeWidth={1.8} /> {node.name}</span>
{#if node.unread}<em>{node.unread}</em>{/if}
</button>
</div>
{/each}
</div>
<div class="folder-summary"><span>Smart mailboxes</span></div>
<div class="folder-group">
{#each smartMailboxes as item}
<button class:active={smartMailbox === item.id} aria-current={smartMailbox === item.id ? "page" : undefined} onclick={() => onSmartMailbox(item.id)}><span class="folder-name"><item.icon size={17} strokeWidth={1.8} /> {item.label}</span></button>
{/each}
</div>
<div class="folder-hint"><CircleHelp size={15} strokeWidth={1.8} /> Press <kbd>/</kbd> to search</div>
</aside>
<section class="message-column" aria-label="Message list">
<div class="column-bar"><div><span class="eyebrow">Current folder</span><strong>{columnTitle}</strong><span class="message-count">{totalMessages} message{totalMessages === 1 ? "" : "s"}</span></div><button class="icon-button" aria-label="Message list options" title="Message list options"><MoreHorizontal size={19} /></button></div>
<div class="filter-chips" role="group" aria-label="Quick filters">
{#each chipDefs as chip}
<button class="chip" class:active={activeChips.has(chip.id)} aria-pressed={activeChips.has(chip.id)} onclick={() => onToggleChip(chip.id)}>{chip.label}</button>
{/each}
{#if activeChips.size > 0}<button class="chip-clear" onclick={onClearChips}><X size={13} /> Clear</button>{/if}
</div>
<div class="message-list" data-message-list aria-live="polite" bind:this={listEl} bind:clientHeight={viewportHeight} onscroll={() => listEl && (scrollTop = listEl.scrollTop)}>
<div aria-hidden="true" style="height:{padTop}px"></div>
{#each visibleThreads as thread (thread.key)}
{@const latest = thread.latest}
{@const convSig = signalForMessage?.(latest.id)}
{@const isThread = thread.messages.length > 1}
{@const expanded = expandedThreads[thread.key]}
<div class="thread-row" data-thread={thread.key}>
<button class:unread={thread.unreadCount > 0} class:selected={selected?.id === latest.id} onclick={() => onRead(latest.id)}>
<span class="message-dot" aria-hidden="true"></span>
<span class="message-main">
<span class="row-top"><b>{senderLabel(latest)}</b><time>{shortDate(latest.date)}</time></span>
<span class="subject-line">
{#if latest.flagged}<Star size={14} fill="currentColor" aria-label="Starred" />{/if}
{#if isVipMessage?.(latest)}<ShieldAlert size={14} class="vip-icon" aria-label="VIP sender" />{/if}
<strong>{latest.subject || "(no subject)"}</strong>
{#if isThread}<span class="thread-count" role="button" tabindex="0" aria-expanded={!!expanded} title={`${thread.messages.length} messages in thread`} onclick={(event) => { event.stopPropagation(); toggleThread(thread.key); }} onkeydown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); toggleThread(thread.key); } }}><ChevronDown size={12} class={expanded ? "rotated" : ""} /> {thread.messages.length}</span>{/if}
</span>
<span class="preview">{latest.preview}</span>
{#if latest.attachments?.length || latest.spamScore || convSig?.replyNeeded?.value || convSig?.priority?.value === "high"}
<span class="message-tags">
{#if latest.attachments?.length}<em><Paperclip size={12} /> {latest.attachments.length}</em>{/if}
{#if latest.spamScore}<em class="spam-tag"><ShieldAlert size={12} /> Review</em>{/if}
{#if convSig?.replyNeeded?.value && !convSig.replyNeeded.dismissed}<SignalBadge signalType="replyNeeded" signal={convSig.replyNeeded} convId={convSig.conversationId} compact />{/if}
{#if convSig?.priority?.value === "high" && !convSig.priority.dismissed}<SignalBadge signalType="priority" signal={convSig.priority} convId={convSig.conversationId} compact />{/if}
</span>
{/if}
</span>
</button>
{#if isThread && onThreadAction}
<div class="thread-actions" aria-label="Thread actions">
<button onclick={() => onThreadAction(thread, "read")}>Mark read</button>
<button onclick={() => onThreadAction(thread, "archive")}>Archive</button>
<button onclick={() => onThreadAction(thread, "delete")}>Delete</button>
<button onclick={() => onThreadAction(thread, "star")}>Flag</button>
</div>
{/if}
{#if isThread && expanded}
<ul class="thread-messages">
{#each thread.messages.slice(0, -1).reverse() as message}
<li><button class:unread={message.unread} class:selected={selected?.id === message.id} onclick={() => onRead(message.id)}><span class="message-dot" aria-hidden="true"></span> <span class="row-top"><b>{senderLabel(message)}</b><time>{shortDate(message.date)}</time></span></button></li>
{/each}
</ul>
{/if}
</div>
{:else}
<div class="empty-state"><div class="empty-icon"><Inbox size={24} strokeWidth={1.6} /></div><strong>{query ? "No matching messages" : "Nothing in this folder"}</strong><p>{query ? "Try a different sender, subject, or phrase." : "New mail will appear here."}</p></div>
{/each}
<div aria-hidden="true" style="height:{padBottom}px"></div>
</div>
<div class="pager" aria-label="Message pagination"><button class="icon-button" aria-label="Previous page" title="Previous page" disabled={page === 0 || busy} onclick={onPrevious}><ChevronLeft size={18} /></button><span>Page {page + 1} of {pageCount}</span><button class="icon-button" aria-label="Next page" title="Next page" disabled={!nextCursor || busy} onclick={onNext}><ChevronRight size={18} /></button></div>
</section>
<style>
.folder-column {
background: var(--surface-soft);
border-right: 1px solid var(--line);
align-content: start;
gap: 14px;
min-width: 0;
padding: 14px 12px;
display: grid;
}
.folder-summary,
.column-bar,
.pager {
justify-content: space-between;
align-items: center;
gap: 12px;
display: flex;
}
.folder-group {
gap: 3px;
display: grid;
}
.folder-row {
align-items: center;
gap: 2px;
display: flex;
}
.disclosure {
block-size: 39px;
inline-size: 20px;
color: var(--muted);
transform: translateX(calc(var(--depth, 0) * 14px));
background: 0 0;
border: 0;
flex: none;
justify-content: center;
align-items: center;
padding: 0;
display: inline-flex;
}
.disclosure :global(svg) {
transition: transform 0.12s;
}
.disclosure :global(svg.open) {
transform: rotate(90deg);
}
.disclosure-spacer {
flex: none;
inline-size: 20px;
}
.folder-button {
min-width: 0;
min-height: 39px;
padding: 0 9px;
padding-left: calc(9px + var(--depth, 0) * 14px);
color: var(--ink);
text-align: left;
background: 0 0;
border: 0;
border-radius: 6px;
flex: 1;
justify-content: space-between;
align-items: center;
gap: 8px;
display: flex;
}
.folder-button.active,
.folder-button:hover {
background: var(--surface-selected);
color: var(--accent);
}
.folder-name {
align-items: center;
gap: 10px;
display: inline-flex;
}
.folder-button em {
background: var(--accent-soft);
min-width: 21px;
color: var(--accent);
text-align: center;
border-radius: 6px;
margin-left: auto;
padding: 2px 6px;
font-size: 11px;
font-style: normal;
}
.folder-button em:not(:empty) {
display: inline-block;
}
.folder-group button:not(.folder-button) {
min-height: 39px;
color: var(--ink);
text-align: left;
background: 0 0;
border: 0;
border-radius: 6px;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 0 9px;
display: flex;
}
.folder-group button.active,
.folder-group
button:not(.folder-button):hover {
background: var(--surface-selected);
color: var(--accent);
}
.message-column {
border-right: 1px solid var(--line);
grid-template-rows: auto auto minmax(0, 1fr) auto;
min-width: 0;
display: grid;
}
.column-bar {
border-bottom: 1px solid var(--line);
min-height: 78px;
padding: 12px 14px;
}
.column-bar div {
gap: 2px;
display: grid;
}
.filter-chips {
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 14px;
display: flex;
}
.chip {
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
cursor: pointer;
border-radius: 6px;
padding: 5px 11px;
font-size: 12px;
font-weight: 650;
}
.chip:hover {
background: var(--surface-selected);
}
.chip.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.chip-clear {
color: var(--muted);
cursor: pointer;
background: 0 0;
border: 0;
align-items: center;
gap: 3px;
padding: 5px 6px;
font-size: 12px;
display: inline-flex;
}
.chip-clear:hover {
color: var(--ink);
}
.message-list {
contain: strict;
min-height: 0;
overflow: auto;
}
.thread-row {
position: relative;
}
.thread-row > button {
box-sizing: border-box;
border: 0;
border-bottom: 1px solid var(--line-soft);
background: var(--surface);
width: 100%;
min-height: 106px;
color: var(--ink);
text-align: left;
gap: 10px;
padding: 13px 14px 12px 11px;
display: flex;
}
.thread-row > button:hover,
.thread-row > button.selected {
background: var(--surface-soft);
}
.message-dot {
background: 0 0;
border-radius: 6px;
flex: none;
block-size: 8px;
inline-size: 8px;
margin-top: 8px;
}
.unread .message-dot {
background: var(--accent);
}
.message-main {
flex: 1;
gap: 3px;
min-width: 0;
display: grid;
}
.row-top,
.subject-line,
.message-tags {
align-items: center;
gap: 7px;
min-width: 0;
display: flex;
}
.row-top {
justify-content: space-between;
}
.row-top b {
color: var(--ink);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.unread
.row-top
b {
font-weight: 700;
}
.row-top time {
color: var(--muted);
font-variant-numeric: tabular-nums;
flex: none;
font-size: 12px;
}
.subject-line strong {
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink);
font-size: 13px;
font-weight: 400;
overflow: hidden;
}
.unread
.subject-line
strong {
font-weight: 600;
}
.subject-line :global(svg) {
color: #d6a62c;
flex: none;
}
.thread-count {
border: 1px solid var(--line);
background: var(--surface-soft);
color: var(--muted);
border-radius: 6px;
flex: none;
align-items: center;
gap: 2px;
margin-left: auto;
padding: 1px 7px;
font-size: 11px;
display: inline-flex;
cursor: pointer;
}
.thread-count :global(svg) {
color: var(--muted);
transition: transform 0.15s;
}
.thread-count :global(svg.rotated) {
transform: rotate(180deg);
}
.preview {
color: var(--muted);
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
overflow: hidden;
}
.message-tags {
margin-top: 1px;
}
.message-tags em {
background: var(--accent-soft);
color: var(--accent);
border-radius: 6px;
align-items: center;
gap: 3px;
padding: 2px 6px;
font-size: 10px;
font-style: normal;
display: inline-flex;
}
.message-tags .spam-tag {
background: color-mix(in srgb, var(--danger) 13%, var(--surface));
color: var(--danger);
}
.thread-actions {
z-index: 2;
gap: 4px;
display: none;
position: absolute;
top: 6px;
right: 8px;
}
.thread-row:hover .thread-actions,
.thread-row:focus-within .thread-actions {
display: flex;
}
.thread-actions button {
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
border-radius: 6px;
padding: 2px 6px;
font-size: 10px;
}
.thread-actions button:hover {
background: var(--surface-selected);
color: var(--accent);
}
.thread-messages {
background: var(--surface-soft);
margin: 0;
padding: 0;
list-style: none;
}
.thread-messages
li
button {
border: 0;
border-bottom: 1px solid var(--line-soft);
width: 100%;
min-height: 36px;
color: var(--ink);
text-align: left;
background: 0 0;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 30px;
display: flex;
}
.thread-messages
li
button:hover,
.thread-messages
li
button.selected {
background: var(--surface-selected);
}
.thread-messages
.row-top
b {
font-size: 12px;
font-weight: 400;
}
.thread-messages
.unread
.row-top
b {
font-weight: 700;
}
.thread-messages .message-dot {
block-size: 6px;
inline-size: 6px;
margin-top: 0;
}
.preview,
.message-count,
.pager,
.folder-hint {
color: var(--muted);
font-size: 12px;
}
.pager {
border-top: 1px solid var(--line);
min-height: 50px;
padding: 6px 12px;
}
.empty-state {
min-height: 270px;
color: var(--muted);
text-align: center;
place-content: center;
justify-items: center;
gap: 8px;
padding: 28px;
display: grid;
}
.empty-icon {
background: var(--accent-soft);
block-size: 48px;
inline-size: 48px;
color: var(--accent);
border-radius: 6px;
place-items: center;
display: grid;
}
@media (width<=640px) {
.folder-column {
border-right: 0;
border-bottom: 1px solid var(--line);
padding: 8px;
display: block;
overflow-x: auto;
}
.folder-summary,
.folder-hint {
display: none;
}
.folder-group {
gap: 5px;
display: flex;
}
.folder-row {
flex: none;
}
.disclosure {
display: none;
}
.folder-button {
flex: none;
min-width: max-content;
min-height: 35px;
padding-left: 9px;
}
.message-column {
border-right: 0;
}
.thread-actions {
display: none !important;
}
.filter-chips {
flex-wrap: nowrap;
padding: 6px 8px;
overflow-x: auto;
}
}
</style>