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:
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>
|
||||
|
||||
Reference in new issue
Block a user