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
+3 -2
View File
@@ -24,6 +24,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
is_starred: false,
has_attachments: false,
has_remote_images: false,
size_bytes: 1024,
snippet: `snippet ${id}`,
};
}
@@ -89,8 +90,8 @@ describe("ThreadView direction-agnostic layout (SPEC §9.4)", () => {
const messages = [msg("a", 0), msg("b", 1, true), msg("c", 2)];
const { container } = render(wrap(<ThreadView subject="Subject" messages={messages} order="asc" />));
// An expanded message renders a MessageBody (iframe/loading/error); collapsed does not.
const aNode = screen.getByText("snippet a").closest('[data-testid="message"]') as HTMLElement;
const bNode = screen.getByText("snippet b").closest('[data-testid="message"]') as HTMLElement;
const aNode = container.querySelector('[data-message-id="a"]') as HTMLElement;
const bNode = container.querySelector('[data-message-id="b"]') as HTMLElement;
const hasBody = (el: HTMLElement) =>
el.querySelector("iframe") !== null ||
/Loading thread|Couldn't load/i.test(el.textContent ?? "");