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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user