feat(webmail): conversations - list, stacked reader, inline reply, settings switch

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 16:46:11 -03:00
1 parent 86a69d1a96
commit 2080489319
14 files changed
+1225 -39

No files matched your search

@@ -12,6 +12,7 @@ import {
Clock,
RefreshCw,
Trash2,
CornerUpLeft,
} from "lucide-react";
import { useI18n } from "../../../../../i18n/provider";
import { useMailEvents } from "../../../../../lib/useMailEvents";
@@ -27,10 +28,16 @@ import {
moveTargets,
type ListFilter,
} from "../../../../../lib/mailCounts";
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
import { plainSnippet } from "../../../../../lib/snippet";
import { useTrustedSenders } from "../../../../../lib/trustedSenders";
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
import type { InlineImages } from "../../../../../lib/emailBody";
import { messageDocument, hasBlockedRemoteContent } from "../../../../../lib/renderMessage";
import {
participantsLine,
useConversationView,
type ThreadParticipant,
type ThreadSummary,
} from "../../../../../lib/conversations";
import { Button } from "../../../../../components/ui/Button";
import {
clampListWidth,
@@ -40,6 +47,7 @@ import {
} from "../../../../../lib/paneWidth";
import { useAccount } from "../../../../../lib/useAccount";
import { MessageReader } from "../../../../../components/MessageReader";
import { ThreadReader } from "../../../../../components/ThreadReader";
import { Avatar } from "../../../../../components/Avatar";
interface MessageSummary {
@@ -55,6 +63,34 @@ interface MessageSummary {
flagged: boolean;
spam_verdict: string;
dmarc_result: string;
// Present when the list is showing conversations: the row is then a whole
// conversation, and uid is its newest message in this folder.
thread_id?: string;
message_count?: number;
answered?: boolean;
participants?: ThreadParticipant[];
}
/** A conversation row in the shape the list already knows how to draw. */
function rowFromThread(thread: ThreadSummary): MessageSummary {
return {
uid: thread.uid,
subject: thread.subject,
sender_address: thread.sender_address,
from_display_name: thread.from_display_name,
snippet: thread.snippet,
size_bytes: 0,
received_at: thread.received_at,
has_attachments: thread.has_attachments,
seen: thread.seen,
flagged: thread.flagged,
spam_verdict: "",
dmarc_result: "",
thread_id: thread.thread_id,
message_count: thread.message_count,
answered: thread.answered,
participants: thread.participants,
};
}
interface RenderedMessage {
@@ -121,6 +157,10 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
const [messages, setMessages] = useState<MessageSummary[]>([]);
const [selected, setSelected] = useState<RenderedMessage | null>(null);
// The open conversation, when the list is showing conversations.
const [selectedThread, setSelectedThread] = useState<MessageSummary | null>(null);
const conversations = useConversationView();
const conversationMode = conversations.enabled;
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
// The senders this account trusts, kept on the server so the decision
// follows the account to every browser and also reaches the spam filter.
@@ -163,25 +203,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
const actions = readerActions(folder);
const loadMessages = useCallback(
async (search: string) => {
setLoading(true);
async (search: string, { quiet = false }: { quiet?: boolean } = {}) => {
// A quiet reload refreshes what is shown without the spinner, for the
// changes the reader itself made.
if (!quiet) setLoading(true);
setError(null);
try {
const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) });
const res = await fetch(`/api/v1/mail/messages?${query}`, { cache: "no-store" });
const res = await fetch(`/api/v1/mail/${conversationMode ? "threads" : "messages"}?${query}`, {
cache: "no-store",
});
if (res.status === 401) {
window.location.href = "/user/login";
return;
}
// A server that has no conversations falls back to the plain list
// rather than showing an empty folder.
if (res.status === 501 && conversationMode) {
void conversations.setEnabled(false);
return;
}
if (!res.ok) throw new Error(t("errors.serverError"));
setMessages(await res.json());
const data = await res.json();
setMessages(conversationMode ? (data as ThreadSummary[]).map(rowFromThread) : data);
} catch (err) {
setError(err instanceof Error ? err.message : t("errors.serverError"));
} finally {
setLoading(false);
}
},
[folder, t]
// conversations.setEnabled is stable enough for this one call
// eslint-disable-next-line react-hooks/exhaustive-deps
[folder, t, conversationMode]
);
useMailEvents(
@@ -199,9 +252,18 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
// separate mount effect as well meant every folder change fired two requests
// and the later one could be the stale answer.
useEffect(() => {
// Waits for the account's answer on how to list, or the folder would load
// one way and immediately reload the other.
if (!conversations.ready) return;
const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0);
return () => clearTimeout(handle);
}, [searchQuery, loadMessages]);
}, [searchQuery, loadMessages, conversations.ready]);
// Changing between the two lists closes whatever was open in the other.
useEffect(() => {
setSelected(null);
setSelectedThread(null);
}, [conversationMode, folder]);
// The reader frame reports its own height; nothing outside a sandboxed frame
// can measure it.
@@ -302,6 +364,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
[folder, t, loadMessages, searchQuery]
);
/** Deletes every message a conversation row stands for in this folder. */
const deleteConversationRow = useCallback(
async (row: MessageSummary) => {
setMessages((current) => current.filter((m) => m.thread_id !== row.thread_id));
setSelectedThread((current) => (current?.thread_id === row.thread_id ? null : current));
try {
const res = await fetch(
`/api/v1/mail/thread/${encodeURIComponent(row.thread_id ?? "")}?folder=${encodeURIComponent(folder)}`,
{ cache: "no-store" },
);
if (!res.ok) throw new Error(t("errors.serverError"));
const infos: { uid: number; folder: string }[] = await res.json();
await Promise.all(
infos
.filter((m) => m.folder.toLowerCase() === folder.toLowerCase())
.map((m) =>
fetch("/api/v1/mail/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder, uid: m.uid }),
}),
),
);
} catch (err) {
setError(err instanceof Error ? err.message : t("errors.serverError"));
void loadMessages(searchQuery);
} finally {
notifyMailStateChanged();
}
},
[folder, t, loadMessages, searchQuery],
);
/** Files one message into another folder. */
const moveMessage = useCallback(
async (uid: number, target: string) => {
@@ -332,6 +427,14 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
router.push(`/user/compose?draft=${uid}`);
return;
}
if (conversationMode) {
const row = messages.find((m) => m.uid === uid);
if (row?.thread_id) {
setSelected(null);
setSelectedThread(row);
return;
}
}
setLoadRemoteImages(false);
setReaderHeight(420);
try {
@@ -357,6 +460,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
searching: searchQuery.trim().length > 0,
});
const selectedRow = messages.find((m) => m.uid === selected?.uid);
const reading = Boolean(selected || selectedThread);
const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false;
/**
@@ -368,27 +472,15 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
*/
const readerDocument = useMemo(() => {
if (!selected) return null;
if (!selected.html) return null;
const sanitized = sanitizeEmailHtml(selected.html);
if (!sanitized) return null;
const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {});
// A trusted sender's images load without asking; anyone else's wait for
// the reader's choice, one message at a time.
const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? "");
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
return buildReaderDocument(body);
return messageDocument(selected, showImages);
}, [selected, loadRemoteImages, trustedSenders]);
// Whether anything was actually held back, so the privacy banner is not shown
// over a message that has no remote content at all.
const hasBlockedContent = useMemo(() => {
if (!selected?.html) return false;
const sanitized = resolveInlineImages(
sanitizeEmailHtml(selected.html),
selected.inline_images ?? {}
);
return blockRemoteImages(sanitized) !== sanitized;
}, [selected]);
const hasBlockedContent = useMemo(() => (selected ? hasBlockedRemoteContent(selected) : false), [selected]);
const chips: { key: ListFilter; label: string; count: number }[] = [
{ key: null, label: t("mail.allMessages"), count: header.count },
@@ -416,7 +508,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{/* Messages List Column */}
<div
style={{ "--list-w": `${listWidth}px` } as React.CSSProperties}
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg md:w-[var(--list-w)] md:flex-none`}
className={`${reading ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg md:w-[var(--list-w)] md:flex-none`}
>
{/* Folder heading, with the counts that belong to the folder rather
than to the page of it that happens to be loaded. */}
@@ -494,7 +586,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</div>
) : (
filteredMessages.map((msg) => {
const isSelected = selected?.uid === msg.uid;
const isSelected = msg.thread_id ? selectedThread?.thread_id === msg.thread_id : selected?.uid === msg.uid;
return (
/* The row and its delete control are siblings inside a
positioned wrapper rather than nested: a button inside a
@@ -524,8 +616,24 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
msg.seen ? "text-slate-300" : "text-slate-100"
}`}
>
{msg.from_display_name || msg.sender_address}
{msg.participants && msg.participants.length > 0
? participantsLine(msg.participants, t("conversations.me"))
: msg.from_display_name || msg.sender_address}
</span>
{msg.message_count && msg.message_count > 1 ? (
<span
className="flex-none text-[11.5px] tabular-nums text-slate-500"
title={t("conversations.messagesInConversation", { count: msg.message_count })}
>
{msg.message_count}
</span>
) : null}
{msg.answered && (
<CornerUpLeft
className="h-3 w-3 flex-none text-emerald-300"
aria-label={t("conversations.youReplied")}
/>
)}
<span className="ml-auto flex-none text-[11px] tabular-nums text-slate-500">
{listTimestamp(msg.received_at, locale)}
</span>
@@ -578,7 +686,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
reading in it, and no other way to get rid of it. */}
<button
type="button"
onClick={() => void deleteMessage(msg.uid)}
onClick={() => void (msg.thread_id ? deleteConversationRow(msg) : deleteMessage(msg.uid))}
title={t("common.delete")}
aria-label={t("common.delete")}
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
@@ -633,9 +741,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</div>
{/* Message Reader Column */}
<div className={`${selected ? "flex" : "hidden md:flex"} min-w-0 flex-1 flex-col overflow-hidden bg-dark-bg`}>
<div className={`${reading ? "flex" : "hidden md:flex"} min-w-0 flex-1 flex-col overflow-hidden bg-dark-bg`}>
<AnimatePresence mode="wait">
{selected ? (
{selectedThread ? (
<ThreadReader
key={selectedThread.thread_id}
thread={{
thread_id: selectedThread.thread_id ?? "",
uid: selectedThread.uid,
subject: selectedThread.subject,
sender_address: selectedThread.sender_address,
from_display_name: selectedThread.from_display_name,
snippet: selectedThread.snippet,
received_at: selectedThread.received_at,
has_attachments: selectedThread.has_attachments,
message_count: selectedThread.message_count ?? 1,
unseen_count: selectedThread.seen ? 0 : 1,
seen: selectedThread.seen,
flagged: selectedThread.flagged,
answered: Boolean(selectedThread.answered),
participants: selectedThread.participants ?? [],
}}
folder={folder}
ownAddress={account?.email ?? ""}
trusted={trustedSenders}
moveTargets={moveTargets(folders, folder).map((target) => ({
name: target.name,
label: FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name,
}))}
deleteIsPermanent={actions.deleteIsPermanent}
onClose={() => setSelectedThread(null)}
onChanged={() => void loadMessages(searchQuery, { quiet: true })}
/>
) : selected ? (
<MessageReader
key={selected.uid}
message={selected}
@@ -28,6 +28,7 @@ import { useFolders } from "../../../../lib/useFolders";
import { AvatarEditor } from "../../../../components/AvatarEditor";
import { bumpAvatar } from "../../../../lib/avatarVersion";
import { useAccount } from "../../../../lib/useAccount";
import { useConversationView } from "../../../../lib/conversations";
import { moveTargets } from "../../../../lib/mailCounts";
interface SieveRule {
@@ -90,6 +91,7 @@ export default function UserSettingsPage() {
const [vacationSaved, setVacationSaved] = useState(false);
const [signature, setSignature] = useState("");
const conversationView = useConversationView();
const signatureRef = useRef<HTMLDivElement>(null);
// execCommand edits the DOM directly, so the mirrored value is re-read here
@@ -989,6 +991,24 @@ export default function UserSettingsPage() {
<div className="max-w-[280px]">
<LanguageSwitcher />
</div>
{/* How the list is read, which belongs with how it is shown. */}
<label className="flex max-w-[560px] cursor-pointer items-start gap-3 rounded-xl bg-dark-card p-3.5 shadow-edge">
<input
type="checkbox"
role="switch"
checked={conversationView.enabled}
disabled={!conversationView.ready}
onChange={(e) => void conversationView.setEnabled(e.target.checked)}
className="mt-0.5 h-4 w-4 flex-none accent-indigo-500"
/>
<span className="min-w-0">
<span className="block text-[13.5px] text-slate-100">{t("conversations.conversationView")}</span>
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-slate-400">
{t("conversations.conversationViewHint")}
</span>
</span>
</label>
</section>
)}
</div>