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
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"meta": {
|
||||
"lastScanned": "2026-10-22T17:41:17.634Z",
|
||||
"fileCount": 439,
|
||||
"lastScanned": "2026-10-22T19:44:13.602Z",
|
||||
"fileCount": 440,
|
||||
"hits": 0,
|
||||
"misses": 0,
|
||||
"renderedHash": "6ed8dc6b806503f41452e51121e5f78379f1c88655d4934d082d2aa065428b8c",
|
||||
"storeUpdatedAt": "2026-10-22T17:41:17.650Z",
|
||||
"rootHash": "098b934a6205dce4"
|
||||
"renderedHash": "4c74546f856ca02c0dd5eeb8a90c610c161275126dc91d9fc7f169a6424d61d6",
|
||||
"storeUpdatedAt": "2026-10-22T19:44:13.627Z",
|
||||
"rootHash": "d8862ac30fea5b61"
|
||||
},
|
||||
"files": {
|
||||
".github/workflows/ci.yml": {
|
||||
@@ -19624,6 +19624,66 @@
|
||||
"tokens": 36
|
||||
}
|
||||
]
|
||||
},
|
||||
"services/webmail/src/components/ThreadReader.tsx": {
|
||||
"description": "One message as the mail API returns it with its body.",
|
||||
"tokens": 7350,
|
||||
"hash": "54dad9430ffc2edb",
|
||||
"size": 25722,
|
||||
"mtimeMs": 1792698253418.4153,
|
||||
"updatedAt": "2026-10-22T19:44:13.601Z",
|
||||
"source": "hook",
|
||||
"symbols": [
|
||||
{
|
||||
"name": "FullMessage",
|
||||
"kind": "section",
|
||||
"startLine": 39,
|
||||
"endLine": 53,
|
||||
"tokens": 74
|
||||
},
|
||||
{
|
||||
"name": "MoveTarget",
|
||||
"kind": "section",
|
||||
"startLine": 54,
|
||||
"endLine": 58,
|
||||
"tokens": 17
|
||||
},
|
||||
{
|
||||
"name": "Trusted",
|
||||
"kind": "section",
|
||||
"startLine": 59,
|
||||
"endLine": 64,
|
||||
"tokens": 44
|
||||
},
|
||||
{
|
||||
"name": "Props",
|
||||
"kind": "section",
|
||||
"startLine": 65,
|
||||
"endLine": 84,
|
||||
"tokens": 314
|
||||
},
|
||||
{
|
||||
"name": "splitSender",
|
||||
"kind": "fn",
|
||||
"startLine": 85,
|
||||
"endLine": 90,
|
||||
"tokens": 74
|
||||
},
|
||||
{
|
||||
"name": "initialsOf",
|
||||
"kind": "fn",
|
||||
"startLine": 91,
|
||||
"endLine": 96,
|
||||
"tokens": 78
|
||||
},
|
||||
{
|
||||
"name": "ThreadReader",
|
||||
"kind": "fn",
|
||||
"startLine": 97,
|
||||
"endLine": 591,
|
||||
"tokens": 6441
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"preamble": [
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
# anatomy.md
|
||||
|
||||
> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T17:41:17.634Z
|
||||
> Files: 439 tracked | Anatomy hits: 0 | Misses: 0
|
||||
> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T19:44:13.602Z
|
||||
> Files: 440 tracked | Anatomy hits: 0 | Misses: 0
|
||||
|
||||
> Project structure index. Auto-maintained by OpenWolf hooks and daemon.
|
||||
> Run `openwolf scan` to generate, or wait for the first Claude Code session.
|
||||
@@ -610,6 +610,7 @@
|
||||
- `AvatarEditor.tsx` — The frame is square; the mask over it is what makes the photo a circle. (~2510 tok)
|
||||
- `MessageReader.tsx` — The document the body frame renders, or null for a text-only message. (~4736 tok)
|
||||
- `RecipientInput.tsx` — Entries as they will be sent: `Name <addr>` or a bare address. (~1486 tok)
|
||||
- `ThreadReader.tsx` — One message as the mail API returns it with its body. (~7350 tok)
|
||||
- `TotpEnrolment.tsx` — The scannable half of TOTP enrolment, shared by the webmail settings page and (~1644 tok)
|
||||
|
||||
## services/webmail/src/components/ui/
|
||||
|
||||
@@ -31,3 +31,4 @@ description: chronological action log per session, consolidated weekly
|
||||
| Time | Action | File(s) | Outcome | ~Tokens |
|
||||
|------|--------|---------|---------|--------|
|
||||
| 14:41 | Created services/auth/src/accounts.ts | — | ~715 |
|
||||
| 16:44 | Created services/webmail/src/components/ThreadReader.tsx | — | ~7350 |
|
||||
@@ -548,5 +548,21 @@
|
||||
"signOutAll": "Sign out of all accounts",
|
||||
"active": "Active",
|
||||
"settings": "Account settings"
|
||||
},
|
||||
"conversations": {
|
||||
"conversationView": "Group mail into conversations",
|
||||
"conversationViewHint": "Show a message and its replies together, and whether you have answered.",
|
||||
"messagesInConversation": "{count} messages",
|
||||
"youReplied": "You replied",
|
||||
"notReplied": "Not answered yet",
|
||||
"expandAll": "Expand all",
|
||||
"collapseOlder": "Collapse older",
|
||||
"loadingMessage": "Loading message…",
|
||||
"replyBelow": "Reply",
|
||||
"conversationEmpty": "This conversation is empty.",
|
||||
"me": "me",
|
||||
"draftDiscarded": "Reply discarded",
|
||||
"sentReply": "Reply sent",
|
||||
"moreMessages": "Show {count} earlier messages"
|
||||
}
|
||||
}
|
||||
@@ -548,5 +548,21 @@
|
||||
"signOutAll": "Cerrar sesión en todas las cuentas",
|
||||
"active": "Activa",
|
||||
"settings": "Ajustes de la cuenta"
|
||||
},
|
||||
"conversations": {
|
||||
"conversationView": "Agrupar el correo en conversaciones",
|
||||
"conversationViewHint": "Muestra un mensaje y sus respuestas juntos, y si ya respondiste.",
|
||||
"messagesInConversation": "{count} mensajes",
|
||||
"youReplied": "Respondiste",
|
||||
"notReplied": "Aún sin responder",
|
||||
"expandAll": "Expandir todo",
|
||||
"collapseOlder": "Contraer anteriores",
|
||||
"loadingMessage": "Cargando mensaje…",
|
||||
"replyBelow": "Responder",
|
||||
"conversationEmpty": "Esta conversación está vacía.",
|
||||
"me": "yo",
|
||||
"draftDiscarded": "Respuesta descartada",
|
||||
"sentReply": "Respuesta enviada",
|
||||
"moreMessages": "Mostrar {count} mensajes anteriores"
|
||||
}
|
||||
}
|
||||
@@ -548,5 +548,21 @@
|
||||
"signOutAll": "Sair de todas as contas",
|
||||
"active": "Ativa",
|
||||
"settings": "Configurações da conta"
|
||||
},
|
||||
"conversations": {
|
||||
"conversationView": "Agrupar e-mails em conversas",
|
||||
"conversationViewHint": "Mostra uma mensagem e as respostas juntas, e se você já respondeu.",
|
||||
"messagesInConversation": "{count} mensagens",
|
||||
"youReplied": "Você respondeu",
|
||||
"notReplied": "Ainda não respondida",
|
||||
"expandAll": "Expandir tudo",
|
||||
"collapseOlder": "Recolher anteriores",
|
||||
"loadingMessage": "Carregando mensagem…",
|
||||
"replyBelow": "Responder",
|
||||
"conversationEmpty": "Esta conversa está vazia.",
|
||||
"me": "eu",
|
||||
"draftDiscarded": "Resposta descartada",
|
||||
"sentReply": "Resposta enviada",
|
||||
"moreMessages": "Mostrar {count} mensagens anteriores"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "../i18n/provider";
|
||||
|
||||
/**
|
||||
* One message body in its sandboxed frame, sized to its own content.
|
||||
*
|
||||
* Several of these sit on one page when a conversation is open, and every frame
|
||||
* reports its height the same way, so a report is only taken from the frame
|
||||
* that sent it. The sandbox has no allow-same-origin: the frame stays on an
|
||||
* opaque origin and can read nothing of this page.
|
||||
*/
|
||||
export function MessageFrame({ document: html }: { document: string }) {
|
||||
const t = useTranslations();
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(160);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== frame.current?.contentWindow) return;
|
||||
const data = event.data as { type?: string; height?: number } | null;
|
||||
if (data?.type !== "lm:reader-height" || typeof data.height !== "number") return;
|
||||
// Clamped so a message that reports something absurd cannot produce a
|
||||
// frame kilometres tall or one collapsed to nothing.
|
||||
setHeight(Math.min(20000, Math.max(80, Math.ceil(data.height))));
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
||||
<iframe
|
||||
ref={frame}
|
||||
title={t("ui.messageBody")}
|
||||
srcDoc={html}
|
||||
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: `${height}px` }}
|
||||
className="block w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
CornerUpLeft,
|
||||
Download,
|
||||
FolderInput,
|
||||
Forward,
|
||||
Image as ImageIcon,
|
||||
MailOpen,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "../i18n/provider";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { Composer, type ComposerInitial } from "./Composer";
|
||||
import { MessageFrame } from "./MessageFrame";
|
||||
import {
|
||||
initiallyOpen,
|
||||
messageKey,
|
||||
replyTarget,
|
||||
type ThreadMessageInfo,
|
||||
type ThreadSummary,
|
||||
} from "../lib/conversations";
|
||||
import { hasBlockedRemoteContent, messageDocument } from "../lib/renderMessage";
|
||||
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
|
||||
import { plainSnippet } from "../lib/snippet";
|
||||
import { notifyMailStateChanged } from "../lib/useFolders";
|
||||
import type { InlineImages } from "../lib/emailBody";
|
||||
|
||||
/** One message as the mail API returns it with its body. */
|
||||
interface FullMessage {
|
||||
uid: number;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
date: string;
|
||||
text: string;
|
||||
html: string;
|
||||
attachments: string[];
|
||||
message_id?: string;
|
||||
important?: boolean;
|
||||
inline_images: InlineImages;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Trusted {
|
||||
isTrusted: (from: string) => boolean;
|
||||
trust: (from: string) => Promise<boolean>;
|
||||
revoke: (from: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
thread: ThreadSummary;
|
||||
folder: string;
|
||||
ownAddress: string;
|
||||
trusted: Trusted;
|
||||
moveTargets: MoveTarget[];
|
||||
deleteIsPermanent: boolean;
|
||||
onClose: () => void;
|
||||
/** Something changed that the list and the badges should show. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
const commandClass =
|
||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||
const infoLinkClass =
|
||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
const replyButtonClass =
|
||||
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
|
||||
/** The name and address in a header like `Jenny <jenny@x.test>`. */
|
||||
function splitSender(from: string): { name: string; address: string } {
|
||||
const match = from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/);
|
||||
if (match) return { name: match[1].trim(), address: match[2].trim() };
|
||||
return { name: "", address: from.trim() };
|
||||
}
|
||||
|
||||
function initialsOf(name: string, address: string): string {
|
||||
const source = (name || address || "?").trim();
|
||||
const parts = source.replace(/[<>"]/g, "").split(/[\s.@_-]+/).filter(Boolean);
|
||||
return (parts.slice(0, 2).map((p) => p[0]).join("") || "?").toUpperCase();
|
||||
}
|
||||
|
||||
export function ThreadReader(props: Props) {
|
||||
const { thread, folder, ownAddress, trusted } = props;
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const [infos, setInfos] = useState<ThreadMessageInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
const [bodies, setBodies] = useState<Record<string, FullMessage | "error">>({});
|
||||
// Images shown for this message only, by the reader's choice; a trusted
|
||||
// sender's are shown without one.
|
||||
const [imagesOnce, setImagesOnce] = useState<Set<string>>(new Set());
|
||||
const [reply, setReply] = useState<{ nonce: number; initial: ComposerInitial } | null>(null);
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const fetching = useRef<Set<string>>(new Set());
|
||||
const replyAnchor = useRef<HTMLDivElement>(null);
|
||||
const loadedFor = useRef<string>("");
|
||||
|
||||
const load = useCallback(
|
||||
async (initial: boolean) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/mail/thread/${encodeURIComponent(thread.thread_id)}?folder=${encodeURIComponent(folder)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/user/login";
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||
const data: ThreadMessageInfo[] = await res.json();
|
||||
setInfos(data);
|
||||
setError(null);
|
||||
setOpen((current) => {
|
||||
if (initial) return initiallyOpen(data);
|
||||
// After sending, the new message is the one to see.
|
||||
const next = new Set(current);
|
||||
if (data.length > 0) next.add(messageKey(data[data.length - 1]));
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||
}
|
||||
},
|
||||
[thread.thread_id, folder, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedFor.current === thread.thread_id) return;
|
||||
loadedFor.current = thread.thread_id;
|
||||
setInfos(null);
|
||||
setBodies({});
|
||||
setImagesOnce(new Set());
|
||||
setReply(null);
|
||||
fetching.current = new Set();
|
||||
void load(true);
|
||||
}, [thread.thread_id, load]);
|
||||
|
||||
/** Fetches one message's body. Opening it is also what marks it read. */
|
||||
const fetchBody = useCallback(
|
||||
async (info: ThreadMessageInfo): Promise<FullMessage | null> => {
|
||||
const key = messageKey(info);
|
||||
if (fetching.current.has(key)) return null;
|
||||
fetching.current.add(key);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/mail/message/${info.uid}?folder=${encodeURIComponent(info.folder)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) throw new Error("fetch");
|
||||
const full: FullMessage = await res.json();
|
||||
setBodies((current) => ({ ...current, [key]: full }));
|
||||
if (!info.seen) {
|
||||
setInfos((current) =>
|
||||
current ? current.map((m) => (messageKey(m) === key ? { ...m, seen: true } : m)) : current,
|
||||
);
|
||||
notifyMailStateChanged();
|
||||
props.onChanged();
|
||||
}
|
||||
return full;
|
||||
} catch {
|
||||
setBodies((current) => ({ ...current, [key]: "error" }));
|
||||
return null;
|
||||
} finally {
|
||||
fetching.current.delete(key);
|
||||
}
|
||||
},
|
||||
// onChanged is a fresh closure each render; the fetch must not depend on it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!infos) return;
|
||||
for (const info of infos) {
|
||||
const key = messageKey(info);
|
||||
if (open.has(key) && !bodies[key]) void fetchBody(info);
|
||||
}
|
||||
}, [infos, open, bodies, fetchBody]);
|
||||
|
||||
const toggle = (info: ThreadMessageInfo) => {
|
||||
const key = messageKey(info);
|
||||
setOpen((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const inThisFolder = useMemo(
|
||||
() => (infos ?? []).filter((m) => m.folder.toLowerCase() === folder.toLowerCase()),
|
||||
[infos, folder],
|
||||
);
|
||||
|
||||
/** Runs one request per message of the conversation that sits in this folder. */
|
||||
const forEachHere = async (path: string, extra: (m: ThreadMessageInfo) => Record<string, unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
inThisFolder.map((m) =>
|
||||
fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder, uid: m.uid, ...extra(m) }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
notifyMailStateChanged();
|
||||
props.onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteThread = async () => {
|
||||
await forEachHere("/api/v1/mail/delete", () => ({}));
|
||||
props.onClose();
|
||||
};
|
||||
const moveThread = async (target: string) => {
|
||||
setMoveOpen(false);
|
||||
await forEachHere("/api/v1/mail/move", () => ({ target }));
|
||||
props.onClose();
|
||||
};
|
||||
const markUnread = async () => {
|
||||
const newest = inThisFolder[inThisFolder.length - 1];
|
||||
if (!newest) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await fetch("/api/v1/mail/seen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder, uid: newest.uid, seen: false }),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
notifyMailStateChanged();
|
||||
props.onChanged();
|
||||
}
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
/** Opens the reply under the conversation, addressed from the message it answers. */
|
||||
const startReply = async (mode: "reply" | "replyAll") => {
|
||||
if (!infos) return;
|
||||
const target = replyTarget(infos);
|
||||
if (!target) return;
|
||||
const key = messageKey(target);
|
||||
const full = (bodies[key] && bodies[key] !== "error" ? (bodies[key] as FullMessage) : null) ?? (await fetchBody(target));
|
||||
if (!full) return;
|
||||
const recipients =
|
||||
mode === "replyAll" ? replyAllRecipients(full, ownAddress) : replyRecipients(full, ownAddress);
|
||||
setReply({
|
||||
nonce: Date.now(),
|
||||
initial: {
|
||||
mode,
|
||||
subject: full.subject,
|
||||
replyTo: recipients.to.join(", "),
|
||||
cc: recipients.cc.join(", "),
|
||||
inReplyTo: full.message_id ?? "",
|
||||
},
|
||||
});
|
||||
setTimeout(() => replyAnchor.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 50);
|
||||
};
|
||||
|
||||
const lastIncoming = infos ? replyTarget(infos) : null;
|
||||
const canReplyAll = (() => {
|
||||
if (!lastIncoming) return false;
|
||||
const full = bodies[messageKey(lastIncoming)];
|
||||
return full && full !== "error" ? offersReplyAll(full, ownAddress) : false;
|
||||
})();
|
||||
const waitingOnMe = infos && infos.length > 0 && !infos[infos.length - 1].own;
|
||||
const iReplied = lastIncoming ? lastIncoming.answered : false;
|
||||
|
||||
const dateText = (iso: string, long: boolean) => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return long
|
||||
? d.toLocaleString(locale, { weekday: "short", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" })
|
||||
: d.toLocaleDateString(locale, { day: "numeric", month: "short" });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
aria-label={thread.subject}
|
||||
>
|
||||
{/* The commands that apply to the whole conversation. */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] sm:px-4">
|
||||
<button type="button" onClick={props.onClose} className={`${commandClass} md:hidden`} aria-label={t("mail.backToList")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => void startReply("reply")} disabled={!infos} className={commandClass}>
|
||||
<Reply className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("mail.reply")}</span>
|
||||
</button>
|
||||
{canReplyAll && (
|
||||
<button type="button" onClick={() => void startReply("replyAll")} className={commandClass}>
|
||||
<ReplyAll className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("mail.replyAll")}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="mx-1 hidden h-5 w-px bg-white/10 sm:block" />
|
||||
<button type="button" onClick={() => void deleteThread()} disabled={busy || inThisFolder.length === 0} className={commandClass}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">{props.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
|
||||
</button>
|
||||
{props.moveTargets.length > 0 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoveOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moveOpen}
|
||||
className={commandClass}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">{t("mail.moveTo")}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{moveOpen && (
|
||||
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10">
|
||||
{props.moveTargets.map((target) => (
|
||||
<button
|
||||
key={target.name}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
onClick={() => void moveThread(target.name)}
|
||||
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-white/[0.07]"
|
||||
>
|
||||
{target.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" onClick={() => void markUnread()} disabled={busy} className={commandClass}>
|
||||
<MailOpen className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">{t("mail.markUnread")}</span>
|
||||
</button>
|
||||
<button type="button" onClick={props.onClose} className={`${commandClass} ml-auto hidden md:inline-flex`} aria-label={t("mail.backToList")}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
||||
{thread.subject || t("mail.noSubject")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[12.5px] text-slate-400">
|
||||
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
|
||||
{infos && waitingOnMe && lastIncoming && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${
|
||||
iReplied ? "bg-emerald-500/15 text-emerald-200" : "bg-amber-500/15 text-amber-200"
|
||||
}`}
|
||||
>
|
||||
<CornerUpLeft className="h-3 w-3" aria-hidden />
|
||||
{iReplied ? t("conversations.youReplied") : t("conversations.notReplied")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="rounded-lg bg-rose-500/10 px-3 py-2 text-[13px] text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!infos && !error && <div className="py-10 text-center text-sm text-slate-400">{t("common.loading")}</div>}
|
||||
{infos && infos.length === 0 && (
|
||||
<div className="py-10 text-center text-sm text-slate-400">{t("conversations.conversationEmpty")}</div>
|
||||
)}
|
||||
|
||||
{infos?.map((info) => {
|
||||
const key = messageKey(info);
|
||||
const isOpen = open.has(key);
|
||||
const full = bodies[key];
|
||||
const loaded = full && full !== "error" ? full : null;
|
||||
const sender = loaded ? splitSender(loaded.from) : { name: info.from_display_name, address: info.sender_address };
|
||||
const displayName = info.own ? t("conversations.me") : sender.name || info.from_display_name || sender.address;
|
||||
const trustedHere = loaded ? trusted.isTrusted(loaded.from) : false;
|
||||
const showImages = trustedHere || imagesOnce.has(key);
|
||||
const doc = loaded ? messageDocument(loaded, showImages) : null;
|
||||
const blocked = loaded ? hasBlockedRemoteContent(loaded) : false;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
className={`rounded-xl shadow-edge ${isOpen ? "bg-dark-panel" : "bg-dark-card/60 hover:bg-dark-card"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(info)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3.5 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||
>
|
||||
<Avatar
|
||||
address={info.sender_address}
|
||||
initials={initialsOf(displayName, info.sender_address)}
|
||||
size={isOpen ? 40 : 30}
|
||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className={`min-w-0 truncate text-[14px] ${info.seen ? "text-slate-200" : "font-semibold text-slate-100"}`}>
|
||||
{displayName}
|
||||
</span>
|
||||
{isOpen && !info.own && sender.address && (
|
||||
<span className="min-w-0 truncate text-[12.5px] text-slate-400"><{sender.address}></span>
|
||||
)}
|
||||
</span>
|
||||
{!isOpen && (
|
||||
<span className="truncate text-[12.5px] text-slate-400">{plainSnippet(info.snippet)}</span>
|
||||
)}
|
||||
</span>
|
||||
{info.answered && (
|
||||
<CornerUpLeft
|
||||
className="h-3.5 w-3.5 flex-none text-emerald-300"
|
||||
aria-label={t("conversations.youReplied")}
|
||||
/>
|
||||
)}
|
||||
{!info.seen && !info.own && <span className="h-[7px] w-[7px] flex-none rounded-full bg-indigo-500" />}
|
||||
<span className="flex-none text-[12px] tabular-nums text-slate-400">{dateText(info.received_at, isOpen)}</span>
|
||||
<ChevronDown className={`h-4 w-4 flex-none text-slate-500 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="flex flex-col gap-3 px-3.5 pb-4">
|
||||
{full === "error" && (
|
||||
<div role="alert" className="rounded-lg bg-rose-500/10 px-3 py-2 text-[13px] text-rose-200">
|
||||
{t("errors.serverError")}
|
||||
</div>
|
||||
)}
|
||||
{!loaded && full !== "error" && (
|
||||
<div className="py-6 text-center text-[13px] text-slate-400">{t("conversations.loadingMessage")}</div>
|
||||
)}
|
||||
{loaded && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-slate-400 [overflow-wrap:anywhere]">
|
||||
{loaded.to.length > 0 && (
|
||||
<span>
|
||||
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
|
||||
{loaded.to.slice(0, 3).join(", ")}
|
||||
{loaded.to.length > 3 && ` +${loaded.to.length - 3}`}
|
||||
</span>
|
||||
)}
|
||||
{!info.own && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void (trustedHere ? trusted.revoke(loaded.from) : trusted.trust(loaded.from))}
|
||||
aria-pressed={trustedHere}
|
||||
title={trustedHere ? t("mail.trustedSenderHint") : t("mail.trustSenderHint")}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||
trustedHere
|
||||
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||
{trustedHere ? t("mail.trustedSender") : t("mail.trustSender")}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/user/compose?mode=forward&subject=${encodeURIComponent(loaded.subject)}`}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-white/[0.07]"
|
||||
>
|
||||
<Forward className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("mail.forward")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{blocked && !trustedHere && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg bg-dark-card px-3 py-2 shadow-edge">
|
||||
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" aria-hidden />
|
||||
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||
{imagesOnce.has(key) ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setImagesOnce((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className={infoLinkClass}
|
||||
>
|
||||
{imagesOnce.has(key) ? t("mail.hideImages") : t("mail.loadImages")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loaded.attachments.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{loaded.attachments.map((filename, idx) => (
|
||||
<a
|
||||
key={`${filename}-${idx}`}
|
||||
href={`/api/v1/mail/message/${loaded.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(info.folder)}`}
|
||||
download={filename}
|
||||
className="group flex h-10 max-w-[260px] flex-none items-center gap-2 rounded-lg bg-dark-card px-2.5 text-slate-200 shadow-edge hover:shadow-edge-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||
>
|
||||
<span className="min-w-0 truncate text-[12.5px]">{filename}</span>
|
||||
<Download className="h-3.5 w-3.5 flex-none text-slate-500 group-hover:text-indigo-300" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc ? (
|
||||
<MessageFrame document={doc} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words rounded-xl bg-dark-bg px-4 py-4 font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||
{loaded.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The reply is written here, under the messages it answers, so they
|
||||
stay in view while it is typed. */}
|
||||
<div ref={replyAnchor} className="flex flex-col gap-3">
|
||||
{reply ? (
|
||||
<div className="rounded-xl bg-dark-panel p-3 shadow-edge">
|
||||
<Composer
|
||||
key={reply.nonce}
|
||||
variant="inline"
|
||||
initial={reply.initial}
|
||||
onSent={() => {
|
||||
setReply(null);
|
||||
void load(false);
|
||||
notifyMailStateChanged();
|
||||
props.onChanged();
|
||||
}}
|
||||
onDiscarded={() => setReply(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
infos &&
|
||||
infos.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => void startReply("reply")} className={replyButtonClass}>
|
||||
<Reply className="h-4 w-4" />
|
||||
{t("mail.reply")}
|
||||
</button>
|
||||
{canReplyAll && (
|
||||
<button type="button" onClick={() => void startReply("replyAll")} className={replyButtonClass}>
|
||||
<ReplyAll className="h-4 w-4" />
|
||||
{t("mail.replyAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
initiallyOpen,
|
||||
messageKey,
|
||||
participantsLine,
|
||||
replyTarget,
|
||||
type ThreadMessageInfo,
|
||||
type ThreadParticipant,
|
||||
} from "./lib/conversations";
|
||||
|
||||
const message = (uid: number, over: Partial<ThreadMessageInfo> = {}): ThreadMessageInfo => ({
|
||||
uid,
|
||||
folder: "INBOX",
|
||||
subject: "s",
|
||||
sender_address: "a@x.test",
|
||||
from_display_name: "",
|
||||
snippet: "",
|
||||
received_at: "2026-10-22T10:00:00Z",
|
||||
has_attachments: false,
|
||||
seen: true,
|
||||
flagged: false,
|
||||
answered: false,
|
||||
own: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("which messages of a conversation start open", () => {
|
||||
it("opens the newest one and leaves the older read ones as a line", () => {
|
||||
const open = initiallyOpen([message(1), message(2), message(3)]);
|
||||
expect([...open]).toEqual(["inbox:3"]);
|
||||
});
|
||||
|
||||
it("also opens what somebody wrote and has not been read, wherever it is", () => {
|
||||
const open = initiallyOpen([message(1, { seen: false }), message(2), message(3)]);
|
||||
expect(open.has("inbox:1")).toBe(true);
|
||||
expect(open.has("inbox:3")).toBe(true);
|
||||
expect(open.has("inbox:2")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not open my own message just because it carries no read flag", () => {
|
||||
const open = initiallyOpen([message(1, { seen: false, own: true }), message(2)]);
|
||||
expect(open.has("inbox:1")).toBe(false);
|
||||
});
|
||||
|
||||
it("is empty for nothing", () => {
|
||||
expect(initiallyOpen([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keys", () => {
|
||||
// The same UID in two folders is two messages.
|
||||
it("tells the same uid apart across folders", () => {
|
||||
expect(messageKey({ folder: "INBOX", uid: 4 })).not.toBe(messageKey({ folder: "Sent", uid: 4 }));
|
||||
});
|
||||
it("ignores the case of the folder name", () => {
|
||||
expect(messageKey({ folder: "Sent", uid: 4 })).toBe(messageKey({ folder: "sent", uid: 4 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("who a reply answers", () => {
|
||||
it("is the newest message somebody else wrote", () => {
|
||||
const target = replyTarget([message(1), message(2, { own: true }), message(3, { own: true })]);
|
||||
expect(target?.uid).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the last message when everything is mine", () => {
|
||||
expect(replyTarget([message(1, { own: true }), message(2, { own: true })])?.uid).toBe(2);
|
||||
});
|
||||
|
||||
it("is nothing for an empty conversation", () => {
|
||||
expect(replyTarget([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the participants line", () => {
|
||||
const p = (name: string, address: string, me = false): ThreadParticipant => ({ name, address, me });
|
||||
|
||||
it("names the owner as me", () => {
|
||||
expect(participantsLine([p("Ana Souza", "ana@x.test"), p("Lucas", "me@x.test", true)], "me")).toBe("Ana, me");
|
||||
});
|
||||
|
||||
it("keeps the whole name when there is only one writer", () => {
|
||||
expect(participantsLine([p("Ana Souza", "ana@x.test")], "me")).toBe("Ana Souza");
|
||||
});
|
||||
|
||||
it("uses the address when there is no name, and does not cut it", () => {
|
||||
expect(participantsLine([p("", "ana@x.test"), p("Bob Lee", "bob@x.test")], "me")).toBe("ana@x.test, Bob");
|
||||
});
|
||||
|
||||
it("is empty for no one", () => {
|
||||
expect(participantsLine([], "me")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Mail read as conversations: one row per conversation in the list, and the
|
||||
* messages of one stacked in the reader.
|
||||
*/
|
||||
|
||||
export interface ThreadParticipant {
|
||||
name: string;
|
||||
address: string;
|
||||
me: boolean;
|
||||
}
|
||||
|
||||
/** One row of the conversation list. `uid` is the newest message in the folder. */
|
||||
export interface ThreadSummary {
|
||||
thread_id: string;
|
||||
uid: number;
|
||||
subject: string;
|
||||
sender_address: string;
|
||||
from_display_name: string;
|
||||
snippet: string;
|
||||
received_at: string;
|
||||
has_attachments: boolean;
|
||||
message_count: number;
|
||||
unseen_count: number;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
answered: boolean;
|
||||
participants: ThreadParticipant[];
|
||||
}
|
||||
|
||||
export interface ThreadMessageInfo {
|
||||
uid: number;
|
||||
folder: string;
|
||||
subject: string;
|
||||
sender_address: string;
|
||||
from_display_name: string;
|
||||
snippet: string;
|
||||
received_at: string;
|
||||
has_attachments: boolean;
|
||||
seen: boolean;
|
||||
flagged: boolean;
|
||||
answered: boolean;
|
||||
own: boolean;
|
||||
}
|
||||
|
||||
/** A UID is only unique inside its folder, and a conversation spans folders. */
|
||||
export function messageKey(message: { folder: string; uid: number }): string {
|
||||
return `${message.folder.toLowerCase()}:${message.uid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who wrote, for the list: names in the order they first wrote, the owner as
|
||||
* "me", and a first name where there are several so the line fits.
|
||||
*/
|
||||
export function participantsLine(participants: ThreadParticipant[], meLabel: string): string {
|
||||
if (participants.length === 0) return "";
|
||||
const several = participants.length > 1;
|
||||
return participants
|
||||
.map((p) => {
|
||||
if (p.me) return meLabel;
|
||||
const name = (p.name || p.address).trim();
|
||||
// "Ana Souza" becomes "Ana" beside other names, but an address stays whole.
|
||||
return several && !name.includes("@") ? name.split(/\s+/)[0] : name;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Which messages start open: the newest, and anything not yet read that
|
||||
* someone else wrote. The rest are one line each, so a long conversation does
|
||||
* not push the message that matters off the screen.
|
||||
*/
|
||||
export function initiallyOpen(messages: ThreadMessageInfo[]): Set<string> {
|
||||
const open = new Set<string>();
|
||||
if (messages.length === 0) return open;
|
||||
open.add(messageKey(messages[messages.length - 1]));
|
||||
for (const m of messages) {
|
||||
if (!m.seen && !m.own) open.add(messageKey(m));
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
/** The message a reply answers: the newest one somebody else wrote. */
|
||||
export function replyTarget(messages: ThreadMessageInfo[]): ThreadMessageInfo | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (!messages[i].own) return messages[i];
|
||||
}
|
||||
return messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
}
|
||||
|
||||
interface Preferences {
|
||||
signature: string;
|
||||
auto_save_drafts: boolean;
|
||||
conversation_view: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the list shows conversations. Kept on the account, so the choice
|
||||
* follows it to every browser. On until the account says otherwise.
|
||||
*
|
||||
* `ready` is false until the account has answered: the list waits for it, or
|
||||
* it would load one way and then reload the other.
|
||||
*/
|
||||
export function useConversationView() {
|
||||
const [prefs, setPrefs] = useState<Preferences | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/v1/user/preferences", { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data) setPrefs(data);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const enabled = prefs?.conversation_view ?? true;
|
||||
|
||||
const setEnabled = useCallback(
|
||||
async (next: boolean) => {
|
||||
if (!prefs) return false;
|
||||
setPrefs({ ...prefs, conversation_view: next });
|
||||
try {
|
||||
// The whole record goes back: the endpoint replaces the signature with
|
||||
// whatever it is given, so saving only the switch would blank it.
|
||||
const res = await fetch("/api/v1/user/preferences", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...prefs, conversation_view: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error("rejected");
|
||||
return true;
|
||||
} catch {
|
||||
setPrefs(prefs);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[prefs],
|
||||
);
|
||||
|
||||
return { enabled, ready, setEnabled };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "./sanitizer";
|
||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "./emailBody";
|
||||
|
||||
/** What it takes to draw a message's body. */
|
||||
export interface RenderableBody {
|
||||
html: string;
|
||||
inline_images?: InlineImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* The document the reader frame renders, or null for a message with no HTML.
|
||||
*
|
||||
* Order matters: sanitise first so nothing downstream handles untrusted
|
||||
* markup, then resolve the message's own inline parts, then block or restore
|
||||
* remote sources - which is the only part the reader chooses.
|
||||
*/
|
||||
export function messageDocument(message: RenderableBody, showImages: boolean): string | null {
|
||||
if (!message.html) return null;
|
||||
const sanitized = sanitizeEmailHtml(message.html);
|
||||
if (!sanitized) return null;
|
||||
const withInline = resolveInlineImages(sanitized, message.inline_images ?? {});
|
||||
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
||||
return buildReaderDocument(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anything was actually held back, so the privacy bar is not shown over
|
||||
* a message that has no remote content at all.
|
||||
*/
|
||||
export function hasBlockedRemoteContent(message: RenderableBody): boolean {
|
||||
if (!message.html) return false;
|
||||
const sanitized = resolveInlineImages(sanitizeEmailHtml(message.html), message.inline_images ?? {});
|
||||
return blockRemoteImages(sanitized) !== sanitized;
|
||||
}
|
||||
@@ -106,13 +106,17 @@ describe("read and unread state", () => {
|
||||
|
||||
describe("the reader pane", () => {
|
||||
it("resolves the message's own inline images", () => {
|
||||
const page = code(MAIL_PAGE);
|
||||
expect(page).toContain("resolveInlineImages");
|
||||
expect(page).toContain("inline_images");
|
||||
// The pipeline moved to one place both readers - a single message and a
|
||||
// conversation - draw through, so it is checked there.
|
||||
const render = code("src/lib/renderMessage.ts");
|
||||
expect(render).toContain("resolveInlineImages");
|
||||
expect(render).toContain("inline_images");
|
||||
expect(code(MAIL_PAGE)).toContain("messageDocument");
|
||||
expect(code("src/components/ThreadReader.tsx")).toContain("messageDocument");
|
||||
});
|
||||
|
||||
it("renders the body inside the tested document rather than a bare fragment", () => {
|
||||
expect(code(MAIL_PAGE)).toContain("buildReaderDocument");
|
||||
expect(code("src/lib/renderMessage.ts")).toContain("buildReaderDocument");
|
||||
});
|
||||
|
||||
// A fixed 500px frame clipped every long message and left a slab of white
|
||||
|
||||
Reference in new issue
Block a user