AkurAI Build
Menu

Public repository / 22202d1bde69be4ace8a299dffd91f8ff24378df

AkurAI-Mail / src/lib/webmail/MessageList.svelte

Latest change 22202d1bde69be4ace8a299dffd91f8ff24378df - Recover deployed webmail frontend (frontend parity with EC2 a6afffc) by Ólafur Búi Ólafsson

<script lang="ts">
  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);
</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>