A mark (a lambda on an ultramarine tile with the flap of an envelope), the favicon, home-screen icons and an install manifest. Every colour is a variable defined once per theme, and the Tailwind scales the screens already use read them, so dark (the default) and light are a change of values and not of classes. Geist is served by the app itself. Ctrl+K opens a command menu for folders, actions, accounts and appearance, and typing something that matches no command searches the mail; c writes a message and / goes to the search field. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
470 lines
21 KiB
TypeScript
470 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import Link from "next/link";
|
|
import { motion } from "framer-motion";
|
|
import {
|
|
AlertCircle,
|
|
ArrowLeft,
|
|
ChevronDown,
|
|
Download,
|
|
FolderInput,
|
|
Forward,
|
|
Image as ImageIcon,
|
|
ShieldAlert,
|
|
ShieldCheck,
|
|
MailOpen,
|
|
Pencil,
|
|
Reply,
|
|
ReplyAll,
|
|
Star,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useI18n } from "../i18n/provider";
|
|
import { Avatar } from "./Avatar";
|
|
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
|
|
|
|
export interface ReaderMessage {
|
|
uid: number;
|
|
subject: string;
|
|
from: string;
|
|
to: string[];
|
|
cc: string[];
|
|
date: string;
|
|
text: string;
|
|
attachments: string[];
|
|
message_id?: string;
|
|
/** The sender asked for priority (Importance: high). */
|
|
important?: boolean;
|
|
}
|
|
|
|
export interface ReaderRow {
|
|
sender_address: string;
|
|
from_display_name: string;
|
|
received_at: string;
|
|
seen: boolean;
|
|
flagged?: boolean;
|
|
}
|
|
|
|
export interface ReaderActions {
|
|
canEdit: boolean;
|
|
canReply: boolean;
|
|
canForward: boolean;
|
|
canMarkUnread: boolean;
|
|
deleteIsPermanent: boolean;
|
|
}
|
|
|
|
interface MoveTarget {
|
|
name: string;
|
|
label: string;
|
|
}
|
|
|
|
interface Props {
|
|
message: ReaderMessage;
|
|
row?: ReaderRow;
|
|
/** The signed-in address, left out of every reply-all. */
|
|
ownAddress: string;
|
|
folder: string;
|
|
actions: ReaderActions;
|
|
moveTargets: MoveTarget[];
|
|
/** The document the body frame renders, or null for a text-only message. */
|
|
document: string | null;
|
|
frameHeight: number;
|
|
/** Whether anything was held back, so the privacy bar is not always shown. */
|
|
hasBlockedContent: boolean;
|
|
imagesLoaded: boolean;
|
|
/** Whether the account trusts this sender: not spam, and images load. */
|
|
senderTrusted: boolean;
|
|
/** The message is being read in the spam folder. */
|
|
isJunk: boolean;
|
|
onClose: () => void;
|
|
onToggleSeen: () => void;
|
|
onToggleFlagged: () => void;
|
|
onDelete: () => void;
|
|
onMove: (target: string) => void;
|
|
onToggleImages: () => void;
|
|
onToggleTrusted: () => void;
|
|
onNotSpam: () => void;
|
|
}
|
|
|
|
/** The sender's name and address, from 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() };
|
|
}
|
|
|
|
/** The initials shown in the sender avatar, from a display name or an address. */
|
|
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();
|
|
}
|
|
|
|
/** A short type tag and colour for an attachment, from its extension. */
|
|
function attachmentKind(filename: string): { tag: string; className: string } {
|
|
const ext = (filename.split(".").pop() ?? "").toLowerCase();
|
|
if (ext === "pdf") return { tag: "PDF", className: "bg-[#c2352f]" };
|
|
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-[#1f7aa8]" };
|
|
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-[#2f5fb8]" };
|
|
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-[#1f8a5b]" };
|
|
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-[#a8690f]" };
|
|
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-[#5b6272]" };
|
|
}
|
|
|
|
// One look for every control on the command bar, so the bar reads as a single
|
|
// strip. The label hides on narrow screens and the icon stays, which is what
|
|
// keeps six actions on one row at phone width.
|
|
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";
|
|
|
|
export function MessageReader(props: Props) {
|
|
const { message, row, folder, actions } = props;
|
|
const { t, locale } = useI18n();
|
|
const [moveOpen, setMoveOpen] = useState(false);
|
|
const [recipientsOpen, setRecipientsOpen] = useState(false);
|
|
|
|
const { name, address } = splitSender(message.from);
|
|
const displayName = name || row?.from_display_name || address;
|
|
const showAddress = displayName !== address;
|
|
|
|
const when = message.date || row?.received_at || "";
|
|
const whenText = when
|
|
? new Date(when).toLocaleString(locale, {
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
: "";
|
|
|
|
// Collapsed, the recipient line names the first two and counts the rest: a
|
|
// newsletter sent to forty people pushed the message itself off the screen.
|
|
const visibleTo = recipientsOpen ? message.to : message.to.slice(0, 2);
|
|
const hiddenCount = message.to.length - visibleTo.length + (recipientsOpen ? 0 : message.cc.length);
|
|
const showCc = recipientsOpen && message.cc.length > 0;
|
|
|
|
const composeHref = (recipients: { to: string[]; cc: string[] }, mode: "reply" | "replyAll") => {
|
|
const query = new URLSearchParams({ mode, subject: message.subject });
|
|
if (recipients.to.length > 0) query.set("replyTo", recipients.to.join(", "));
|
|
if (recipients.cc.length > 0) query.set("cc", recipients.cc.join(", "));
|
|
if (message.message_id) query.set("inReplyTo", message.message_id);
|
|
return `/user/compose?${query.toString()}`;
|
|
};
|
|
const replyHref = composeHref(replyRecipients(message, props.ownAddress), "reply");
|
|
const replyAllHref = composeHref(replyAllRecipients(message, props.ownAddress), "replyAll");
|
|
// Offered only when it would reach someone a plain reply would not: with
|
|
// one other person on the thread the two buttons would do the same thing.
|
|
const canReplyAll = actions.canReply && offersReplyAll(message, props.ownAddress);
|
|
// Forward carries the subject but no recipient: prefilling the original
|
|
// sender would send the message straight back to whoever wrote it.
|
|
const forwardHref = `/user/compose?mode=forward&subject=${encodeURIComponent(message.subject)}`;
|
|
const flagged = Boolean(row?.flagged);
|
|
const deleteLabel = actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete");
|
|
|
|
return (
|
|
<motion.article
|
|
key={message.uid}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="flex min-h-0 flex-1 flex-col"
|
|
>
|
|
{/* Command bar. It stays put while the message scrolls under it, so
|
|
reply and delete are never a scroll away from a long newsletter. */}
|
|
<div
|
|
role="toolbar"
|
|
aria-label={t("mail.messageActions")}
|
|
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgb(var(--ink)/0.09)] sm:px-4"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={props.onClose}
|
|
className={`${commandClass} md:hidden`}
|
|
title={t("mail.backToList")}
|
|
aria-label={t("mail.backToList")}
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
|
|
{actions.canEdit && (
|
|
<Link href={`/user/compose?draft=${message.uid}`} className={`${commandClass} text-indigo-300`}>
|
|
<Pencil className="h-4 w-4" />
|
|
<span className="hidden xl:inline">{t("mail.continueDraft")}</span>
|
|
</Link>
|
|
)}
|
|
{actions.canReply && (
|
|
<Link href={replyHref} className={`${commandClass} text-indigo-300`} title={t("mail.reply")}>
|
|
<Reply className="h-4 w-4" />
|
|
<span className="hidden xl:inline">{t("mail.reply")}</span>
|
|
</Link>
|
|
)}
|
|
{canReplyAll && (
|
|
<Link href={replyAllHref} className={commandClass} title={t("mail.replyAll")}>
|
|
<ReplyAll className="h-4 w-4" />
|
|
<span className="hidden 2xl:inline">{t("mail.replyAll")}</span>
|
|
</Link>
|
|
)}
|
|
{actions.canForward && (
|
|
<Link href={forwardHref} className={commandClass} title={t("mail.forward")}>
|
|
<Forward className="h-4 w-4" />
|
|
<span className="hidden 2xl:inline">{t("mail.forward")}</span>
|
|
</Link>
|
|
)}
|
|
|
|
<span aria-hidden className="mx-1 h-5 w-px bg-ink/[0.12]" />
|
|
|
|
<button type="button" onClick={props.onDelete} className={commandClass} title={deleteLabel}>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span className="hidden 2xl:inline">{deleteLabel}</span>
|
|
</button>
|
|
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMoveOpen((open) => !open)}
|
|
aria-expanded={moveOpen}
|
|
aria-haspopup="menu"
|
|
className={commandClass}
|
|
title={t("mail.moveTo")}
|
|
>
|
|
<FolderInput className="h-4 w-4" />
|
|
<span className="hidden 2xl:inline">{t("mail.moveTo")}</span>
|
|
<ChevronDown className="hidden h-3 w-3 2xl:block" />
|
|
</button>
|
|
{moveOpen && (
|
|
<div
|
|
role="menu"
|
|
className="absolute right-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge md:left-0 md:right-auto"
|
|
>
|
|
{props.moveTargets.map((target) => (
|
|
<button
|
|
key={target.name}
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setMoveOpen(false);
|
|
props.onMove(target.name);
|
|
}}
|
|
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:bg-ink/[0.07] focus-visible:outline-none"
|
|
>
|
|
{target.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Not offered in Sent or Drafts: nothing delivers to those folders,
|
|
so an unread flag there says nothing. */}
|
|
{actions.canMarkUnread && (
|
|
<button
|
|
type="button"
|
|
onClick={props.onToggleSeen}
|
|
className={commandClass}
|
|
title={row?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
|
|
>
|
|
<MailOpen className="h-4 w-4" />
|
|
<span className="hidden 2xl:inline">
|
|
{row?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* The importance mark, available in every folder: a message in Sent
|
|
or Archive can be worth flagging too. */}
|
|
<button
|
|
type="button"
|
|
onClick={props.onToggleFlagged}
|
|
aria-pressed={flagged}
|
|
className={`${commandClass} ${flagged ? "text-amber-300" : ""}`}
|
|
title={flagged ? t("mail.unmarkImportant") : t("mail.markImportant")}
|
|
>
|
|
<Star className="h-4 w-4" fill={flagged ? "currentColor" : "none"} />
|
|
<span className="hidden 2xl:inline">{flagged ? t("mail.unmarkImportant") : t("mail.markImportant")}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={props.onClose}
|
|
className={`${commandClass} ml-auto hidden md:inline-flex`}
|
|
title={t("mail.backToList")}
|
|
aria-label={t("mail.backToList")}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-4 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
|
{message.important && (
|
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-rose-900/60 px-2.5 py-0.5 text-xs font-medium text-rose-200 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.45)]">
|
|
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
|
{t("mail.highImportanceBadge")}
|
|
</span>
|
|
)}
|
|
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
|
{message.subject || t("mail.noSubject")}
|
|
</h1>
|
|
|
|
{/* The sender block: name first and the address second, because the
|
|
name is what a reader recognises and a long address used to be
|
|
the loudest thing in the header. */}
|
|
<header className="flex items-start gap-3">
|
|
<Avatar
|
|
address={row?.sender_address ?? address}
|
|
initials={initialsOf(displayName, address)}
|
|
size={44}
|
|
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
|
/>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
<span className="text-[15px] font-semibold text-slate-100 [overflow-wrap:anywhere]">{displayName}</span>
|
|
{showAddress && (
|
|
<span className="text-[13px] text-slate-400 [overflow-wrap:anywhere]"><{address}></span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={props.onToggleTrusted}
|
|
aria-pressed={props.senderTrusted}
|
|
title={props.senderTrusted ? 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 ${
|
|
props.senderTrusted
|
|
? "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 />
|
|
{props.senderTrusted ? t("mail.trustedSender") : t("mail.trustSender")}
|
|
</button>
|
|
</div>
|
|
<div className="text-[12.5px] leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
|
{visibleTo.length > 0 && (
|
|
<>
|
|
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
|
|
{visibleTo.join(", ")}
|
|
</>
|
|
)}
|
|
{showCc && (
|
|
<div>
|
|
<span className="text-slate-500">{t("ui.ccRecipients")}: </span>
|
|
{message.cc.join(", ")}
|
|
</div>
|
|
)}
|
|
{(hiddenCount > 0 || recipientsOpen) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setRecipientsOpen((open) => !open)}
|
|
aria-expanded={recipientsOpen}
|
|
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
|
>
|
|
{recipientsOpen ? t("mail.showFewerRecipients") : `+${hiddenCount}`}
|
|
<ChevronDown className={`h-3 w-3 transition-transform ${recipientsOpen ? "rotate-180" : ""}`} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* On a phone the date drops under the recipients rather than
|
|
squeezing the name. */}
|
|
<span className="text-xs tabular-nums text-slate-500 sm:hidden">{whenText}</span>
|
|
</div>
|
|
<span className="hidden flex-none pt-0.5 text-xs tabular-nums text-slate-400 sm:block">{whenText}</span>
|
|
</header>
|
|
|
|
{/* One quiet line, shown only when something was actually held back.
|
|
The wording is the reassurance, not the colour: this is not an
|
|
error. Two separate decisions - look now, or stop asking about
|
|
this sender - so a reader can see one message without granting
|
|
anything permanently. */}
|
|
{props.isJunk && (
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg bg-amber-500/10 px-3 py-2.5 shadow-[inset_0_0_0_1px_rgba(245,158,11,0.35)]">
|
|
<ShieldAlert className="h-4 w-4 flex-none text-amber-400" aria-hidden />
|
|
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-amber-100">
|
|
{t("mail.junkNotice")}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={props.onNotSpam}
|
|
className="rounded-md bg-amber-400 px-2.5 py-1 text-[12.5px] font-semibold text-slate-900 transition-colors hover:bg-amber-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200"
|
|
>
|
|
{t("mail.notSpam")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{props.hasBlockedContent && (
|
|
<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">
|
|
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
|
</span>
|
|
{/* A trusted sender's images are shown for as long as they are
|
|
trusted, so hiding them here would do nothing. */}
|
|
{!props.senderTrusted && (
|
|
<button type="button" onClick={props.onToggleImages} className={infoLinkClass}>
|
|
{props.imagesLoaded ? t("mail.hideImages") : t("mail.loadImages")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{message.attachments.length > 0 && (
|
|
<section aria-label={t("mail.attachments", { count: message.attachments.length })}>
|
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
{message.attachments.map((filename, idx) => {
|
|
const kind = attachmentKind(filename);
|
|
return (
|
|
<a
|
|
key={`${filename}-${idx}`}
|
|
href={`/api/v1/mail/message/${message.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(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 transition-shadow hover:shadow-edge-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
|
>
|
|
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold text-white ${kind.className}`}>
|
|
{kind.tag}
|
|
</span>
|
|
<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>
|
|
</section>
|
|
)}
|
|
|
|
{props.document ? (
|
|
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
|
<iframe
|
|
title={t("ui.messageBody")}
|
|
srcDoc={props.document}
|
|
/* allow-scripts, and deliberately not allow-same-origin: the
|
|
frame stays on an opaque origin - it cannot read a cookie
|
|
or touch this document - while still running the few lines
|
|
that report its height and open links outward. Message
|
|
markup is sanitised before it gets here. */
|
|
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
|
referrerPolicy="no-referrer"
|
|
style={{ height: `${props.frameHeight}px` }}
|
|
className="block w-full border-0"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl bg-dark-panel px-5 py-5 shadow-edge sm:px-6">
|
|
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
|
|
{message.text || t("mail.emptyBody")}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.article>
|
|
);
|
|
}
|