AkurAI Build
Menu

Public repository / 3af23b0e937520000e1aa43c7e2eb9de2675abd1

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

Latest change 0216cf0f6008aae88a648a82e9b3a462a6688302 - OLI-248: reader view-model on framework ESM (wave 3 slice), tests red-first by Ólafur Búi Ólafsson

<script lang="ts">
  import { Archive, ArrowLeft, Bell, Clock, Download, FileText, Flag, FolderInput, Forward, Inbox, MessageCircle, Paperclip, PenLine, Reply, ReplyAll, ShieldAlert, Star, Trash2 } from "lucide-svelte";
  import { attachmentSizeLabel, displaySignals as frameworkDisplaySignals, moveTargets as frameworkMoveTargets, provenanceLabel, signalTypeLabel } from "../../framework/reader.js";
  let { selected, selectedDate, current, reply, forward, post, onBack, downloadAttachment, onDelete, convSignal, onCorrectSignal, onDismissSignal, isVip, onToggleVip, folderList, isFirstRun, isNoResults, isEmptyMailbox }: any = $props();

  let moveMenuOpen = $state(false);
  const moveTargets = $derived(frameworkMoveTargets(folderList));

  function moveTo(target: string) {
    moveMenuOpen = false;
    post({ action: "message", id: selected.id, messageAction: "move", target });
  }

  function closeMoveMenuOnEscape(event: KeyboardEvent) {
    if (event.key === "Escape") moveMenuOpen = false;
  }

  function closeMoveMenuOnBlur(event: FocusEvent) {
    const next = event.relatedTarget as Node | null;
    if (!next || !(event.currentTarget as HTMLElement).contains(next)) moveMenuOpen = false;
  }

  function messageAction(messageAction: string) {
    post({ action: "message", id: selected.id, messageAction });
  }

  const displaySignals = $derived(frameworkDisplaySignals(convSignal));
</script>
<section class="reader-column" aria-label="Message reader">
  {#if selected}
    <div class="reader-toolbar" role="toolbar" aria-label="Message actions">
      <button class="icon-button mobile-back" aria-label="Back to messages" title="Back to messages" onclick={onBack}><ArrowLeft size={18} /></button>
      <button class="toolbar-button" aria-label="Archive message" title="Archive message" onclick={() => messageAction("archive")}><Archive size={18} strokeWidth={1.8} /><span>Archive</span></button>
      <div class="menu-wrap" onkeydown={closeMoveMenuOnEscape} onfocusout={closeMoveMenuOnBlur} role="presentation">
        <button class="toolbar-button" aria-haspopup="menu" aria-expanded={moveMenuOpen} aria-label="Move message" title="Move message" onclick={() => (moveMenuOpen = !moveMenuOpen)}><FolderInput size={18} strokeWidth={1.8} /><span>Move</span></button>
        {#if moveMenuOpen}
          <div class="popup-menu" role="menu">{#each moveTargets as item}<button role="menuitem" onclick={() => moveTo(item.name)}>{item.name}</button>{/each}</div>
        {/if}
      </div>
      <button class="toolbar-button" aria-label="Mark as junk" title="Mark as junk" onclick={() => messageAction("spam")}><ShieldAlert size={18} strokeWidth={1.8} /><span>Junk</span></button>
      <button class="toolbar-button destructive" aria-label="Delete message" title="Delete message" onclick={onDelete}><Trash2 size={18} strokeWidth={1.8} /><span>Delete</span></button>
      <span class="toolbar-divider"></span>
      <button class="toolbar-button" aria-label="Reply" title="Reply" onclick={() => reply(false)}><Reply size={18} strokeWidth={1.8} /><span>Reply</span></button>
      <button class="toolbar-button" aria-label="Reply all" title="Reply all" onclick={() => reply(true)}><ReplyAll size={18} strokeWidth={1.8} /><span>Reply all</span></button>
      <button class="toolbar-button" aria-label="Forward" title="Forward" onclick={forward}><Forward size={18} strokeWidth={1.8} /><span>Forward</span></button>
      <span class="toolbar-divider"></span>
      <button class="toolbar-button" aria-label={selected.flagged ? "Remove flag" : "Flag message"} title={selected.flagged ? "Remove flag" : "Flag message"} onclick={() => messageAction(selected.flagged ? "unstar" : "star")}><Star size={18} strokeWidth={1.8} fill={selected.flagged ? "currentColor" : "none"} /><span>Flag</span></button>
      <button class="toolbar-button" class:vip-active={isVip} aria-label={isVip ? "Remove VIP" : "Mark sender VIP"} title={isVip ? "Remove VIP" : "Mark sender VIP"} onclick={onToggleVip}><ShieldAlert size={18} strokeWidth={1.8} fill={isVip ? "currentColor" : "none"} /><span>VIP</span></button>
    </div>
    {#if displaySignals.length > 0}
    <div class="signals-panel">
      {#each displaySignals as [type, sig]}
        {#if sig && !sig.dismissed}
        <div class="signal-row">
          <div class="signal-info">
            {#if type === "replyNeeded" && sig.value}<MessageCircle size={15} strokeWidth={2} class="icon-amber" />{/if}
            {#if type === "followUpDue" && sig.value}<Clock size={15} strokeWidth={2} class="icon-blue" />{/if}
            {#if type === "priority" && sig.value === "high"}<Flag size={15} strokeWidth={2} class="icon-red" />{/if}
            {#if type === "deadlineDetected" && sig.value}<Bell size={15} strokeWidth={2} class="icon-amber" />{/if}
            <span class="signal-label">{signalTypeLabel(type)}</span>
            <span class="signal-prov">{provenanceLabel(sig.provenance)}</span>
            <span class="signal-conf">{Math.round((sig.confidence ?? 0) * 100)}%</span>
          </div>
          <div class="signal-evidence">{#each (sig.evidence ?? []) as ev}<span>{ev}</span>{/each}</div>
          <div class="signal-actions">
            {#if onDismissSignal}
              <button class="subtle-button" onclick={() => onDismissSignal(convSignal.conversationId, type, "dismiss")}>Dismiss</button>
            {/if}
            {#if type === "priority" && onCorrectSignal}
              <button class="subtle-button" onclick={() => onCorrectSignal(convSignal.conversationId, type, "correct", sig.value === "high" ? "normal" : "high")}>Fix</button>
            {/if}
          </div>
        </div>
        {/if}
      {/each}
    </div>
    {/if}
    <article class="reader-card"><header class="reader-head"><div class="reader-title"><h1>{selected.subject || "(no subject)"}</h1><p>{selectedDate ? selectedDate.toLocaleString() : selected.date}</p></div><dl><div><dt>From</dt><dd>{selected.from || "(unknown)"}</dd></div><div><dt>To</dt><dd>{selected.to || current}</dd></div></dl></header>{#if selected.spamReasons?.length}<div class="warning"><ShieldAlert size={18} /><span><strong>Spam signals</strong>{selected.spamReasons.join(", ")}</span></div>{/if}<div class="message-body">{@html selected.bodyHtml}</div>{#if selected.attachments?.length}<section class="attachments" aria-label="Attachments"><strong><Paperclip size={17} /> Attachments</strong><div>{#each selected.attachments as file}<button class="attachment" onclick={() => downloadAttachment(file)}><FileText size={18} /><span><b>{file.filename}</b><small>{attachmentSizeLabel(file.size)}</small></span><Download size={17} /></button>{/each}</div></section>{/if}</article>
  {:else if isFirstRun}
    <div class="reader-empty"><div class="empty-icon"><PenLine size={25} strokeWidth={1.5} /></div><strong>Welcome to your inbox</strong><p>New mail lands here as it arrives. Try composing your first message or importing existing mail from Settings.</p></div>
  {:else if isNoResults}
    <div class="reader-empty"><div class="empty-icon"><Inbox size={25} strokeWidth={1.5} /></div><strong>No matching messages</strong><p>Nothing here matches your search or filter. Try a different sender, subject, or phrase.</p></div>
  {:else if isEmptyMailbox}
    <div class="reader-empty"><div class="empty-icon"><Inbox size={25} strokeWidth={1.5} /></div><strong>This folder is empty</strong><p>Nothing has landed here yet.</p></div>
  {:else}
    <div class="reader-empty"><div class="empty-icon"><Star size={25} strokeWidth={1.5} /></div><strong>Choose a conversation</strong><p>Select a message to read it here. Your inbox stays visible while you work.</p></div>
  {/if}
</section>

<style>
  .reader-column {
    grid-template-rows: auto auto minmax(0, 1fr);
    min-width: 0;
    min-height: 0;
    display: grid;
    overflow: hidden;
  }
  .reader-toolbar {
    border-bottom: 1px solid var(--line);
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    padding: 10px 14px;
    display: flex;
  }
  .toolbar-button {
    color: var(--ink);
    cursor: pointer;
    background: 0 0;
    border: 0;
    border-radius: 6px;
    align-items: center;
    gap: 5px;
    padding: 7px 10px;
    font-size: 13px;
    display: inline-flex;
  }
  .toolbar-button:hover {
    background: var(--surface-soft);
  }
  .toolbar-divider {
    background: var(--line);
    width: 1px;
    height: 22px;
    margin: 0 4px;
  }
  .icon-button {
    color: var(--ink);
    cursor: pointer;
    background: 0 0;
    border: 0;
    border-radius: 6px;
    align-items: center;
    padding: 6px;
    display: inline-flex;
  }
  .icon-button:hover {
    background: var(--surface-soft);
  }
  .destructive {
    color: #b3261e;
  }
  .vip-active {
    color: #d6a62c;
  }
  .mobile-back {
    display: none;
  }
  .menu-wrap {
    position: relative;
  }
  .popup-menu {
    z-index: 30;
    border: 1px solid var(--line);
    background: var(--surface);
    border-radius: 6px;
    gap: 2px;
    min-width: 150px;
    padding: 5px;
    display: grid;
    position: absolute;
    top: calc(100% + 6px);
    left: 0;
    box-shadow: 0 16px 40px #0c1f182e;
  }
  .popup-menu button {
    color: var(--ink);
    text-align: left;
    background: 0 0;
    border: 0;
    border-radius: 6px;
    padding: 8px 9px;
    font-size: 13px;
  }
  .popup-menu button:hover {
    background: var(--surface-selected);
    color: var(--accent);
  }
  .signals-panel {
    border-bottom: 1px solid var(--line);
    background: var(--surface-soft);
    gap: 5px;
    padding: 8px 14px;
    display: grid;
  }
  .signal-row {
    gap: 2px;
    padding: 5px 0;
    display: grid;
  }
  .signal-info {
    align-items: center;
    gap: 6px;
    font-size: 13px;
    display: flex;
  }
  .signal-label {
    font-weight: 700;
  }
  .signal-prov {
    color: var(--muted);
    font-size: 11px;
  }
  .signal-conf {
    color: var(--muted);
    margin-left: auto;
    font-size: 11px;
  }
  .signal-evidence {
    color: var(--muted);
    gap: 8px;
    padding-left: 21px;
    font-size: 12px;
    display: flex;
  }
  .signal-evidence span {
    display: inline;
  }
  .signal-evidence
    span
    + span:before {
    content: "·";
    color: var(--line);
    margin-right: 8px;
  }
  .signal-actions {
    gap: 6px;
    padding-left: 21px;
    display: flex;
  }
  .subtle-button {
    background: var(--surface);
    color: var(--accent);
    cursor: pointer;
    border: 0;
    border-radius: 4px;
    padding: 2px 8px;
    font-size: 12px;
  }
  .subtle-button:hover {
    background: var(--surface-selected);
  }
  .reader-empty {
    color: var(--muted);
    align-content: center;
    justify-items: center;
    gap: 6px;
    padding: 36px;
    display: grid;
  }
  .reader-empty p {
    text-align: center;
    max-width: 220px;
    margin: 0;
    font-size: 14px;
  }
  .reader-empty strong {
    font-size: 17px;
  }
  .empty-icon {
    opacity: 0.4;
  }
  .reader-card {
    min-height: 0;
    padding: 20px 22px;
    overflow-y: auto;
  }
  .reader-head {
    margin-bottom: 22px;
  }
  .reader-title {
    justify-content: space-between;
    align-items: flex-start;
    gap: 12px;
    display: flex;
  }
  .reader-title h1 {
    margin: 0;
    font-size: 21px;
    line-height: 1.25;
  }
  .reader-title p {
    color: var(--muted);
    margin: 6px 0 0;
    font-size: 13px;
  }
  .reader-head dl {
    gap: 6px;
    margin-top: 14px;
    display: grid;
  }
  .reader-head dl div {
    grid-template-columns: 50px minmax(0, 1fr);
    gap: 8px;
    font-size: 14px;
    display: grid;
  }
  .reader-head dt {
    color: var(--muted);
    font-weight: 500;
  }
  .warning {
    background: var(--error-tint);
    color: #b3261e;
    border-radius: 6px;
    align-items: flex-start;
    gap: 8px;
    margin-bottom: 16px;
    padding: 10px 12px;
    font-size: 13px;
    display: flex;
  }
  .message-body {
    color: var(--ink);
    overflow-wrap: anywhere;
    font-size: 15px;
    line-height: 1.65;
  }
  .attachments {
    border-top: 1px solid var(--line);
    margin-top: 22px;
    padding-top: 14px;
  }
  .attachments strong {
    align-items: center;
    gap: 6px;
    font-size: 14px;
    display: flex;
  }
  .attachments div {
    gap: 6px;
    margin-top: 8px;
    display: grid;
  }
  .attachment {
    background: var(--surface-soft);
    cursor: pointer;
    text-align: left;
    border: 0;
    border-radius: 6px;
    align-items: center;
    gap: 8px;
    width: 100%;
    padding: 9px 11px;
    font-size: 13px;
    display: flex;
  }
  .attachment:hover {
    background: var(--surface-selected);
  }
  .attachment span {
    flex: 1;
    display: grid;
  }
  .attachment small {
    color: var(--muted);
  }
  @media (width<=640px) {
    .mobile-back {
      display: inline-flex;
    }
  }
</style>