The mark is rebuilt as a lambda of two square-cut strokes of one weight, joined without a seam, on a flat ultramarine tile with continuous corners: the rounded strokes and the gradient are gone. A message written plainly is now drawn in the app's own colours instead of a white slab, with text too dark for the ground lifted and white backdrops cleared; a designed one (newsletter, receipt) keeps its white sheet. A reply's quoted history folds behind a button. A narrower reading column, a larger title, and a way to see a plain message as sent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
616 lines
26 KiB
TypeScript
616 lines
26 KiB
TypeScript
"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 { useTheme } from "../lib/theme";
|
|
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-ink/[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-ink/[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_rgb(var(--ink)/0.18)] transition-colors hover:bg-ink/[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 { theme } = useTheme();
|
|
// Plain messages that are shown as sent instead of in the app's colours.
|
|
const [originals, setOriginals] = useState<Set<string>>(new Set());
|
|
|
|
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_rgb(var(--ink)/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-ink/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-ink/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-ink/[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-[820px] flex-col gap-3 px-4 pb-14 pt-6 sm:px-8">
|
|
<div className="flex flex-col gap-2">
|
|
<h1 className="text-[22px] font-semibold leading-[1.22] tracking-[-0.022em] text-slate-50 [overflow-wrap:anywhere] [text-wrap:balance] sm:text-[27px]">
|
|
{thread.subject || t("mail.noSubject")}
|
|
</h1>
|
|
<div className="flex flex-wrap items-center gap-2 text-[12.5px] text-slate-400">
|
|
{(infos?.length ?? thread.message_count) > 1 && (
|
|
<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 rendered = loaded ? messageDocument(loaded, showImages, { theme, original: originals.has(key) }) : 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_rgb(var(--a-700))]"
|
|
/>
|
|
<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="hidden min-w-0 truncate text-[12.5px] text-slate-400 sm:inline"><{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_rgb(var(--ink)/0.18)] hover:bg-ink/[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-ink/[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>
|
|
)}
|
|
|
|
{rendered?.canAdapt && (
|
|
<div className="-mb-1 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setOriginals((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
})
|
|
}
|
|
className={infoLinkClass}
|
|
>
|
|
{originals.has(key) ? t("viewer.adapt") : t("viewer.asSent")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{rendered ? (
|
|
<MessageFrame document={rendered.document} paper={rendered.paper} />
|
|
) : (
|
|
<pre className="whitespace-pre-wrap break-words font-sans text-[15.5px] leading-[1.68] text-slate-100">
|
|
{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>
|
|
);
|
|
}
|