AkurAI Build
Menu

Public repository / e0753e49bfea7ef672aabf8df9a3a27b8bdd9dc7

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

Latest change aa05f301181c865428b6faf9c750f0631324c73a - feat: compute reply-needed, follow-up and priority signals for conversations (AKURAI-MAIL-7) by Ólafur Búi Ólafsson

<script lang="ts">
  import { Archive, ArrowLeft, Bell, Clock, Download, FileText, Flag, Forward, MessageCircle, MoreHorizontal, Paperclip, Reply, ReplyAll, ShieldAlert, Star, Trash2 } from "lucide-svelte";
  let { selected, selectedDate, current, reply, forward, post, onBack, downloadAttachment, onDelete, convSignal, onCorrectSignal, onDismissSignal }: any = $props();

  function signalTypeLabel(type: string) {
    if (type === "replyNeeded") return "Reply needed";
    if (type === "followUpDue") return "Follow-up due";
    if (type === "priority") return "Priority";
    if (type === "deadlineDetected") return "Deadline detected";
    return type;
  }

  function provenanceLabel(prov: string) {
    if (prov === "deterministic") return "Computed";
    if (prov === "model") return "Model";
    if (prov === "user-corrected") return "Your correction";
    return prov;
  }

  const displaySignals = $derived(
    convSignal ? (Object.entries(convSignal) as [string, any][]).filter(([key]) =>
      ["replyNeeded", "followUpDue", "priority", "deadlineDetected"].includes(key)
    ) : []
  );
</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" onclick={() => reply(false)}><Reply size={17} /> <span>Reply</span></button><button class="toolbar-button" onclick={() => reply(true)}><ReplyAll size={17} /> <span>Reply all</span></button><button class="toolbar-button" onclick={forward}><Forward size={17} /> <span>Forward</span></button><span class="toolbar-divider"></span><button class="icon-button" aria-label={selected.flagged ? "Remove star" : "Add star"} title={selected.flagged ? "Remove star" : "Add star"} onclick={() => post({ action: "message", id: selected.id, messageAction: selected.flagged ? "unstar" : "star" })}><Star size={18} fill={selected.flagged ? "currentColor" : "none"} /></button><button class="icon-button" aria-label="Archive message" title="Archive message" onclick={() => post({ action: "message", id: selected.id, messageAction: "archive" })}><Archive size={18} /></button><button class="icon-button" aria-label="Mark as spam" title="Mark as spam" onclick={() => post({ action: "message", id: selected.id, messageAction: "spam" })}><ShieldAlert size={18} /></button><button class="icon-button destructive" aria-label="Delete message" title="Delete message" onclick={onDelete}><Trash2 size={18} /></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>{Math.ceil(file.size / 1024)} KB</small></span><Download size={17} /></button>{/each}</div></section>{/if}</article>
  {: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 { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; }
  .reader-toolbar { display: flex; align-items: center; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
  .toolbar-button { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 6px; background: transparent; color: var(--ink); padding: 7px 10px; font-size: 13px; cursor: pointer; }
  .toolbar-button:hover { background: var(--surface-soft); }
  .toolbar-divider { width: 1px; height: 22px; background: var(--line); margin: 0 4px; }
  .icon-button { border: 0; border-radius: 6px; background: transparent; color: var(--ink); padding: 6px; cursor: pointer; display: inline-flex; align-items: center; }
  .icon-button:hover { background: var(--surface-soft); }
  .destructive { color: #b3261e; }
  .mobile-back { display: none; }

  .signals-panel { padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--surface-soft); display: grid; gap: 5px; }
  .signal-row { display: grid; gap: 2px; padding: 5px 0; }
  .signal-info { display: flex; align-items: center; gap: 6px; font-size: 13px; }
  .signal-label { font-weight: 700; }
  .signal-prov { color: var(--muted); font-size: 11px; }
  .signal-conf { color: var(--muted); font-size: 11px; margin-left: auto; }
  .signal-evidence { display: flex; gap: 8px; font-size: 12px; color: var(--muted); padding-left: 21px; }
  .signal-evidence span { display: inline; }
  .signal-evidence span + span::before { content: "·"; margin-right: 8px; color: var(--line); }
  .signal-actions { display: flex; gap: 6px; padding-left: 21px; }
  .subtle-button { border: 0; border-radius: 4px; background: var(--surface); color: var(--accent); padding: 2px 8px; font-size: 12px; cursor: pointer; }
  .subtle-button:hover { background: var(--surface-selected); }

  .icon-amber { color: #9a6b00; }
  .icon-blue { color: #1a56b4; }
  .icon-red { color: #b3261e; }

  .reader-empty { display: grid; align-content: center; justify-items: center; gap: 6px; padding: 36px; color: var(--muted); }
  .reader-empty p { margin: 0; font-size: 14px; max-width: 220px; text-align: center; }
  .reader-empty strong { font-size: 17px; }
  .empty-icon { opacity: 0.4; }

  .reader-card { min-height: 0; overflow-y: auto; padding: 20px 22px; }
  .reader-head { margin-bottom: 22px; }
  .reader-title { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
  .reader-title h1 { margin: 0; font-size: 21px; line-height: 1.25; }
  .reader-title p { margin: 6px 0 0; font-size: 13px; color: var(--muted); }
  .reader-head dl { display: grid; gap: 6px; margin-top: 14px; }
  .reader-head dl div { display: grid; grid-template-columns: 50px minmax(0, 1fr); gap: 8px; font-size: 14px; }
  .reader-head dt { color: var(--muted); font-weight: 500; }
  .warning { display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px; border-radius: 6px; background: var(--error-tint); color: #b3261e; font-size: 13px; margin-bottom: 16px; }
  .message-body { font-size: 15px; line-height: 1.65; color: var(--ink); overflow-wrap: anywhere; }
  .attachments { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--line); }
  .attachments strong { display: flex; align-items: center; gap: 6px; font-size: 14px; }
  .attachments div { display: grid; gap: 6px; margin-top: 8px; }
  .attachment { display: flex; align-items: center; gap: 8px; border: 0; border-radius: 6px; background: var(--surface-soft); padding: 9px 11px; font-size: 13px; cursor: pointer; width: 100%; text-align: left; }
  .attachment:hover { background: var(--surface-selected); }
  .attachment span { display: grid; flex: 1; }
  .attachment small { color: var(--muted); }

  @media (max-width: 640px) {
    .mobile-back { display: inline-flex; }
  }
</style>