Menu
Public repository / 2704a695631eb72db620d03a9099c15609ebd9d7
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>