Menu
Public repository / c3ef99b092caa55fb6a3eed9d3e776734cc99e97
AkurAI-Mail / src/lib/webmail/MessageReader.svelte
Latest change 22202d1bde69be4ace8a299dffd91f8ff24378df - Recover deployed webmail frontend (frontend parity with EC2 a6afffc) 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";
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((folderList ?? []).filter((item: any) => item.name !== "Trash"));
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 });
}
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" 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>{Math.ceil(file.size / 1024)} KB</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>