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>
|
|
|
|
|
)}
|
2026-07-18 15:27:09 +02:00
|
|
|
{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">
|
2026-07-18 15:27:09 +02:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|