- {visual.map((message) => {
- const isNewest = Boolean(newest && message.id === newest.id);
- const isComposing = composing?.anchorId === message.id;
+
+ {visual.map((message, i) => {
+ const isNewest = Boolean(newest && newest.id === message.id);
+ const isExpanded = expanded.has(message.id);
+ const prev = i > 0 ? visual[i - 1] : undefined;
+ const prevCollapsed = prev ? !expanded.has(prev.id) : false;
+ const prevIsNewest = Boolean(prev && newest && prev.id === newest.id);
+ // Overlap onto a preceding collapsed card, and also onto the always-open
+ // newest message — so the newest stacks with its neighbour in both sort
+ // orders (bottom card in oldest-first, top card in newest-first). z-index
+ // keeps the newer/"current" edge on top so text stays visible.
+ const overlap = prevCollapsed || prevIsNewest;
+ const margin = i === 0 ? "" : overlap ? "-mt-[20px]" : "mt-2";
+ const z = order === "asc" ? i + 1 : visual.length - i;
+ const boxClassName = `${margin} rounded-lg border border-app-border bg-app-surface ${
+ isExpanded ? "" : "shadow-sm"
+ }`;
return (
toggle(message.id)}
onReply={(kind) => openReply(message, kind)}
+ onToggleRead={() => toggleRead(message)}
+ onPrint={() => window.print()}
+ onDelete={() => deleteMessage(message)}
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
/>
- {isNewest ? (
- {composerFor(message.id)}
- ) : (
- composerFor(message.id)
+ {isNewest && (
+
+ {composing?.anchorId === message.id ? composerFor(message.id) : null}
+
)}
+ {composing?.anchorId === message.id && !isNewest && composerFor(message.id)}
);
})}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index b5fdd09..265afc5 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -53,6 +53,8 @@ export const en = {
"mail.selectConversation": "Select a conversation.",
"mail.loadingThread": "Loading thread…",
"mail.expandAll": "Expand all",
+ "mail.collapseAll": "Collapse all",
+ "mail.print": "Print",
"mail.orderOldest": "Oldest first",
"mail.orderNewest": "Newest first",
"filter.all": "All mail",
@@ -79,6 +81,15 @@ export const en = {
"mail.loadImages": "Load images",
"mail.alwaysFromSender": "Always from this sender",
"mail.to": "to",
+ "mail.from": "From",
+ "mail.subjectLabel": "Subject",
+ "mail.dateLabel": "Date",
+ "mail.sizeLabel": "Size",
+ "mail.actions": "Actions",
+ "mail.forwardAsAttachment": "Forward as attachment",
+ "mail.showQuoted": "Show quoted text",
+ "mail.hideQuoted": "Hide quoted text",
+ "mail.markReadUnread": "Mark read / unread",
"mail.messageCount": "{count} messages",
"mail.downloadAll": "Download all {count} attachments",
"mail.downloadZip": "Download zip",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f8effba..9d082d6 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -23,7 +23,7 @@
--app-bg: 27 30 32; /* main background */
--app-surface: 33 36 39; /* panels (slightly elevated) */
--app-elevated: 39 42 46; /* popovers, composer */
- --app-border: 38 40 42; /* subtle dark dividers */
+ --app-border: 58 62 68; /* dark dividers (brighter) */
--app-fg: 226 228 231;
--app-muted: 150 156 164;
--app-faint: 108 114 122;
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index a458c7a..c62a9a3 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -55,6 +55,7 @@ export interface MessageEnvelope {
is_starred: boolean;
has_attachments: boolean;
has_remote_images: boolean;
+ size_bytes: number;
snippet: string;
}
diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx
index 852cb2a..f606dac 100644
--- a/frontend/src/routes/MailLayout.tsx
+++ b/frontend/src/routes/MailLayout.tsx
@@ -69,6 +69,7 @@ export function MailLayout() {
// Which special-use folder the reading pane is showing (drives the toolbar).
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
+ const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
const threadsQuery = useQuery({
queryKey: ["threads", view],
queryFn: () => api.threads(view),
@@ -444,6 +445,8 @@ export function MailLayout() {
subject={detailQuery.data.subject}
messages={detailQuery.data.messages}
order={detailQuery.data.order}
+ mailboxes={mailboxesQuery.data ?? []}
+ accounts={accounts}
replyKindSignal={replySignal}
/>
diff --git a/frontend/src/test/ThreadView.test.tsx b/frontend/src/test/ThreadView.test.tsx
index d50963a..a24a0ac 100644
--- a/frontend/src/test/ThreadView.test.tsx
+++ b/frontend/src/test/ThreadView.test.tsx
@@ -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(
));
// 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 ?? "");