frontend: redesign the thread reading panel

Rework the conversation view to match the new dark-theme mockups:
- Subject bar gains a print button and an expand/collapse-all toggle (default
  action expands all; collapsing leaves only the newest message open).
- Collapsed messages are compact single-line cards (40px avatar, colored sender
  name, snippet, relative date) that overlap into a stack; alignment follows the
  sort order (top edge for oldest-first, bottom edge for newest-first) and the
  always-open newest card overlaps its neighbour in both orders.
- Expanded messages show sender+address (whole header row toggles collapse), a
  detail disclosure (From/To/Cc/Subject/Date/Size), the message location
  (Inbox/Sent), a read/unread circle (per-message flag toggle), a quick
  Reply/Reply-all, and an Actions menu (reply/forward/print/delete). The
  Reply/Reply all/Forward bar now sits inside the newest card above a divider.
- Message body: the quoted original is hidden by default with a "•••" toggle
  injected after the "On … wrote:" attribution; the iframe now collapses-then-
  measures so it shrinks back when the quote is hidden.
- Avatars use the sender's IMAP-account colour when the address matches one of
  our accounts, else a stable per-sender hue; two initials for multi-word names.
- Brighter dark-theme border token; new print/expand-all/collapse-all icons.

Backend: add size_bytes to the message envelope (shown in message details).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:58:01 +02:00
co-authored by Claude Opus 4.8
parent 293945480b
commit 1a83e0fb9b
13 changed files with 442 additions and 62 deletions
+37 -2
View File
@@ -30,13 +30,48 @@ export function MessageBody({ message }: { message: MessageEnvelope }) {
});
const resize = useCallback(() => {
const doc = frameRef.current?.contentDocument;
if (doc?.documentElement) setHeight(doc.documentElement.scrollHeight);
const frame = frameRef.current;
const doc = frame?.contentDocument;
if (!frame || !doc?.documentElement) return;
// Collapse first so scrollHeight reflects the content, not the current frame
// height — otherwise the frame can grow but never shrink (e.g. hiding a quote).
frame.style.height = "0px";
const h = doc.documentElement.scrollHeight;
frame.style.height = `${h}px`;
setHeight(h);
}, []);
// Hide the quoted original by default and drop a "•••" toggle right after the
// "On <date>, <sender> wrote:" attribution (inside the white body), so the reader
// sees only the new content until they choose to reveal the quote.
const injectQuoteToggle = useCallback(
(doc: Document) => {
if (doc.getElementById("__quote_toggle")) return;
const quote = doc.querySelector<HTMLElement>("blockquote");
if (!quote?.parentNode) return;
quote.style.display = "none";
const btn = doc.createElement("button");
btn.id = "__quote_toggle";
btn.type = "button";
btn.textContent = "•••";
btn.setAttribute(
"style",
"display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" +
"background:#e5e7eb;color:#4b5563;cursor:pointer;font:inherit;font-size:15px;line-height:1.5;",
);
btn.addEventListener("click", () => {
quote.style.display = quote.style.display === "none" ? "" : "none";
resize();
});
quote.parentNode.insertBefore(btn, quote);
},
[resize],
);
const onLoad = () => {
const doc = frameRef.current?.contentDocument;
if (!doc) return;
injectQuoteToggle(doc);
resize();
// Re-measure as images finish loading (they change the content height).
doc.querySelectorAll("img").forEach((img) => {