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:
@@ -54,6 +54,7 @@ class MessageEnvelope(BaseModel):
|
|||||||
is_starred: bool
|
is_starred: bool
|
||||||
has_attachments: bool
|
has_attachments: bool
|
||||||
has_remote_images: bool
|
has_remote_images: bool
|
||||||
|
size_bytes: int
|
||||||
snippet: str
|
snippet: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ def _envelope(msg: Message) -> MessageEnvelope:
|
|||||||
is_starred="\\Flagged" in msg.flags,
|
is_starred="\\Flagged" in msg.flags,
|
||||||
has_attachments=msg.has_attachments,
|
has_attachments=msg.has_attachments,
|
||||||
has_remote_images=msg.has_remote_images,
|
has_remote_images=msg.has_remote_images,
|
||||||
|
size_bytes=msg.size_bytes,
|
||||||
snippet=msg.snippet,
|
snippet=msg.snippet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 24 24" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><path fill="currentColor" d="m7.4 21.308l-.708-.708L12 15.292l5.308 5.308l-.708.708l-4.6-4.6zm4.6-12.6L6.692 3.4l.708-.708l4.6 4.6l4.6-4.6l.708.708z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 24 24" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><path fill="currentColor" d="m12 22l-6-6l1.425-1.425L12 19.15l4.575-4.575L18 16zM7.45 9.4L6 8l6-6l6 6l-1.45 1.4L12 4.85z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 24 24" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><path fill="currentColor" d="M7 2a1 1 0 0 0-1 1v4H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2h2a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2V3a1 1 0 0 0-1-1zm10 12H7a1 1 0 0 0-1 1v2H4V9h16v8h-2v-2a1 1 0 0 0-1-1m-1-7H8V4h8zM5 10v2h3v-2zm11 6v4H8v-4z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 503 B |
@@ -3,11 +3,14 @@
|
|||||||
// let the size come from CSS so each glyph inherits the surrounding text color
|
// let the size come from CSS so each glyph inherits the surrounding text color
|
||||||
// and adapts to light/dark themes.
|
// and adapts to light/dark themes.
|
||||||
import archive from "../../icons/archive.svg?raw";
|
import archive from "../../icons/archive.svg?raw";
|
||||||
|
import collapseAll from "../../icons/collapse-all.svg?raw";
|
||||||
import compose from "../../icons/compose.svg?raw";
|
import compose from "../../icons/compose.svg?raw";
|
||||||
import drafts from "../../icons/drafts-28-regular.svg?raw";
|
import drafts from "../../icons/drafts-28-regular.svg?raw";
|
||||||
|
import expandAll from "../../icons/expand-all.svg?raw";
|
||||||
import forward from "../../icons/forward.svg?raw";
|
import forward from "../../icons/forward.svg?raw";
|
||||||
import inbox from "../../icons/inbox.svg?raw";
|
import inbox from "../../icons/inbox.svg?raw";
|
||||||
import pin from "../../icons/pin.svg?raw";
|
import pin from "../../icons/pin.svg?raw";
|
||||||
|
import print from "../../icons/print.svg?raw";
|
||||||
import replyAll from "../../icons/reply-all-line.svg?raw";
|
import replyAll from "../../icons/reply-all-line.svg?raw";
|
||||||
import reply from "../../icons/reply.svg?raw";
|
import reply from "../../icons/reply.svg?raw";
|
||||||
import sent from "../../icons/sent-02.svg?raw";
|
import sent from "../../icons/sent-02.svg?raw";
|
||||||
@@ -16,11 +19,14 @@ import trash from "../../icons/trash.svg?raw";
|
|||||||
|
|
||||||
const RAW: Record<string, string> = {
|
const RAW: Record<string, string> = {
|
||||||
archive,
|
archive,
|
||||||
|
"collapse-all": collapseAll,
|
||||||
compose,
|
compose,
|
||||||
drafts,
|
drafts,
|
||||||
|
"expand-all": expandAll,
|
||||||
forward,
|
forward,
|
||||||
inbox,
|
inbox,
|
||||||
pin,
|
pin,
|
||||||
|
print,
|
||||||
reply,
|
reply,
|
||||||
"reply-all": replyAll,
|
"reply-all": replyAll,
|
||||||
sent,
|
sent,
|
||||||
|
|||||||
@@ -30,13 +30,48 @@ export function MessageBody({ message }: { message: MessageEnvelope }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resize = useCallback(() => {
|
const resize = useCallback(() => {
|
||||||
const doc = frameRef.current?.contentDocument;
|
const frame = frameRef.current;
|
||||||
if (doc?.documentElement) setHeight(doc.documentElement.scrollHeight);
|
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 onLoad = () => {
|
||||||
const doc = frameRef.current?.contentDocument;
|
const doc = frameRef.current?.contentDocument;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
injectQuoteToggle(doc);
|
||||||
resize();
|
resize();
|
||||||
// Re-measure as images finish loading (they change the content height).
|
// Re-measure as images finish loading (they change the content height).
|
||||||
doc.querySelectorAll("img").forEach((img) => {
|
doc.querySelectorAll("img").forEach((img) => {
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import type { MessageEnvelope } from "../../lib/types";
|
import { api } from "../../lib/api";
|
||||||
|
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
||||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
import { MessageBody } from "./MessageBody";
|
import { MessageBody } from "./MessageBody";
|
||||||
|
|
||||||
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
||||||
// `messages` is ALWAYS ascending (oldest first); the ONLY branch on `order` is the
|
// `messages` is ALWAYS ascending (oldest first); the ONLY branch on `order` is the
|
||||||
// visual reversal below. The composer anchor is attached to the newest message;
|
// visual reversal below. The composer anchor is attached to the newest message.
|
||||||
// any expanded message can additionally start a reply of its own.
|
|
||||||
export interface ThreadViewProps {
|
export interface ThreadViewProps {
|
||||||
subject: string;
|
subject: string;
|
||||||
messages: MessageEnvelope[];
|
messages: MessageEnvelope[];
|
||||||
order: "asc" | "desc";
|
order: "asc" | "desc";
|
||||||
|
mailboxes?: MailboxOut[];
|
||||||
|
accounts?: AccountOut[];
|
||||||
replyKindSignal?: { kind: string; seq: number };
|
replyKindSignal?: { kind: string; seq: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,8 +29,12 @@ function formatList(addrs: { name: string; email: string }[]): string {
|
|||||||
return addrs.length > 0 ? addrs.map(formatAddress).join(", ") : "(no recipient)";
|
return addrs.length > 0 ? addrs.map(formatAddress).join(", ") : "(no recipient)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attribution line prefixed to a quoted original, e.g.
|
function recipientSummary(addrs: { name: string; email: string }[]): string {
|
||||||
// "On Fri, Jul 17, 2026, at 2:04 PM, Lukas Remis wrote:".
|
if (addrs.length === 0) return "(no recipient)";
|
||||||
|
const first = addrs[0].name || addrs[0].email;
|
||||||
|
return addrs.length > 1 ? `${first} +${addrs.length - 1}` : first;
|
||||||
|
}
|
||||||
|
|
||||||
function attributionFor(msg: MessageEnvelope): string {
|
function attributionFor(msg: MessageEnvelope): string {
|
||||||
const d = new Date(msg.date);
|
const d = new Date(msg.date);
|
||||||
const date = d.toLocaleDateString("en-US", {
|
const date = d.toLocaleDateString("en-US", {
|
||||||
@@ -71,69 +78,286 @@ function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initials(msg: MessageEnvelope): string {
|
function initials(msg: MessageEnvelope): string {
|
||||||
const name = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?";
|
const name = (msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?").trim();
|
||||||
return name.trim().slice(0, 1).toUpperCase();
|
const words = name.split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
|
||||||
|
return name.slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable per-sender hue so a sender's avatar + name share a colour (like the mockup).
|
||||||
|
function hue(msg: MessageEnvelope): number {
|
||||||
|
const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase();
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRelative(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const base = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const mins = Math.round(diff / 60000);
|
||||||
|
const hrs = Math.round(diff / 3600000);
|
||||||
|
const days = Math.round(diff / 86400000);
|
||||||
|
let rel = "";
|
||||||
|
if (mins < 1) rel = "just now";
|
||||||
|
else if (mins < 60) rel = `${mins} min ago`;
|
||||||
|
else if (hrs < 24) rel = `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
|
||||||
|
else if (days < 30) rel = `${days} day${days === 1 ? "" : "s"} ago`;
|
||||||
|
return rel ? `${base} (${rel})` : `${base} ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFull(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCATION_LABEL: Record<string, string> = {
|
||||||
|
inbox: "Inbox",
|
||||||
|
sent: "Sent",
|
||||||
|
archive: "Archive",
|
||||||
|
drafts: "Drafts",
|
||||||
|
trash: "Trash",
|
||||||
|
junk: "Spam",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ActionsMenu({
|
||||||
|
onReply,
|
||||||
|
onPrint,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
onReply: (kind: string) => void;
|
||||||
|
onPrint: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const item = (label: string, onClick: () => void) => (
|
||||||
|
<button
|
||||||
|
className="block w-full px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{t("mail.actions")}
|
||||||
|
<span className="text-[9px]">▾</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
|
||||||
|
{item(t("mail.reply"), () => onReply("reply"))}
|
||||||
|
{item(t("mail.replyAll"), () => onReply("reply_all"))}
|
||||||
|
{item(t("mail.forward"), () => onReply("forward"))}
|
||||||
|
{item(t("mail.forwardAsAttachment"), () => onReply("forward"))}
|
||||||
|
{item(t("mail.print"), onPrint)}
|
||||||
|
<div className="my-1 border-t border-app-border" />
|
||||||
|
{item(t("mail.delete"), onDelete)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageItem({
|
function MessageItem({
|
||||||
message,
|
message,
|
||||||
expanded,
|
expanded,
|
||||||
showReply,
|
location,
|
||||||
|
align,
|
||||||
|
accentColor,
|
||||||
|
boxClassName,
|
||||||
|
zIndex,
|
||||||
|
showReplyBar,
|
||||||
onToggle,
|
onToggle,
|
||||||
onReply,
|
onReply,
|
||||||
|
onToggleRead,
|
||||||
|
onPrint,
|
||||||
|
onDelete,
|
||||||
innerRef,
|
innerRef,
|
||||||
}: {
|
}: {
|
||||||
message: MessageEnvelope;
|
message: MessageEnvelope;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
showReply: boolean;
|
location: string;
|
||||||
|
align: "start" | "end";
|
||||||
|
accentColor: string | null;
|
||||||
|
boxClassName: string;
|
||||||
|
zIndex: number;
|
||||||
|
showReplyBar: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onReply: (kind: string) => void;
|
onReply: (kind: string) => void;
|
||||||
|
onToggleRead: () => void;
|
||||||
|
onPrint: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
innerRef?: (el: HTMLDivElement | null) => void;
|
innerRef?: (el: HTMLDivElement | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
const replyBtn = (kind: string, label: string, icon: string) => (
|
const replyBtn = (kind: string, label: string, icon: string) => (
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-md border border-app-border px-2.5 py-1 text-xs text-app-fg hover:bg-app-hover"
|
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
|
||||||
onClick={() => onReply(kind)}
|
onClick={() => onReply(kind)}
|
||||||
>
|
>
|
||||||
<Icon name={icon} size={14} />
|
<Icon name={icon} size={14} />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
const h = hue(message);
|
||||||
|
// Our own IMAP accounts use their assigned colour; everyone else a stable hue.
|
||||||
|
const avatarBg = accentColor ?? `hsl(${h} 45% 45%)`;
|
||||||
|
const nameColor = accentColor ?? `hsl(${h} 55% 62%)`;
|
||||||
|
const avatar = () => (
|
||||||
|
<span
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
|
||||||
|
style={{ backgroundColor: avatarBg }}
|
||||||
|
>
|
||||||
|
{initials(message)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const senderName = (
|
||||||
|
<span className="font-semibold" style={{ color: nameColor }}>
|
||||||
|
{message.from_addr[0]?.name || message.from_addr[0]?.email || "(unknown)"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const multiRecipient = message.to_addr.length + message.cc_addr.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="message"
|
data-testid="message"
|
||||||
data-message-id={message.id}
|
data-message-id={message.id}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
className="overflow-hidden rounded-lg border border-app-border bg-app-surface"
|
style={{ zIndex }}
|
||||||
|
className={`relative ${boxClassName}`}
|
||||||
>
|
>
|
||||||
<button className="flex w-full items-start gap-3 px-3 py-2.5 text-left hover:bg-app-hover" onClick={onToggle}>
|
{!expanded ? (
|
||||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-app-active text-sm font-medium text-app-fg">
|
// Collapsed: single-line row (avatar · sender · inline snippet · date).
|
||||||
{initials(message)}
|
// Aligned to the visible (non-overlapped) edge of the stacked card.
|
||||||
</span>
|
<button
|
||||||
<span className="min-w-0 flex-1">
|
className={`flex w-full gap-3 px-4 py-2.5 text-left hover:bg-app-hover ${
|
||||||
<span className="flex items-baseline gap-2">
|
align === "end" ? "items-end" : "items-start"
|
||||||
{message.is_unread && <span className="h-2 w-2 shrink-0 rounded-full bg-app-accent" />}
|
}`}
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">
|
onClick={onToggle}
|
||||||
<span className="font-medium text-app-fg">{formatAddress(message.from_addr[0])}</span>
|
>
|
||||||
<span className="text-app-faint"> → {formatList(message.to_addr)}</span>
|
{avatar()}
|
||||||
{message.cc_addr.length > 0 && (
|
<span className="min-w-0 flex-1 truncate text-sm">
|
||||||
<span className="text-app-faint"> · cc: {formatList(message.cc_addr)}</span>
|
{senderName}
|
||||||
)}
|
<span className="ml-2 text-app-faint">{message.snippet}</span>
|
||||||
{message.bcc_addr.length > 0 && (
|
|
||||||
<span className="text-app-faint"> · bcc: {formatList(message.bcc_addr)}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-[13px] text-app-muted">{message.snippet}</span>
|
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
) : (
|
||||||
{expanded && (
|
<div>
|
||||||
<div className="border-t border-app-border">
|
<div className="flex items-start gap-3 px-4 pb-2 pt-3">
|
||||||
<MessageBody message={message} />
|
{avatar()}
|
||||||
{showReply && (
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex justify-end gap-2 border-t border-app-border px-3 py-2">
|
{/* line 1: sender + email ············· date (whole row collapses) */}
|
||||||
|
<button
|
||||||
|
className="flex w-full items-baseline justify-between gap-2 text-left"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-sm">
|
||||||
|
{senderName}
|
||||||
|
{message.from_addr[0]?.email && (
|
||||||
|
<span className="text-app-faint"> <{message.from_addr[0].email}></span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
|
||||||
|
</button>
|
||||||
|
{/* line 2: to … ▼ ············· location ○ reply actions */}
|
||||||
|
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
className="flex min-w-0 items-center gap-1 text-left text-xs text-app-muted"
|
||||||
|
onClick={() => setShowDetails((v) => !v)}
|
||||||
|
aria-expanded={showDetails}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{t("mail.to")} {recipientSummary(message.to_addr)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||||
|
{location && <span className="font-semibold text-app-fg">{location}</span>}
|
||||||
|
<button
|
||||||
|
title={t("mail.markReadUnread")}
|
||||||
|
aria-label={t("mail.markReadUnread")}
|
||||||
|
onClick={onToggleRead}
|
||||||
|
className="grid h-4 w-4 place-items-center"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-3 w-3 rounded-full border ${
|
||||||
|
message.is_unread
|
||||||
|
? "border-app-accent bg-app-accent"
|
||||||
|
: "border-app-faint"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||||
|
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
||||||
|
>
|
||||||
|
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
||||||
|
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
|
||||||
|
</button>
|
||||||
|
<ActionsMenu onReply={onReply} onPrint={onPrint} onDelete={onDelete} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showDetails && (
|
||||||
|
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-app-border pt-2 text-xs text-app-muted">
|
||||||
|
<dt className="text-app-faint">{t("mail.from")}</dt>
|
||||||
|
<dd className="truncate">{formatList(message.from_addr)}</dd>
|
||||||
|
<dt className="text-app-faint">{t("mail.to")}</dt>
|
||||||
|
<dd className="truncate">{formatList(message.to_addr)}</dd>
|
||||||
|
{message.cc_addr.length > 0 && (
|
||||||
|
<>
|
||||||
|
<dt className="text-app-faint">Cc</dt>
|
||||||
|
<dd className="truncate">{formatList(message.cc_addr)}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-app-faint">{t("mail.subjectLabel")}</dt>
|
||||||
|
<dd className="truncate">{message.subject || "(no subject)"}</dd>
|
||||||
|
<dt className="text-app-faint">{t("mail.dateLabel")}</dt>
|
||||||
|
<dd>{fmtFull(message.date)}</dd>
|
||||||
|
<dt className="text-app-faint">{t("mail.sizeLabel")}</dt>
|
||||||
|
<dd>{fmtSize(message.size_bytes)}</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<MessageBody message={message} />
|
||||||
|
</div>
|
||||||
|
{showReplyBar && (
|
||||||
|
<div className="flex gap-2 border-t border-app-border px-4 py-3">
|
||||||
{replyBtn("reply", t("mail.reply"), "reply")}
|
{replyBtn("reply", t("mail.reply"), "reply")}
|
||||||
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
|
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
|
||||||
{replyBtn("forward", t("mail.forward"), "forward")}
|
{replyBtn("forward", t("mail.forward"), "forward")}
|
||||||
@@ -145,8 +369,16 @@ function MessageItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadView({ subject, messages, order, replyKindSignal }: ThreadViewProps) {
|
export function ThreadView({
|
||||||
|
subject,
|
||||||
|
messages,
|
||||||
|
order,
|
||||||
|
mailboxes,
|
||||||
|
accounts,
|
||||||
|
replyKindSignal,
|
||||||
|
}: ThreadViewProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const newest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
const newest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||||
|
|
||||||
const defaultExpanded = useMemo(() => {
|
const defaultExpanded = useMemo(() => {
|
||||||
@@ -159,9 +391,54 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread
|
|||||||
const [expanded, setExpanded] = useState<Set<string>>(defaultExpanded);
|
const [expanded, setExpanded] = useState<Set<string>>(defaultExpanded);
|
||||||
useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]);
|
useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]);
|
||||||
|
|
||||||
// The reply currently open: which message it targets, and its prefilled draft.
|
|
||||||
const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null);
|
const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null);
|
||||||
|
|
||||||
|
const flags = useMutation({
|
||||||
|
mutationFn: ({ id, add, remove }: { id: string; add: string[]; remove: string[] }) =>
|
||||||
|
api.messageFlags(id, add, remove),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["thread"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const move = useMutation({
|
||||||
|
mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) =>
|
||||||
|
api.moveMessage(id, mailboxId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["thread"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const specialUse = useMemo(() => {
|
||||||
|
const m = new Map<string, string | null>();
|
||||||
|
for (const mb of mailboxes ?? []) m.set(mb.id, mb.special_use);
|
||||||
|
return m;
|
||||||
|
}, [mailboxes]);
|
||||||
|
const locationFor = (msg: MessageEnvelope) => {
|
||||||
|
const su = specialUse.get(msg.mailbox_id);
|
||||||
|
return su ? (LOCATION_LABEL[su] ?? su.charAt(0).toUpperCase() + su.slice(1)) : "";
|
||||||
|
};
|
||||||
|
const trashFor = (accountId: string) =>
|
||||||
|
(mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id;
|
||||||
|
|
||||||
|
const accountColor = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const a of accounts ?? []) m.set(a.email.toLowerCase(), a.color);
|
||||||
|
return m;
|
||||||
|
}, [accounts]);
|
||||||
|
const accentFor = (msg: MessageEnvelope) =>
|
||||||
|
accountColor.get((msg.from_addr[0]?.email ?? "").toLowerCase()) ?? null;
|
||||||
|
|
||||||
|
const toggleRead = (msg: MessageEnvelope) =>
|
||||||
|
flags.mutate(
|
||||||
|
msg.is_unread ? { id: msg.id, add: ["\\Seen"], remove: [] } : { id: msg.id, add: [], remove: ["\\Seen"] },
|
||||||
|
);
|
||||||
|
const deleteMessage = (msg: MessageEnvelope) => {
|
||||||
|
const trash = trashFor(msg.account_id);
|
||||||
|
if (trash) move.mutate({ id: msg.id, mailboxId: trash });
|
||||||
|
};
|
||||||
|
|
||||||
const openReply = (msg: MessageEnvelope, kind: string) => {
|
const openReply = (msg: MessageEnvelope, kind: string) => {
|
||||||
setExpanded((prev) => new Set(prev).add(msg.id));
|
setExpanded((prev) => new Set(prev).add(msg.id));
|
||||||
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
|
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
|
||||||
@@ -178,6 +455,7 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread
|
|||||||
}, [newest?.id]);
|
}, [newest?.id]);
|
||||||
|
|
||||||
const visual = order === "asc" ? messages : [...messages].reverse();
|
const visual = order === "asc" ? messages : [...messages].reverse();
|
||||||
|
const allExpanded = messages.length > 0 && messages.every((m) => expanded.has(m.id));
|
||||||
|
|
||||||
const toggle = (id: string) =>
|
const toggle = (id: string) =>
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
@@ -187,43 +465,83 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default action expands everything; collapsing leaves only the latest message open.
|
||||||
|
const toggleAll = () =>
|
||||||
|
setExpanded(
|
||||||
|
allExpanded ? new Set(newest ? [newest.id] : []) : new Set(messages.map((m) => m.id)),
|
||||||
|
);
|
||||||
|
|
||||||
const composerFor = (id: string) =>
|
const composerFor = (id: string) =>
|
||||||
composing?.anchorId === id ? (
|
composing?.anchorId === id ? (
|
||||||
<div className="py-2">
|
<div className="px-4 py-3">
|
||||||
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
||||||
<div className="sticky top-0 z-10 flex h-[44px] shrink-0 items-center justify-between border-b border-app-border bg-app-surface px-4">
|
<div className="sticky top-0 z-10 flex h-[44px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
|
||||||
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
||||||
<button
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
className="shrink-0 text-xs text-app-accent hover:underline"
|
<button
|
||||||
onClick={() => setExpanded(new Set(messages.map((m) => m.id)))}
|
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
>
|
title={t("mail.print")}
|
||||||
{t("mail.expandAll")}
|
aria-label={t("mail.print")}
|
||||||
</button>
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
<Icon name="print" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
title={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
|
||||||
|
aria-label={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
|
||||||
|
onClick={toggleAll}
|
||||||
|
>
|
||||||
|
<Icon name={allExpanded ? "collapse-all" : "expand-all"} size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 p-3">
|
<div className="p-3">
|
||||||
{visual.map((message) => {
|
{visual.map((message, i) => {
|
||||||
const isNewest = Boolean(newest && message.id === newest.id);
|
const isNewest = Boolean(newest && newest.id === message.id);
|
||||||
const isComposing = composing?.anchorId === 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 (
|
return (
|
||||||
<Fragment key={message.id}>
|
<Fragment key={message.id}>
|
||||||
<MessageItem
|
<MessageItem
|
||||||
message={message}
|
message={message}
|
||||||
expanded={expanded.has(message.id)}
|
expanded={isExpanded}
|
||||||
showReply={expanded.has(message.id) && !isComposing}
|
location={locationFor(message)}
|
||||||
|
align={order === "asc" ? "start" : "end"}
|
||||||
|
accentColor={accentFor(message)}
|
||||||
|
boxClassName={boxClassName}
|
||||||
|
zIndex={z}
|
||||||
|
showReplyBar={isNewest && composing?.anchorId !== message.id}
|
||||||
onToggle={() => toggle(message.id)}
|
onToggle={() => toggle(message.id)}
|
||||||
onReply={(kind) => openReply(message, kind)}
|
onReply={(kind) => openReply(message, kind)}
|
||||||
|
onToggleRead={() => toggleRead(message)}
|
||||||
|
onPrint={() => window.print()}
|
||||||
|
onDelete={() => deleteMessage(message)}
|
||||||
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
|
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
|
||||||
/>
|
/>
|
||||||
{isNewest ? (
|
{isNewest && (
|
||||||
<div data-testid="composer-anchor">{composerFor(message.id)}</div>
|
<div data-testid="composer-anchor">
|
||||||
) : (
|
{composing?.anchorId === message.id ? composerFor(message.id) : null}
|
||||||
composerFor(message.id)
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{composing?.anchorId === message.id && !isNewest && composerFor(message.id)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export const en = {
|
|||||||
"mail.selectConversation": "Select a conversation.",
|
"mail.selectConversation": "Select a conversation.",
|
||||||
"mail.loadingThread": "Loading thread…",
|
"mail.loadingThread": "Loading thread…",
|
||||||
"mail.expandAll": "Expand all",
|
"mail.expandAll": "Expand all",
|
||||||
|
"mail.collapseAll": "Collapse all",
|
||||||
|
"mail.print": "Print",
|
||||||
"mail.orderOldest": "Oldest first",
|
"mail.orderOldest": "Oldest first",
|
||||||
"mail.orderNewest": "Newest first",
|
"mail.orderNewest": "Newest first",
|
||||||
"filter.all": "All mail",
|
"filter.all": "All mail",
|
||||||
@@ -79,6 +81,15 @@ export const en = {
|
|||||||
"mail.loadImages": "Load images",
|
"mail.loadImages": "Load images",
|
||||||
"mail.alwaysFromSender": "Always from this sender",
|
"mail.alwaysFromSender": "Always from this sender",
|
||||||
"mail.to": "to",
|
"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.messageCount": "{count} messages",
|
||||||
"mail.downloadAll": "Download all {count} attachments",
|
"mail.downloadAll": "Download all {count} attachments",
|
||||||
"mail.downloadZip": "Download zip",
|
"mail.downloadZip": "Download zip",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
--app-bg: 27 30 32; /* main background */
|
--app-bg: 27 30 32; /* main background */
|
||||||
--app-surface: 33 36 39; /* panels (slightly elevated) */
|
--app-surface: 33 36 39; /* panels (slightly elevated) */
|
||||||
--app-elevated: 39 42 46; /* popovers, composer */
|
--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-fg: 226 228 231;
|
||||||
--app-muted: 150 156 164;
|
--app-muted: 150 156 164;
|
||||||
--app-faint: 108 114 122;
|
--app-faint: 108 114 122;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface MessageEnvelope {
|
|||||||
is_starred: boolean;
|
is_starred: boolean;
|
||||||
has_attachments: boolean;
|
has_attachments: boolean;
|
||||||
has_remote_images: boolean;
|
has_remote_images: boolean;
|
||||||
|
size_bytes: number;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function MailLayout() {
|
|||||||
// Which special-use folder the reading pane is showing (drives the toolbar).
|
// Which special-use folder the reading pane is showing (drives the toolbar).
|
||||||
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
|
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
|
||||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||||
|
const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
|
||||||
const threadsQuery = useQuery({
|
const threadsQuery = useQuery({
|
||||||
queryKey: ["threads", view],
|
queryKey: ["threads", view],
|
||||||
queryFn: () => api.threads(view),
|
queryFn: () => api.threads(view),
|
||||||
@@ -444,6 +445,8 @@ export function MailLayout() {
|
|||||||
subject={detailQuery.data.subject}
|
subject={detailQuery.data.subject}
|
||||||
messages={detailQuery.data.messages}
|
messages={detailQuery.data.messages}
|
||||||
order={detailQuery.data.order}
|
order={detailQuery.data.order}
|
||||||
|
mailboxes={mailboxesQuery.data ?? []}
|
||||||
|
accounts={accounts}
|
||||||
replyKindSignal={replySignal}
|
replyKindSignal={replySignal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
|
|||||||
is_starred: false,
|
is_starred: false,
|
||||||
has_attachments: false,
|
has_attachments: false,
|
||||||
has_remote_images: false,
|
has_remote_images: false,
|
||||||
|
size_bytes: 1024,
|
||||||
snippet: `snippet ${id}`,
|
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 messages = [msg("a", 0), msg("b", 1, true), msg("c", 2)];
|
||||||
const { container } = render(wrap(<ThreadView subject="Subject" messages={messages} order="asc" />));
|
const { container } = render(wrap(<ThreadView subject="Subject" messages={messages} order="asc" />));
|
||||||
// An expanded message renders a MessageBody (iframe/loading/error); collapsed does not.
|
// 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 aNode = container.querySelector('[data-message-id="a"]') as HTMLElement;
|
||||||
const bNode = screen.getByText("snippet b").closest('[data-testid="message"]') as HTMLElement;
|
const bNode = container.querySelector('[data-message-id="b"]') as HTMLElement;
|
||||||
const hasBody = (el: HTMLElement) =>
|
const hasBody = (el: HTMLElement) =>
|
||||||
el.querySelector("iframe") !== null ||
|
el.querySelector("iframe") !== null ||
|
||||||
/Loading thread|Couldn't load/i.test(el.textContent ?? "");
|
/Loading thread|Couldn't load/i.test(el.textContent ?? "");
|
||||||
|
|||||||
Reference in New Issue
Block a user