2026-08-03 20:53:35 +02:00
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-07-17 23:20:34 +02:00
|
|
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
|
|
|
|
|
import { useT } from "../../i18n";
|
2026-10-06 21:50:29 +02:00
|
|
|
import { personHue } from "../../lib/avatarColor";
|
2026-07-19 18:58:01 +02:00
|
|
|
import { api } from "../../lib/api";
|
2026-07-20 23:12:25 +02:00
|
|
|
import { printMessages } from "../../lib/print";
|
2026-10-08 20:20:21 +02:00
|
|
|
import { replyTargets } from "../../lib/replyTargets";
|
2026-09-28 08:09:09 +02:00
|
|
|
import { useIsMobile } from "../../lib/useIsMobile";
|
2026-07-28 16:33:34 +02:00
|
|
|
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
|
2026-07-19 18:58:01 +02:00
|
|
|
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
2026-07-27 08:30:24 +02:00
|
|
|
import { PluginSlot } from "../../plugins/PluginSlot";
|
2026-07-17 23:20:34 +02:00
|
|
|
import { Composer, type ComposerInit } from "../compose/Composer";
|
|
|
|
|
import { Icon } from "../Icon";
|
2026-08-06 19:23:27 +02:00
|
|
|
import { AvatarImage } from "../SenderAvatar";
|
|
|
|
|
import { clearPdfPasswordCache } from "./AttachmentPreview";
|
2026-08-03 20:53:35 +02:00
|
|
|
import { LabelChips } from "../mail/LabelChips";
|
2026-07-17 23:20:34 +02:00
|
|
|
import { MessageBody } from "./MessageBody";
|
|
|
|
|
|
|
|
|
|
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
|
|
|
|
// `messages` is ALWAYS ascending (oldest first); the ONLY branch on `order` is the
|
2026-07-19 18:58:01 +02:00
|
|
|
// visual reversal below. The composer anchor is attached to the newest message.
|
2026-07-17 23:20:34 +02:00
|
|
|
export interface ThreadViewProps {
|
|
|
|
|
subject: string;
|
|
|
|
|
messages: MessageEnvelope[];
|
|
|
|
|
order: "asc" | "desc";
|
2026-07-19 18:58:01 +02:00
|
|
|
mailboxes?: MailboxOut[];
|
|
|
|
|
accounts?: AccountOut[];
|
2026-07-17 23:20:34 +02:00
|
|
|
replyKindSignal?: { kind: string; seq: number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatAddress(a: { name: string; email: string } | undefined): string {
|
|
|
|
|
if (!a || !a.email) return "(unknown)";
|
|
|
|
|
return a.name ? `${a.name} (${a.email})` : a.email;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatList(addrs: { name: string; email: string }[]): string {
|
|
|
|
|
return addrs.length > 0 ? addrs.map(formatAddress).join(", ") : "(no recipient)";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:58:01 +02:00
|
|
|
function recipientSummary(addrs: { name: string; email: string }[]): string {
|
|
|
|
|
if (addrs.length === 0) return "(no recipient)";
|
|
|
|
|
const first = addrs[0].name || addrs[0].email;
|
|
|
|
|
return addrs.length > 1 ? `${first} +${addrs.length - 1}` : first;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
function attributionFor(msg: MessageEnvelope): string {
|
|
|
|
|
const d = new Date(msg.date);
|
2026-07-28 16:33:34 +02:00
|
|
|
const date = d.toLocaleDateString(getDateLocale(), {
|
2026-07-17 23:20:34 +02:00
|
|
|
weekday: "short",
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
});
|
2026-07-28 16:33:34 +02:00
|
|
|
const time = d.toLocaleTimeString("en-US", {
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
hour12: getHour12(),
|
|
|
|
|
});
|
2026-07-17 23:20:34 +02:00
|
|
|
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
|
|
|
|
|
return `On ${date}, at ${time}, ${who} wrote:`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 21:36:47 +02:00
|
|
|
/** The header block above a forwarded message, as mail programs write it:
|
|
|
|
|
* who sent it (name and address), when, its subject and recipients. */
|
|
|
|
|
function forwardHeaderFor(msg: MessageEnvelope, subject: string): string {
|
|
|
|
|
const d = new Date(msg.date);
|
|
|
|
|
const date = d.toLocaleString(getDateLocale(), {
|
|
|
|
|
weekday: "short",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
hour12: getHour12(),
|
|
|
|
|
});
|
|
|
|
|
const person = (a: { name: string; email: string }) =>
|
|
|
|
|
a.name && a.name !== a.email ? `${a.name} <${a.email}>` : a.email;
|
|
|
|
|
const lines = [
|
|
|
|
|
"---------- Forwarded message ---------",
|
|
|
|
|
`From: ${msg.from_addr.map(person).join(", ")}`,
|
|
|
|
|
`Date: ${date}`,
|
|
|
|
|
`Subject: ${msg.subject || subject}`,
|
|
|
|
|
];
|
|
|
|
|
if (msg.to_addr.length) lines.push(`To: ${msg.to_addr.map(person).join(", ")}`);
|
|
|
|
|
if (msg.cc_addr.length) lines.push(`Cc: ${msg.cc_addr.map(person).join(", ")}`);
|
|
|
|
|
return lines.join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 20:20:21 +02:00
|
|
|
function buildReplyInit(
|
|
|
|
|
subject: string,
|
|
|
|
|
msg: MessageEnvelope,
|
|
|
|
|
kind: string,
|
|
|
|
|
own: Set<string>,
|
|
|
|
|
): ComposerInit {
|
2026-07-17 23:20:34 +02:00
|
|
|
const norm = subject.replace(/^\s*(re|fwd?):\s*/i, "");
|
|
|
|
|
const quote = { attribution: attributionFor(msg), messageId: msg.id };
|
|
|
|
|
if (kind === "forward") {
|
|
|
|
|
return {
|
|
|
|
|
accountId: msg.account_id,
|
|
|
|
|
threadId: msg.thread_id,
|
|
|
|
|
replyToMessageId: msg.id,
|
|
|
|
|
kind,
|
|
|
|
|
subject: `Fwd: ${norm}`,
|
2026-10-08 21:36:47 +02:00
|
|
|
quote: { attribution: forwardHeaderFor(msg, subject), messageId: msg.id },
|
2026-10-06 20:15:17 +02:00
|
|
|
forwardFrom: msg.id,
|
2026-07-17 23:20:34 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-10-08 20:20:21 +02:00
|
|
|
const targets = replyTargets(msg, own, kind);
|
|
|
|
|
const to = targets.to.join(", ");
|
|
|
|
|
const cc = targets.cc.join(", ");
|
2026-07-17 23:20:34 +02:00
|
|
|
return {
|
|
|
|
|
accountId: msg.account_id,
|
|
|
|
|
threadId: msg.thread_id,
|
|
|
|
|
replyToMessageId: msg.id,
|
|
|
|
|
kind,
|
|
|
|
|
to,
|
|
|
|
|
cc,
|
|
|
|
|
subject: `Re: ${norm}`,
|
|
|
|
|
quote,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initials(msg: MessageEnvelope): string {
|
2026-07-19 18:58:01 +02:00
|
|
|
const name = (msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?").trim();
|
|
|
|
|
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 {
|
2026-10-06 21:50:29 +02:00
|
|
|
return personHue(msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?");
|
2026-07-19 18:58:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-28 16:33:34 +02:00
|
|
|
return new Date(iso).toLocaleString(getDateLocale(), {
|
2026-07-19 18:58:01 +02:00
|
|
|
weekday: "short",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
minute: "2-digit",
|
2026-07-28 16:33:34 +02:00
|
|
|
hour12: getHour12(),
|
2026-07-19 18:58:01 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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({
|
2026-09-17 19:36:29 +02:00
|
|
|
messageId,
|
2026-07-19 18:58:01 +02:00
|
|
|
onReply,
|
|
|
|
|
onPrint,
|
|
|
|
|
onDelete,
|
2026-07-20 23:12:25 +02:00
|
|
|
replyDisabled = false,
|
2026-08-03 20:53:35 +02:00
|
|
|
allLabels,
|
|
|
|
|
messageLabels,
|
|
|
|
|
onToggleLabel,
|
|
|
|
|
onCreateLabel,
|
2026-07-19 18:58:01 +02:00
|
|
|
}: {
|
2026-09-17 19:36:29 +02:00
|
|
|
messageId: string;
|
2026-07-19 18:58:01 +02:00
|
|
|
onReply: (kind: string) => void;
|
|
|
|
|
onPrint: () => void;
|
|
|
|
|
onDelete: () => void;
|
2026-07-20 23:12:25 +02:00
|
|
|
replyDisabled?: boolean;
|
2026-08-03 20:53:35 +02:00
|
|
|
allLabels: string[];
|
|
|
|
|
messageLabels: string[];
|
|
|
|
|
onToggleLabel: (label: string) => void;
|
|
|
|
|
onCreateLabel: (name: string) => void;
|
2026-07-19 18:58:01 +02:00
|
|
|
}) {
|
|
|
|
|
const t = useT();
|
|
|
|
|
const [open, setOpen] = useState(false);
|
2026-08-03 20:53:35 +02:00
|
|
|
const [labelsOpen, setLabelsOpen] = useState(false);
|
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
|
const [newName, setNewName] = useState("");
|
2026-07-19 18:58:01 +02:00
|
|
|
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);
|
|
|
|
|
};
|
2026-07-20 23:12:25 +02:00
|
|
|
// Clicking inside the sandboxed message-body iframe doesn't reach document
|
|
|
|
|
// mousedown, but it blurs the parent window — close the menu on that too.
|
|
|
|
|
const onBlur = () => setOpen(false);
|
2026-07-19 18:58:01 +02:00
|
|
|
document.addEventListener("mousedown", onDoc);
|
2026-07-20 23:12:25 +02:00
|
|
|
window.addEventListener("blur", onBlur);
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("mousedown", onDoc);
|
|
|
|
|
window.removeEventListener("blur", onBlur);
|
|
|
|
|
};
|
2026-07-19 18:58:01 +02:00
|
|
|
}, [open]);
|
2026-08-03 20:53:35 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); }
|
|
|
|
|
}, [open]);
|
2026-07-19 18:58:01 +02:00
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
const item = (label: string, onClick: () => void, disabled = false) => (
|
2026-07-19 18:58:01 +02:00
|
|
|
<button
|
2026-07-20 23:12:25 +02:00
|
|
|
disabled={disabled}
|
|
|
|
|
className={`block w-full px-3 py-1.5 text-left text-xs ${
|
|
|
|
|
disabled
|
|
|
|
|
? "cursor-not-allowed text-app-faint"
|
|
|
|
|
: "text-app-fg hover:bg-app-hover"
|
|
|
|
|
}`}
|
2026-07-19 18:58:01 +02:00
|
|
|
onClick={() => {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
onClick();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div ref={ref} className="relative">
|
|
|
|
|
<button
|
2026-10-05 18:54:17 +02:00
|
|
|
className="wm-online-only flex items-center gap-1 rounded px-1.5 py-1 text-xs text-app-muted hover:bg-app-hover hover:text-app-fg"
|
2026-07-19 18:58:01 +02:00
|
|
|
onClick={() => setOpen((v) => !v)}
|
|
|
|
|
>
|
|
|
|
|
{t("mail.actions")}
|
2026-07-22 21:49:48 +02:00
|
|
|
<span className="text-[11px]">▾</span>
|
2026-07-19 18:58:01 +02:00
|
|
|
</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">
|
2026-07-20 23:12:25 +02:00
|
|
|
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
|
|
|
|
|
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
|
|
|
|
|
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
|
|
|
|
|
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
|
2026-07-19 18:58:01 +02:00
|
|
|
{item(t("mail.print"), onPrint)}
|
|
|
|
|
<div className="my-1 border-t border-app-border" />
|
2026-08-03 20:53:35 +02:00
|
|
|
{/* Labels submenu: multi-select checkboxes (menu stays open) + create new. */}
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
|
|
|
|
|
onClick={() => setLabelsOpen((v) => !v)}
|
|
|
|
|
>
|
|
|
|
|
{t("mail.labels")}
|
|
|
|
|
<span className="text-[11px] text-app-muted">{labelsOpen ? "▾" : "▸"}</span>
|
|
|
|
|
</button>
|
|
|
|
|
{labelsOpen && (
|
|
|
|
|
<div className="max-h-52 overflow-auto border-y border-app-border bg-app-surface/40">
|
|
|
|
|
{allLabels.map((l) => (
|
|
|
|
|
<label
|
|
|
|
|
key={l}
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs text-app-fg hover:bg-app-hover"
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
className="h-3.5 w-3.5 accent-app-accent"
|
|
|
|
|
checked={messageLabels.includes(l)}
|
|
|
|
|
onChange={() => onToggleLabel(l)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="truncate">{l}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
{allLabels.length === 0 && (
|
|
|
|
|
<div className="px-3 py-1.5 text-xs text-app-faint">{t("mail.noLabels")}</div>
|
|
|
|
|
)}
|
|
|
|
|
{creating ? (
|
|
|
|
|
<input
|
|
|
|
|
autoFocus
|
|
|
|
|
value={newName}
|
|
|
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter" && newName.trim()) {
|
|
|
|
|
onCreateLabel(newName.trim());
|
|
|
|
|
setNewName("");
|
|
|
|
|
setCreating(false);
|
|
|
|
|
} else if (e.key === "Escape") {
|
|
|
|
|
setCreating(false);
|
|
|
|
|
setNewName("");
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
placeholder={t("mail.labelName")}
|
|
|
|
|
className="m-1 w-[calc(100%-0.5rem)] rounded border border-app-border bg-app-surface px-2 py-1 text-xs outline-none focus:border-app-accent"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
className="block w-full px-3 py-1.5 text-left text-xs text-app-accent hover:bg-app-hover"
|
|
|
|
|
onClick={() => setCreating(true)}
|
|
|
|
|
>
|
|
|
|
|
{t("mail.createLabel")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-09-17 19:36:29 +02:00
|
|
|
{/* Plugin verbs that belong in the menu rather than on the header
|
|
|
|
|
(SPEC §11.3 message.actions.menu). Clicking anything inside closes
|
|
|
|
|
the menu, so a bundle only has to perform its action. */}
|
|
|
|
|
<div onClick={() => setOpen(false)}>
|
|
|
|
|
<PluginSlot name="message.actions.menu" props={{ messageId }} />
|
|
|
|
|
</div>
|
2026-08-03 20:53:35 +02:00
|
|
|
<div className="my-1 border-t border-app-border" />
|
2026-07-19 18:58:01 +02:00
|
|
|
{item(t("mail.delete"), onDelete)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-07-17 23:20:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MessageItem({
|
|
|
|
|
message,
|
|
|
|
|
expanded,
|
2026-07-19 18:58:01 +02:00
|
|
|
location,
|
|
|
|
|
align,
|
|
|
|
|
accentColor,
|
|
|
|
|
boxClassName,
|
|
|
|
|
zIndex,
|
|
|
|
|
showReplyBar,
|
2026-07-20 23:12:25 +02:00
|
|
|
canReply,
|
2026-07-22 21:49:48 +02:00
|
|
|
alsoIn,
|
2026-07-17 23:20:34 +02:00
|
|
|
onToggle,
|
|
|
|
|
onReply,
|
2026-07-26 09:28:48 +02:00
|
|
|
onMailto,
|
2026-07-19 18:58:01 +02:00
|
|
|
onToggleRead,
|
|
|
|
|
onPrint,
|
|
|
|
|
onDelete,
|
2026-07-17 23:20:34 +02:00
|
|
|
innerRef,
|
2026-08-03 20:53:35 +02:00
|
|
|
allLabels,
|
|
|
|
|
messageLabels,
|
|
|
|
|
onToggleLabel,
|
|
|
|
|
onCreateLabel,
|
2026-10-08 20:20:21 +02:00
|
|
|
ownAddresses,
|
2026-07-17 23:20:34 +02:00
|
|
|
}: {
|
|
|
|
|
message: MessageEnvelope;
|
|
|
|
|
expanded: boolean;
|
2026-07-19 18:58:01 +02:00
|
|
|
location: string;
|
|
|
|
|
align: "start" | "end";
|
|
|
|
|
accentColor: string | null;
|
|
|
|
|
boxClassName: string;
|
|
|
|
|
zIndex: number;
|
|
|
|
|
showReplyBar: boolean;
|
2026-07-20 23:12:25 +02:00
|
|
|
canReply: boolean;
|
2026-07-22 21:49:48 +02:00
|
|
|
alsoIn: string[];
|
2026-07-17 23:20:34 +02:00
|
|
|
onToggle: () => void;
|
|
|
|
|
onReply: (kind: string) => void;
|
2026-07-26 09:28:48 +02:00
|
|
|
onMailto: (email: string) => void;
|
2026-07-19 18:58:01 +02:00
|
|
|
onToggleRead: () => void;
|
|
|
|
|
onPrint: () => void;
|
|
|
|
|
onDelete: () => void;
|
2026-07-17 23:20:34 +02:00
|
|
|
innerRef?: (el: HTMLDivElement | null) => void;
|
2026-08-03 20:53:35 +02:00
|
|
|
allLabels: string[];
|
|
|
|
|
messageLabels: string[];
|
|
|
|
|
onToggleLabel: (label: string) => void;
|
|
|
|
|
onCreateLabel: (name: string) => void;
|
2026-10-08 20:20:21 +02:00
|
|
|
ownAddresses: Set<string>;
|
2026-07-17 23:20:34 +02:00
|
|
|
}) {
|
|
|
|
|
const t = useT();
|
2026-07-19 18:58:01 +02:00
|
|
|
const [showDetails, setShowDetails] = useState(false);
|
2026-07-17 23:20:34 +02:00
|
|
|
const replyBtn = (kind: string, label: string, icon: string) => (
|
|
|
|
|
<button
|
2026-10-05 21:11:41 +02:00
|
|
|
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"
|
2026-07-17 23:20:34 +02:00
|
|
|
onClick={() => onReply(kind)}
|
|
|
|
|
>
|
|
|
|
|
<Icon name={icon} size={14} />
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
2026-07-19 18:58:01 +02:00
|
|
|
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
|
2026-08-06 19:23:27 +02:00
|
|
|
className="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full text-sm font-semibold text-white"
|
2026-07-19 18:58:01 +02:00
|
|
|
style={{ backgroundColor: avatarBg }}
|
|
|
|
|
>
|
|
|
|
|
{initials(message)}
|
2026-08-06 19:23:27 +02:00
|
|
|
<AvatarImage email={message.from_addr[0]?.email} />
|
2026-07-19 18:58:01 +02:00
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
const senderName = (
|
|
|
|
|
<span className="font-semibold" style={{ color: nameColor }}>
|
|
|
|
|
{message.from_addr[0]?.name || message.from_addr[0]?.email || "(unknown)"}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
2026-10-08 20:20:21 +02:00
|
|
|
// Reply all only when it would reach more than one person (not counting you).
|
|
|
|
|
const allTargets = replyTargets(message, ownAddresses, "reply_all");
|
|
|
|
|
const multiRecipient = allTargets.to.length + allTargets.cc.length > 1;
|
2026-07-19 18:58:01 +02:00
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
data-testid="message"
|
|
|
|
|
data-message-id={message.id}
|
|
|
|
|
ref={innerRef}
|
2026-07-19 18:58:01 +02:00
|
|
|
style={{ zIndex }}
|
|
|
|
|
className={`relative ${boxClassName}`}
|
2026-07-17 23:20:34 +02:00
|
|
|
>
|
2026-07-19 18:58:01 +02:00
|
|
|
{!expanded ? (
|
|
|
|
|
// Collapsed: single-line row (avatar · sender · inline snippet · date).
|
|
|
|
|
// Aligned to the visible (non-overlapped) edge of the stacked card.
|
|
|
|
|
<button
|
|
|
|
|
className={`flex w-full gap-3 px-4 py-2.5 text-left hover:bg-app-hover ${
|
|
|
|
|
align === "end" ? "items-end" : "items-start"
|
|
|
|
|
}`}
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
>
|
|
|
|
|
{avatar()}
|
2026-07-22 21:49:48 +02:00
|
|
|
<span className="min-w-0 flex-1 truncate text-[15px]">
|
2026-07-19 18:58:01 +02:00
|
|
|
{senderName}
|
2026-07-22 21:49:48 +02:00
|
|
|
<span className="ml-2 text-xs text-app-faint">{message.snippet}</span>
|
2026-07-17 23:20:34 +02:00
|
|
|
</span>
|
2026-07-19 18:58:01 +02:00
|
|
|
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-start gap-3 px-4 pb-2 pt-3">
|
|
|
|
|
{avatar()}
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
{/* line 1: sender + email ············· date (whole row collapses) */}
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-baseline justify-between gap-2 text-left"
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
>
|
2026-07-22 21:49:48 +02:00
|
|
|
<span className="min-w-0 truncate text-[15px]">
|
2026-07-19 18:58:01 +02:00
|
|
|
{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>
|
2026-07-22 21:49:48 +02:00
|
|
|
<span className="text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
|
2026-07-19 18:58:01 +02:00
|
|
|
</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}
|
2026-10-05 18:54:17 +02:00
|
|
|
className="wm-online-only grid h-4 w-4 place-items-center"
|
2026-07-19 18:58:01 +02:00
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={`h-3 w-3 rounded-full border ${
|
|
|
|
|
message.is_unread
|
|
|
|
|
? "border-app-accent bg-app-accent"
|
|
|
|
|
: "border-app-faint"
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
2026-07-20 23:12:25 +02:00
|
|
|
{canReply && (
|
|
|
|
|
<button
|
2026-10-05 21:11:41 +02:00
|
|
|
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
2026-07-20 23:12:25 +02:00
|
|
|
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
|
|
|
|
>
|
|
|
|
|
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
|
|
|
|
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<ActionsMenu
|
2026-09-17 19:36:29 +02:00
|
|
|
messageId={message.id}
|
2026-07-20 23:12:25 +02:00
|
|
|
onReply={onReply}
|
|
|
|
|
onPrint={onPrint}
|
|
|
|
|
onDelete={onDelete}
|
|
|
|
|
replyDisabled={!canReply}
|
2026-08-03 20:53:35 +02:00
|
|
|
allLabels={allLabels}
|
|
|
|
|
messageLabels={messageLabels}
|
|
|
|
|
onToggleLabel={onToggleLabel}
|
|
|
|
|
onCreateLabel={onCreateLabel}
|
2026-07-20 23:12:25 +02:00
|
|
|
/>
|
2026-07-27 08:30:24 +02:00
|
|
|
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
|
|
|
|
|
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
|
2026-07-19 18:58:01 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-07-22 21:49:48 +02:00
|
|
|
{alsoIn.length > 0 && (
|
|
|
|
|
<div className="mt-0.5 truncate text-xs text-app-faint">
|
|
|
|
|
{t("mail.alsoIn")} {alsoIn.join(", ")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-19 18:58:01 +02:00
|
|
|
{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>
|
2026-07-27 08:30:24 +02:00
|
|
|
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
|
2026-10-06 21:50:29 +02:00
|
|
|
<PluginSlot
|
|
|
|
|
name="message.banner"
|
|
|
|
|
props={{ messageId: message.id, hasAttachments: message.has_attachments }}
|
|
|
|
|
/>
|
2026-07-19 18:58:01 +02:00
|
|
|
<div className="px-4 pb-3">
|
2026-07-26 09:28:48 +02:00
|
|
|
<MessageBody message={message} onMailto={onMailto} />
|
2026-07-19 18:58:01 +02:00
|
|
|
</div>
|
2026-07-20 23:12:25 +02:00
|
|
|
{showReplyBar && canReply && (
|
2026-07-19 18:58:01 +02:00
|
|
|
<div className="flex gap-2 border-t border-app-border px-4 py-3">
|
2026-07-17 23:20:34 +02:00
|
|
|
{replyBtn("reply", t("mail.reply"), "reply")}
|
|
|
|
|
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
|
|
|
|
|
{replyBtn("forward", t("mail.forward"), "forward")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:58:01 +02:00
|
|
|
export function ThreadView({
|
|
|
|
|
subject,
|
|
|
|
|
messages,
|
|
|
|
|
order,
|
|
|
|
|
mailboxes,
|
|
|
|
|
accounts,
|
|
|
|
|
replyKindSignal,
|
|
|
|
|
}: ThreadViewProps) {
|
2026-07-17 23:20:34 +02:00
|
|
|
const t = useT();
|
2026-07-19 18:58:01 +02:00
|
|
|
const queryClient = useQueryClient();
|
2026-07-17 23:20:34 +02:00
|
|
|
const newest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
|
|
|
|
|
|
|
|
|
const defaultExpanded = useMemo(() => {
|
|
|
|
|
const ids = new Set<string>();
|
|
|
|
|
for (const m of messages) if (m.is_unread) ids.add(m.id);
|
|
|
|
|
if (newest) ids.add(newest.id);
|
|
|
|
|
return ids;
|
|
|
|
|
}, [messages, newest]);
|
|
|
|
|
|
|
|
|
|
const [expanded, setExpanded] = useState<Set<string>>(defaultExpanded);
|
|
|
|
|
useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]);
|
|
|
|
|
|
|
|
|
|
const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null);
|
|
|
|
|
|
2026-07-19 18:58:01 +02:00
|
|
|
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"] });
|
2026-07-22 07:40:29 +02:00
|
|
|
// Mark read/unread changes the Inbox unread badge.
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
2026-07-19 18:58:01 +02:00
|
|
|
},
|
|
|
|
|
});
|
2026-08-03 20:53:35 +02:00
|
|
|
|
|
|
|
|
// --- labels: per-message storage + a thread union for display (chips + list) ---
|
|
|
|
|
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
|
|
|
|
const threadId = messages[0]?.thread_id ?? null;
|
2026-08-06 19:23:27 +02:00
|
|
|
// Forget remembered protected-PDF passwords when the open thread changes or closes.
|
|
|
|
|
useEffect(() => () => clearPdfPasswordCache(), [threadId]);
|
2026-08-03 20:53:35 +02:00
|
|
|
const allLabelsQuery = useQuery({ queryKey: ["labels-all"], queryFn: api.labelsAll });
|
|
|
|
|
const msgLabelsQuery = useQuery({
|
|
|
|
|
queryKey: ["message-labels", messageIds],
|
|
|
|
|
queryFn: () => api.messageLabelsBatch(messageIds),
|
|
|
|
|
enabled: messageIds.length > 0,
|
|
|
|
|
});
|
|
|
|
|
const allLabels = allLabelsQuery.data ?? [];
|
|
|
|
|
const msgLabels = msgLabelsQuery.data ?? {};
|
|
|
|
|
const threadLabels = useMemo(
|
|
|
|
|
() => [...new Set(Object.values(msgLabels).flat())].sort((a, b) => a.localeCompare(b)),
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
[msgLabelsQuery.data],
|
|
|
|
|
);
|
|
|
|
|
// Recompute + persist the thread's label union (list chips read this), then tell
|
|
|
|
|
// the sidebar + other queries a label change happened.
|
|
|
|
|
const persistUnionAndNotify = async () => {
|
|
|
|
|
const fresh = await api.messageLabelsBatch(messageIds);
|
|
|
|
|
const union = [...new Set(Object.values(fresh).flat())].sort((a, b) => a.localeCompare(b));
|
|
|
|
|
if (threadId) await api.setThreadLabels(threadId, union);
|
|
|
|
|
};
|
|
|
|
|
const onLabelsChanged = () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
|
|
|
|
window.dispatchEvent(new Event("webmail:labels-changed"));
|
|
|
|
|
};
|
|
|
|
|
const applyLabels = useMutation({
|
|
|
|
|
mutationFn: async ({ messageId, labels }: { messageId: string; labels: string[] }) => {
|
|
|
|
|
await api.setMessageLabels(messageId, labels);
|
|
|
|
|
await persistUnionAndNotify();
|
|
|
|
|
},
|
|
|
|
|
onSuccess: onLabelsChanged,
|
|
|
|
|
});
|
|
|
|
|
const removeThreadLabel = useMutation({
|
|
|
|
|
mutationFn: async (label: string) => {
|
|
|
|
|
for (const m of messages) {
|
|
|
|
|
const cur = msgLabels[m.id] ?? [];
|
|
|
|
|
if (cur.includes(label)) await api.setMessageLabels(m.id, cur.filter((x) => x !== label));
|
|
|
|
|
}
|
|
|
|
|
await persistUnionAndNotify();
|
|
|
|
|
},
|
|
|
|
|
onSuccess: onLabelsChanged,
|
|
|
|
|
});
|
|
|
|
|
const toggleLabel = (messageId: string, label: string) => {
|
|
|
|
|
const cur = msgLabels[messageId] ?? [];
|
|
|
|
|
const next = cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label];
|
|
|
|
|
applyLabels.mutate({ messageId, labels: next });
|
|
|
|
|
};
|
|
|
|
|
const createLabel = async (name: string, messageId: string) => {
|
|
|
|
|
await api.labelsCreate(name);
|
|
|
|
|
toggleLabel(messageId, name);
|
|
|
|
|
};
|
2026-07-19 18:58:01 +02:00
|
|
|
const move = useMutation({
|
|
|
|
|
mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) =>
|
|
|
|
|
api.moveMessage(id, mailboxId),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["thread"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
2026-07-22 07:40:29 +02:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
2026-07-19 18:58:01 +02:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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]);
|
2026-10-08 20:20:21 +02:00
|
|
|
// Your own addresses (every account's): replies never go back to them.
|
|
|
|
|
const ownAddresses = useMemo(() => new Set(accountColor.keys()), [accountColor]);
|
2026-07-19 18:58:01 +02:00
|
|
|
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 });
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
const openReply = (msg: MessageEnvelope, kind: string) => {
|
2026-07-20 23:12:25 +02:00
|
|
|
if (specialUse.get(msg.mailbox_id) === "trash") return; // no reply/forward from Trash
|
2026-07-17 23:20:34 +02:00
|
|
|
setExpanded((prev) => new Set(prev).add(msg.id));
|
2026-10-08 20:20:21 +02:00
|
|
|
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind, ownAddresses) });
|
2026-07-17 23:20:34 +02:00
|
|
|
};
|
|
|
|
|
|
2026-07-26 09:28:48 +02:00
|
|
|
// A mailto: link in the message body opens a fresh composer to that address,
|
|
|
|
|
// sent from the account this message belongs to.
|
|
|
|
|
const openMailto = (msg: MessageEnvelope, email: string) => {
|
|
|
|
|
setExpanded((prev) => new Set(prev).add(msg.id));
|
|
|
|
|
setComposing({
|
|
|
|
|
anchorId: msg.id,
|
|
|
|
|
init: { accountId: msg.account_id, kind: "new", to: email, subject: "" },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (replyKindSignal && newest) openReply(newest, replyKindSignal.kind);
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [replyKindSignal?.seq]);
|
|
|
|
|
|
|
|
|
|
const newestRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
newestRef.current?.scrollIntoView?.({ block: "nearest" });
|
|
|
|
|
}, [newest?.id]);
|
|
|
|
|
|
|
|
|
|
const visual = order === "asc" ? messages : [...messages].reverse();
|
2026-07-19 18:58:01 +02:00
|
|
|
const allExpanded = messages.length > 0 && messages.every((m) => expanded.has(m.id));
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
const toggle = (id: string) =>
|
|
|
|
|
setExpanded((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (next.has(id)) next.delete(id);
|
|
|
|
|
else next.add(id);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-19 18:58:01 +02:00
|
|
|
// 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)),
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
const composerRef = useRef<HTMLDivElement | null>(null);
|
2026-09-28 08:09:09 +02:00
|
|
|
// On a phone, reply/reply-all/forward open full screen rather than inline.
|
|
|
|
|
const mobile = useIsMobile();
|
2026-07-20 23:12:25 +02:00
|
|
|
// Bring the inline composer into view when it opens (reply/reply-all/forward).
|
|
|
|
|
useEffect(() => {
|
2026-09-28 08:09:09 +02:00
|
|
|
if (composing && !mobile) {
|
|
|
|
|
composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
|
|
|
}
|
|
|
|
|
}, [composing, mobile]);
|
2026-07-20 23:12:25 +02:00
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
const composerFor = (id: string) =>
|
|
|
|
|
composing?.anchorId === id ? (
|
2026-09-28 08:09:09 +02:00
|
|
|
mobile ? (
|
|
|
|
|
<div className="fixed inset-0 z-50 bg-app-elevated pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]">
|
|
|
|
|
<Composer init={composing.init} onClose={() => setComposing(null)} fullWindow />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div ref={composerRef} className="px-4 py-3">
|
|
|
|
|
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
2026-07-17 23:20:34 +02:00
|
|
|
) : null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
2026-07-22 21:49:48 +02:00
|
|
|
<div className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
|
2026-08-03 20:53:35 +02:00
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
|
|
|
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
|
|
|
|
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
|
|
|
|
|
</div>
|
2026-07-19 18:58:01 +02:00
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
|
|
|
<button
|
|
|
|
|
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
|
|
|
|
title={t("mail.print")}
|
|
|
|
|
aria-label={t("mail.print")}
|
2026-07-22 07:40:29 +02:00
|
|
|
onClick={() => void printMessages(subject, visual)}
|
2026-07-19 18:58:01 +02:00
|
|
|
>
|
|
|
|
|
<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>
|
2026-07-17 23:20:34 +02:00
|
|
|
</div>
|
2026-07-19 18:58:01 +02:00
|
|
|
<div className="p-3">
|
|
|
|
|
{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;
|
2026-07-20 23:12:25 +02:00
|
|
|
// Cards overlap by a negative top margin (tuck up under the card above),
|
|
|
|
|
// with the newer card kept on top by z-index. The card above is OLDER in
|
|
|
|
|
// oldest-first but NEWER in newest-first, so the rule differs by order:
|
|
|
|
|
// - oldest-first: an expanded card tucks up over the older card above it
|
|
|
|
|
// (its own top stays clear). Only overlap a collapsed card above.
|
|
|
|
|
// - newest-first: the card above is newer, so tucking an expanded card up
|
|
|
|
|
// would hide its header under that newer card. Only collapsed cards tuck
|
|
|
|
|
// up; an expanded card keeps its gap, so its top stays clear of the newer
|
|
|
|
|
// message and the older card BELOW tucks under it instead.
|
|
|
|
|
const overlap = order === "desc" ? !isExpanded : prevCollapsed;
|
2026-07-19 18:58:01 +02:00
|
|
|
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"
|
|
|
|
|
}`;
|
2026-07-20 23:12:25 +02:00
|
|
|
// Reply/Reply-all/Forward make no sense on a trashed message.
|
|
|
|
|
const canReply = specialUse.get(message.mailbox_id) !== "trash";
|
2026-07-22 21:49:48 +02:00
|
|
|
const alsoIn = (message.also_in_account_ids ?? [])
|
|
|
|
|
.map((id) => accounts?.find((a) => a.id === id)?.email)
|
|
|
|
|
.filter((e): e is string => Boolean(e));
|
2026-07-17 23:20:34 +02:00
|
|
|
return (
|
|
|
|
|
<Fragment key={message.id}>
|
|
|
|
|
<MessageItem
|
|
|
|
|
message={message}
|
2026-07-19 18:58:01 +02:00
|
|
|
expanded={isExpanded}
|
|
|
|
|
location={locationFor(message)}
|
|
|
|
|
align={order === "asc" ? "start" : "end"}
|
|
|
|
|
accentColor={accentFor(message)}
|
|
|
|
|
boxClassName={boxClassName}
|
|
|
|
|
zIndex={z}
|
|
|
|
|
showReplyBar={isNewest && composing?.anchorId !== message.id}
|
2026-07-20 23:12:25 +02:00
|
|
|
canReply={canReply}
|
2026-07-22 21:49:48 +02:00
|
|
|
alsoIn={alsoIn}
|
2026-07-17 23:20:34 +02:00
|
|
|
onToggle={() => toggle(message.id)}
|
|
|
|
|
onReply={(kind) => openReply(message, kind)}
|
2026-07-26 09:28:48 +02:00
|
|
|
onMailto={(email) => openMailto(message, email)}
|
2026-07-19 18:58:01 +02:00
|
|
|
onToggleRead={() => toggleRead(message)}
|
2026-07-20 23:12:25 +02:00
|
|
|
onPrint={() => void printMessages(subject, [message])}
|
2026-07-19 18:58:01 +02:00
|
|
|
onDelete={() => deleteMessage(message)}
|
2026-07-17 23:20:34 +02:00
|
|
|
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
|
2026-08-03 20:53:35 +02:00
|
|
|
allLabels={allLabels}
|
|
|
|
|
messageLabels={msgLabels[message.id] ?? []}
|
|
|
|
|
onToggleLabel={(l) => toggleLabel(message.id, l)}
|
|
|
|
|
onCreateLabel={(name) => void createLabel(name, message.id)}
|
2026-10-08 20:20:21 +02:00
|
|
|
ownAddresses={ownAddresses}
|
2026-07-17 23:20:34 +02:00
|
|
|
/>
|
2026-07-19 18:58:01 +02:00
|
|
|
{isNewest && (
|
|
|
|
|
<div data-testid="composer-anchor">
|
|
|
|
|
{composing?.anchorId === message.id ? composerFor(message.id) : null}
|
|
|
|
|
</div>
|
2026-07-17 23:20:34 +02:00
|
|
|
)}
|
2026-07-19 18:58:01 +02:00
|
|
|
{composing?.anchorId === message.id && !isNewest && composerFor(message.id)}
|
2026-07-17 23:20:34 +02:00
|
|
|
</Fragment>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|