feature/IO-3587-Commision-Cut - Improved local email / Test Plans
This commit is contained in:
@@ -46,8 +46,7 @@ app.get("/api/messages", async (req, res) => {
|
||||
|
||||
app.get("/api/messages/:id/raw", async (req, res) => {
|
||||
try {
|
||||
const messages = await fetchSesMessages();
|
||||
const message = messages.find((candidate) => resolveMessageId(candidate) === req.params.id);
|
||||
const message = await findSesMessageById(req.params.id);
|
||||
|
||||
if (!message) {
|
||||
res.status(404).type("text/plain").send("Message not found");
|
||||
@@ -61,6 +60,42 @@ app.get("/api/messages/:id/raw", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/messages/:id/attachments/:index", async (req, res) => {
|
||||
try {
|
||||
const attachmentIndex = Number.parseInt(req.params.index, 10);
|
||||
|
||||
if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) {
|
||||
res.status(400).type("text/plain").send("Attachment index must be a non-negative integer");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = await parseSesMessageById(req.params.id);
|
||||
|
||||
if (!parsed) {
|
||||
res.status(404).type("text/plain").send("Message not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = parsed.attachments?.[attachmentIndex];
|
||||
|
||||
if (!attachment) {
|
||||
res.status(404).type("text/plain").send("Attachment not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = resolveAttachmentFilename(attachment, attachmentIndex);
|
||||
const content = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || "");
|
||||
|
||||
res.setHeader("Content-Type", attachment.contentType || "application/octet-stream");
|
||||
res.setHeader("Content-Disposition", buildAttachmentDisposition(filename));
|
||||
res.setHeader("Content-Length", String(content.length));
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMessages() {
|
||||
const startedAt = Date.now();
|
||||
const sesMessages = await fetchSesMessages();
|
||||
@@ -98,6 +133,21 @@ async function fetchSesMessages() {
|
||||
return Array.isArray(data.messages) ? data.messages : [];
|
||||
}
|
||||
|
||||
async function findSesMessageById(id) {
|
||||
const messages = await fetchSesMessages();
|
||||
return messages.find((message, index) => resolveMessageId(message, index) === id) || null;
|
||||
}
|
||||
|
||||
async function parseSesMessageById(id) {
|
||||
const message = await findSesMessageById(id);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return simpleParser(message.RawData || "");
|
||||
}
|
||||
|
||||
async function toMessageViewModel(message, index) {
|
||||
const id = resolveMessageId(message, index);
|
||||
|
||||
@@ -120,8 +170,9 @@ async function toMessageViewModel(message, index) {
|
||||
messageId: parsed.messageId || "",
|
||||
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
|
||||
attachmentCount: parsed.attachments.length,
|
||||
attachments: parsed.attachments.map((attachment) => ({
|
||||
filename: attachment.filename || "Unnamed attachment",
|
||||
attachments: parsed.attachments.map((attachment, attachmentIndex) => ({
|
||||
index: attachmentIndex,
|
||||
filename: resolveAttachmentFilename(attachment, attachmentIndex),
|
||||
contentType: attachment.contentType || "application/octet-stream",
|
||||
size: attachment.size || 0
|
||||
})),
|
||||
@@ -159,6 +210,45 @@ function resolveMessageId(message, index = 0) {
|
||||
return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`;
|
||||
}
|
||||
|
||||
function resolveAttachmentFilename(attachment, index = 0) {
|
||||
if (attachment?.filename) {
|
||||
return attachment.filename;
|
||||
}
|
||||
|
||||
return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`;
|
||||
}
|
||||
|
||||
function attachmentExtension(contentType) {
|
||||
const normalized = String(contentType || "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
return (
|
||||
{
|
||||
"application/json": ".json",
|
||||
"application/pdf": ".pdf",
|
||||
"application/zip": ".zip",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"text/calendar": ".ics",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/plain": ".txt"
|
||||
}[normalized] || ""
|
||||
);
|
||||
}
|
||||
|
||||
function buildAttachmentDisposition(filename) {
|
||||
const fallback = String(filename || "attachment")
|
||||
.replace(/[^\x20-\x7e]/g, "_")
|
||||
.replace(/["\\]/g, "_");
|
||||
|
||||
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
@@ -254,11 +344,11 @@ function renderHtml() {
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">LocalStack SES</p>
|
||||
<h1>Email Viewer</h1>
|
||||
<p class="lede">A faster local inbox for generated emails with live refresh, manual refresh, search, and raw MIME inspection.</p>
|
||||
<p class="lede">Compact inbox for generated emails with live polling, search, and raw MIME inspection.</p>
|
||||
</div>
|
||||
<div class="hero-controls">
|
||||
<div class="row">
|
||||
<button id="refreshButton" class="primary" type="button">Refresh now</button>
|
||||
<button id="refreshButton" class="primary" type="button">Refresh</button>
|
||||
<label class="chip"><input id="autoToggle" type="checkbox" checked> Live refresh</label>
|
||||
<label class="chip">Every
|
||||
<select id="intervalSelect">
|
||||
@@ -271,12 +361,12 @@ function renderHtml() {
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="searchInput" class="search" type="search" placeholder="Search subject, addresses, preview, attachment..." autocomplete="off">
|
||||
<input id="searchInput" class="search" type="search" placeholder="Search subject, sender, preview..." autocomplete="off">
|
||||
<button id="clearSearchButton" class="ghost" type="button">Clear</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="expandAllButton" class="ghost" type="button">Expand all</button>
|
||||
<button id="collapseAllButton" class="ghost" type="button">Collapse all</button>
|
||||
<button id="expandAllButton" class="ghost" type="button">Open all</button>
|
||||
<button id="collapseAllButton" class="ghost" type="button">Close all</button>
|
||||
<span id="statusChip" class="status">Waiting for first refresh...</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,77 +391,80 @@ function renderHtml() {
|
||||
|
||||
function renderStyles() {
|
||||
return `
|
||||
:root{--panel:rgba(255,255,255,.86);--panel-strong:#fff;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--accent:#cf6d3c;--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 18px 48px rgba(35,43,53,.12);}
|
||||
:root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;min-height:100%}
|
||||
body{background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.14),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:16px/1.5 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif}
|
||||
body{background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif}
|
||||
button,input,select,textarea{font:inherit}
|
||||
button{cursor:pointer}
|
||||
.page{max-width:1440px;margin:0 auto;padding:24px}
|
||||
.hero{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(320px,.9fr);gap:24px;margin-bottom:24px}
|
||||
.hero-copy,.hero-controls,.stat,.card{background:var(--panel);backdrop-filter:blur(16px);border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||
.hero-copy,.hero-controls{border-radius:24px;padding:24px}
|
||||
.eyebrow{margin:0 0 8px;color:var(--accent);font-size:.8rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase}
|
||||
h1{margin:0;font-size:clamp(2.2rem,5vw,4rem);line-height:.95;letter-spacing:-.05em}
|
||||
.lede{margin:16px 0 0;max-width:60ch;color:var(--muted)}
|
||||
.hero-controls{display:grid;gap:12px}
|
||||
.row{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
|
||||
.page{max-width:1360px;margin:0 auto;padding:18px}
|
||||
.hero{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(320px,.95fr);gap:14px;margin-bottom:14px}
|
||||
.hero-copy,.hero-controls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||
.card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)}
|
||||
.hero-copy,.hero-controls{border-radius:18px;padding:18px}
|
||||
.eyebrow{margin:0 0 6px;color:var(--accent);font-size:.76rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase}
|
||||
h1{margin:0;font-size:clamp(1.9rem,4vw,3.1rem);line-height:.98;letter-spacing:-.05em}
|
||||
.lede{margin:10px 0 0;max-width:54ch;color:var(--muted);font-size:.96rem}
|
||||
.hero-controls{display:grid;gap:10px}
|
||||
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.primary,.ghost,.mini,.tab{border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
|
||||
.primary,.ghost{min-height:44px;padding:0 18px;font-weight:700}
|
||||
.mini,.tab{min-height:34px;padding:0 12px;font-weight:600}
|
||||
.primary,.ghost{min-height:38px;padding:0 14px;font-weight:700}
|
||||
.mini,.tab{min-height:30px;padding:0 10px;font-weight:600}
|
||||
.primary{background:var(--accent);color:#fff7f2}
|
||||
.ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)}
|
||||
.tab{background:transparent;color:var(--muted)}
|
||||
.tab.active{background:#fff;border-color:var(--line);color:var(--ink)}
|
||||
.tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)}
|
||||
.primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)}
|
||||
.chip{display:inline-flex;align-items:center;gap:10px;min-height:44px;padding:0 16px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600}
|
||||
.chip{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 12px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.92rem}
|
||||
.chip input{margin:0;accent-color:var(--accent)}
|
||||
.chip select{border:none;background:transparent;outline:none;color:var(--ink)}
|
||||
.search{flex:1 1 320px;min-height:48px;padding:0 16px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none}
|
||||
.status{display:inline-flex;align-items:center;min-height:40px;padding:0 14px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-weight:600}
|
||||
.search{flex:1 1 260px;min-height:40px;padding:0 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none}
|
||||
.status{display:inline-flex;align-items:center;min-height:34px;padding:0 12px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.9rem;font-weight:600}
|
||||
.status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)}
|
||||
.status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)}
|
||||
.status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)}
|
||||
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:18px}
|
||||
.stat{border-radius:18px;padding:18px}
|
||||
.stat span{display:block;margin-bottom:10px;color:var(--muted);font-size:.82rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
|
||||
.stat strong{display:block;font-size:clamp(2rem,4vw,2.6rem);line-height:1;letter-spacing:-.05em}
|
||||
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:12px}
|
||||
.stat{border-radius:16px;padding:14px}
|
||||
.stat span{display:block;margin-bottom:8px;color:var(--muted);font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
|
||||
.stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em}
|
||||
.stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em}
|
||||
.stat small{display:block;margin-top:10px;color:var(--muted);font-size:.95rem}
|
||||
.banner,.empty{margin:0 0 18px;padding:16px 18px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.82)}
|
||||
.stat small{display:block;margin-top:8px;color:var(--muted);font-size:.85rem}
|
||||
.banner,.empty{margin:0 0 12px;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)}
|
||||
.banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)}
|
||||
.list{display:grid;gap:18px}
|
||||
.card{overflow:hidden;border-radius:18px}
|
||||
.card.new{border-color:rgba(31,143,101,.28);box-shadow:var(--shadow),0 0 0 1px rgba(31,143,101,.12)}
|
||||
.summary{list-style:none;display:grid;gap:12px;padding:20px;cursor:pointer}
|
||||
.list{display:grid;gap:12px}
|
||||
.card{overflow:hidden;border-radius:16px}
|
||||
.card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)}
|
||||
.summary{list-style:none;display:grid;gap:8px;padding:14px 16px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))}
|
||||
.summary::-webkit-details-marker{display:none}
|
||||
.top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
|
||||
.top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.top{justify-content:space-between}
|
||||
.head{min-width:0;flex:1 1 320px}
|
||||
.head h2{margin:0;font-size:clamp(1.1rem,2vw,1.45rem);line-height:1.2;letter-spacing:-.03em;word-break:break-word}
|
||||
.meta{margin:6px 0 0;color:var(--muted);font-size:.95rem;word-break:break-word}
|
||||
.time,.tag{display:inline-flex;align-items:center;min-height:28px;padding:0 12px;border-radius:999px;font-size:.84rem;font-weight:700}
|
||||
.head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word}
|
||||
.meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word}
|
||||
.time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700}
|
||||
.time{background:rgba(31,41,51,.06)}
|
||||
.tag{background:rgba(31,41,51,.06);color:var(--muted)}
|
||||
.tag{background:var(--accent-soft);color:#8d5632}
|
||||
.tag.new{background:rgba(31,143,101,.1);color:var(--ok)}
|
||||
.tag.bad{background:rgba(179,58,58,.1);color:var(--bad)}
|
||||
.preview{margin:0}
|
||||
.body{display:grid;gap:16px;padding:18px 20px 20px;border-top:1px solid var(--line);background:var(--panel-strong)}
|
||||
.toolbar{justify-content:space-between;align-items:flex-start}
|
||||
.tabs{display:inline-flex;gap:6px;padding:6px;border-radius:999px;background:rgba(31,41,51,.05)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
|
||||
.metaCard{padding:14px 16px;border-radius:14px;background:rgba(31,41,51,.035);border:1px solid rgba(31,41,51,.08)}
|
||||
.metaCard dt{margin:0 0 6px;color:var(--muted);font-size:.8rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
|
||||
.preview{margin:0;color:#324150;font-size:.94rem}
|
||||
.body{display:grid;gap:12px;padding:12px 16px 16px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)}
|
||||
.toolbar{justify-content:space-between;align-items:center}
|
||||
.tabs{display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:rgba(207,109,60,.08)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
|
||||
.metaCard{padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)}
|
||||
.metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
|
||||
.metaCard dd{margin:0;word-break:break-word}
|
||||
.attachments{gap:8px}
|
||||
.attachment{padding:9px 12px;border-radius:12px;background:rgba(255,255,255,.72);border:1px solid var(--line);font-size:.92rem}
|
||||
.panel{overflow:hidden;border-radius:14px;border:1px solid var(--line);background:#fff}
|
||||
iframe{width:100%;min-height:720px;border:none;background:#fff}
|
||||
pre{margin:0;padding:16px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:13px/1.55 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff}
|
||||
.placeholder,.inlineError{padding:16px}
|
||||
.attachments{gap:6px}
|
||||
.attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem}
|
||||
.attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
|
||||
.attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)}
|
||||
.panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff}
|
||||
iframe{width:100%;min-height:560px;border:none;background:#fff}
|
||||
pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff}
|
||||
.placeholder,.inlineError{padding:12px}
|
||||
.inlineError{color:var(--bad)}
|
||||
@media (max-width:1080px){.hero{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media (max-width:720px){.page{padding:16px}.stats{grid-template-columns:1fr}.primary,.ghost,.chip{width:100%;justify-content:center}.toolbar,.row{align-items:stretch}iframe{min-height:560px}}
|
||||
@media (max-width:720px){.page{padding:12px}.stats{grid-template-columns:1fr}.primary,.ghost,.chip{width:100%;justify-content:center}.toolbar,.row{align-items:stretch}iframe{min-height:420px}}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -402,7 +495,8 @@ function clientApp(config) {
|
||||
knownIds: new Set(),
|
||||
openIds: new Set(),
|
||||
views: {},
|
||||
raw: {}
|
||||
raw: {},
|
||||
listSignature: ""
|
||||
};
|
||||
|
||||
const el = {
|
||||
@@ -531,6 +625,8 @@ function clientApp(config) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldRenderList = false;
|
||||
|
||||
state.loading = true;
|
||||
state.source = source;
|
||||
state.error = "";
|
||||
@@ -548,19 +644,23 @@ function clientApp(config) {
|
||||
const payload = await response.json();
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
const nextIds = new Set(messages.map((message) => message.id));
|
||||
const nextSignature = computeListSignature(messages);
|
||||
|
||||
state.newIds = state.updatedAt
|
||||
? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id))
|
||||
: new Set();
|
||||
shouldRenderList = nextSignature !== state.listSignature;
|
||||
state.newIds =
|
||||
state.updatedAt && shouldRenderList
|
||||
? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id))
|
||||
: state.newIds;
|
||||
state.knownIds = nextIds;
|
||||
state.messages = messages;
|
||||
state.duration = payload.fetchDurationMs || 0;
|
||||
state.parseErrors = payload.parseErrors || 0;
|
||||
state.newest = payload.latestMessageTimestamp || "";
|
||||
state.updatedAt = Date.now();
|
||||
state.listSignature = nextSignature;
|
||||
|
||||
pruneState();
|
||||
applyFilter();
|
||||
applyFilter(shouldRenderList);
|
||||
setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok");
|
||||
} catch (error) {
|
||||
state.error = error.message || "Unknown refresh error";
|
||||
@@ -568,7 +668,7 @@ function clientApp(config) {
|
||||
} finally {
|
||||
state.loading = false;
|
||||
scheduleRefresh();
|
||||
renderAll();
|
||||
renderAll({ renderList: shouldRenderList });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,12 +690,28 @@ function clientApp(config) {
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
function applyFilter(shouldRenderList = true) {
|
||||
const search = state.search.trim().toLowerCase();
|
||||
state.filtered = !search
|
||||
? [...state.messages]
|
||||
: state.messages.filter((message) => haystack(message).includes(search));
|
||||
renderAll();
|
||||
renderAll({ renderList: shouldRenderList });
|
||||
}
|
||||
|
||||
function computeListSignature(messages) {
|
||||
return messages
|
||||
.map((message) =>
|
||||
[
|
||||
message.id,
|
||||
message.timestampMs || 0,
|
||||
message.rawSizeBytes || 0,
|
||||
message.attachmentCount || 0,
|
||||
message.hasHtml ? 1 : 0,
|
||||
message.preview || "",
|
||||
message.parseError || ""
|
||||
].join("::")
|
||||
)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function haystack(message) {
|
||||
@@ -624,11 +740,14 @@ function clientApp(config) {
|
||||
state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval);
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
function renderAll(options = {}) {
|
||||
const { renderList: shouldRenderList = true } = options;
|
||||
renderStats();
|
||||
renderFetch();
|
||||
renderStatus();
|
||||
renderList();
|
||||
if (shouldRenderList) {
|
||||
renderList();
|
||||
}
|
||||
renderLiveClock();
|
||||
}
|
||||
|
||||
@@ -814,7 +933,7 @@ function clientApp(config) {
|
||||
? `<div class="attachments">${message.attachments
|
||||
.map((attachment) => {
|
||||
const size = attachment.size ? `, ${formatBytes(attachment.size)}` : "";
|
||||
return `<span class="attachment">${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})</span>`;
|
||||
return `<a class="attachment attachmentLink" href="/api/messages/${encodeURIComponent(message.id)}/attachments/${attachment.index}" download="${escapeHtml(attachment.filename)}" title="Download ${escapeHtml(attachment.filename)}">${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})</a>`;
|
||||
})
|
||||
.join("")}</div>`
|
||||
: ""
|
||||
|
||||
Reference in New Issue
Block a user