"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 `. */ 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 ( {/* Command bar. It stays put while the message scrolls under it, so reply and delete are never a scroll away from a long newsletter. */}
{actions.canEdit && ( {t("mail.continueDraft")} )} {actions.canReply && ( {t("mail.reply")} )} {canReplyAll && ( {t("mail.replyAll")} )} {actions.canForward && ( {t("mail.forward")} )}
{moveOpen && (
{props.moveTargets.map((target) => ( ))}
)}
{/* Not offered in Sent or Drafts: nothing delivers to those folders, so an unread flag there says nothing. */} {actions.canMarkUnread && ( )} {/* The importance mark, available in every folder: a message in Sent or Archive can be worth flagging too. */}
{message.important && ( {t("mail.highImportanceBadge")} )}

{message.subject || t("mail.noSubject")}

{/* 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. */}
{displayName} {showAddress && ( <{address}> )}
{visibleTo.length > 0 && ( <> {t("ui.toRecipients")}: {visibleTo.join(", ")} )} {showCc && (
{t("ui.ccRecipients")}: {message.cc.join(", ")}
)} {(hiddenCount > 0 || recipientsOpen) && ( )}
{/* On a phone the date drops under the recipients rather than squeezing the name. */} {whenText}
{whenText}
{/* 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 && (
{t("mail.junkNotice")}
)} {props.hasBlockedContent && (
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")} {/* A trusted sender's images are shown for as long as they are trusted, so hiding them here would do nothing. */} {!props.senderTrusted && ( )}
)} {message.attachments.length > 0 && (
{message.attachments.map((filename, idx) => { const kind = attachmentKind(filename); return ( {kind.tag} {filename} ); })}
)} {props.document ? (