Files
webmail-client/frontend/src/components/thread/ThreadView.tsx
T

234 lines
8.5 KiB
TypeScript
Raw Normal View History

2026-07-17 23:20:34 +02:00
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import type { MessageEnvelope } from "../../lib/types";
import { Composer, type ComposerInit } from "../compose/Composer";
import { Icon } from "../Icon";
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
// 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 {
subject: string;
messages: MessageEnvelope[];
order: "asc" | "desc";
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)";
}
// Attribution line prefixed to a quoted original, e.g.
// "On Fri, Jul 17, 2026, at 2:04 PM, Lukas Remis wrote:".
function attributionFor(msg: MessageEnvelope): string {
const d = new Date(msg.date);
const date = d.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
return `On ${date}, at ${time}, ${who} wrote:`;
}
function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): ComposerInit {
const from = msg.from_addr[0]?.email ?? "";
const toEmails = msg.to_addr.map((a) => a.email);
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}`,
quote,
};
}
const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from;
const cc = kind === "reply_all" ? msg.cc_addr.map((a) => a.email).join(", ") : "";
return {
accountId: msg.account_id,
threadId: msg.thread_id,
replyToMessageId: msg.id,
kind,
to,
cc,
subject: `Re: ${norm}`,
quote,
};
}
function initials(msg: MessageEnvelope): string {
const name = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?";
return name.trim().slice(0, 1).toUpperCase();
}
function MessageItem({
message,
expanded,
showReply,
onToggle,
onReply,
innerRef,
}: {
message: MessageEnvelope;
expanded: boolean;
showReply: boolean;
onToggle: () => void;
onReply: (kind: string) => void;
innerRef?: (el: HTMLDivElement | null) => void;
}) {
const t = useT();
const replyBtn = (kind: string, label: string, icon: string) => (
<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"
onClick={() => onReply(kind)}
>
<Icon name={icon} size={14} />
{label}
</button>
);
return (
<div
data-testid="message"
data-message-id={message.id}
ref={innerRef}
className="overflow-hidden rounded-lg border border-app-border bg-app-surface"
>
<button className="flex w-full items-start gap-3 px-3 py-2.5 text-left hover:bg-app-hover" onClick={onToggle}>
<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">
{initials(message)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
{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">
<span className="font-medium text-app-fg">{formatAddress(message.from_addr[0])}</span>
<span className="text-app-faint"> → {formatList(message.to_addr)}</span>
{message.cc_addr.length > 0 && (
<span className="text-app-faint"> · cc: {formatList(message.cc_addr)}</span>
)}
{message.bcc_addr.length > 0 && (
<span className="text-app-faint"> · bcc: {formatList(message.bcc_addr)}</span>
)}
2026-07-17 23:20:34 +02:00
</span>
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
</span>
<span className="block truncate text-[13px] text-app-muted">{message.snippet}</span>
</span>
</button>
{expanded && (
<div className="border-t border-app-border">
<MessageBody message={message} />
{showReply && (
<div className="flex justify-end gap-2 border-t border-app-border px-3 py-2">
{replyBtn("reply", t("mail.reply"), "reply")}
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
{replyBtn("forward", t("mail.forward"), "forward")}
</div>
)}
</div>
)}
</div>
);
}
export function ThreadView({ subject, messages, order, replyKindSignal }: ThreadViewProps) {
const t = useT();
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]);
// The reply currently open: which message it targets, and its prefilled draft.
const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null);
const openReply = (msg: MessageEnvelope, kind: string) => {
setExpanded((prev) => new Set(prev).add(msg.id));
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
};
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();
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const composerFor = (id: string) =>
composing?.anchorId === id ? (
<div className="py-2">
<Composer init={composing.init} onClose={() => setComposing(null)} />
</div>
) : null;
return (
<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">
2026-07-17 23:20:34 +02:00
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<button
className="shrink-0 text-xs text-app-accent hover:underline"
onClick={() => setExpanded(new Set(messages.map((m) => m.id)))}
>
{t("mail.expandAll")}
</button>
</div>
<div className="space-y-2 p-3">
{visual.map((message) => {
const isNewest = Boolean(newest && message.id === newest.id);
const isComposing = composing?.anchorId === message.id;
return (
<Fragment key={message.id}>
<MessageItem
message={message}
expanded={expanded.has(message.id)}
showReply={expanded.has(message.id) && !isComposing}
onToggle={() => toggle(message.id)}
onReply={(kind) => openReply(message, kind)}
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
/>
{isNewest ? (
<div data-testid="composer-anchor">{composerFor(message.id)}</div>
) : (
composerFor(message.id)
)}
</Fragment>
);
})}
</div>
</div>
);
}