ultisuite-client/components/gmail/email-view.tsx
2026-05-16 20:30:50 +02:00

1034 lines
36 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Star,
Reply,
ReplyAll,
Forward,
Smile,
MoreVertical,
Printer,
ExternalLink,
ChevronDown,
Info,
TriangleAlert,
Trash2,
Mail,
Ban,
ShieldAlert,
Fish,
Flag,
SlidersHorizontal,
Languages,
Download,
Code2,
MessageCircleWarning,
HardDrive,
File,
FileText,
Image as ImageIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import {
avatarColor,
cleanSenderName,
senderInitial,
} from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text"
import type {
Email,
ConversationMessage,
EmailAttachment,
EmailAttachmentKind,
} from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import {
attachmentPreviewTooltip,
resolveAttachmentKind,
shouldUseAttachmentPillsInPreview,
} from "@/lib/attachment-display"
import {
useComposeActions,
useComposeDrafts,
useComposeWindows,
DEFAULT_IDENTITIES,
type ThreadComposeKind,
savedThreadDraftToComposePreset,
} from "@/lib/compose-context"
import { buildThreadComposePreset } from "@/lib/thread-compose-preset"
import { openConversationPrint } from "@/lib/print-conversation"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { ComposeWindow } from "@/components/gmail/compose-modal"
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
import { ContactHoverCard } from "./contact-hover-card"
import { MailLabelPillStrip } from "./mail-label-pills"
interface EmailViewProps {
email: Email
onToggleStar: (id: string) => void
isStarred: boolean
onNavigateToLabel?: (label: string) => void
/** Message spam : bannière + pastille sujet ; bouton « non-spam » */
onNotSpam?: () => void
/** Si défini, les pastilles libellé dont la fonction retourne false sont masquées (préférences barre latérale). */
showLabelChip?: (label: string) => boolean
labelBgByText?: Map<string, string>
emailLabelToSidebarFolderId?: Record<string, string>
getNavItemPrefs?: (id: string) => { messages: string }
folderTree?: FolderTreeNode[]
labelRows?: readonly LabelRowItem[]
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
currentFolderId?: string
}
const LABEL_DISPLAY_NAMES: Record<string, string> = {
inbox: "Boîte de réception",
starred: "Suivis",
snoozed: "En attente",
important: "Important",
sent: "Messages envoyés",
drafts: "Brouillons",
spam: "Spam",
trash: "Corbeille",
}
const MESSAGE_MORE_MENU_CLASS =
"min-w-[280px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]"
/* ── Sandboxed iframe for HTML body ── */
function SandboxedContent({
html,
isSpam,
}: {
html: string
isSpam: boolean
}) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [height, setHeight] = useState(120)
const sandboxValue = isSpam
? "allow-same-origin"
: "allow-same-origin allow-popups"
const injectContent = useCallback(() => {
const iframe = iframeRef.current
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
const cspMeta = isSpam
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
doc.open()
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${cspMeta}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #202124;
padding: 0;
overflow: hidden;
word-wrap: break-word;
overflow-wrap: break-word;
}
a { color: #1a73e8; }
img { max-width: 100%; height: auto; }
blockquote {
border-left: 3px solid #dadce0;
padding-left: 12px;
margin: 8px 0;
color: #5f6368;
}
pre, code {
background: #f6f8fa;
border-radius: 3px;
font-size: 13px;
}
pre { padding: 12px; overflow-x: auto; }
code { padding: 2px 6px; }
</style>
</head>
<body>${html}</body>
</html>`)
doc.close()
const resizeObserver = new ResizeObserver(() => {
const body = iframe.contentDocument?.body
if (body) {
setHeight(Math.max(60, body.scrollHeight + 2))
}
})
if (doc.body) {
resizeObserver.observe(doc.body)
setHeight(Math.max(60, doc.body.scrollHeight + 2))
}
return () => resizeObserver.disconnect()
}, [html, isSpam])
useEffect(() => {
const cleanup = injectContent()
return () => cleanup?.()
}, [injectContent])
return (
<iframe
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"
className="w-full border-0"
style={{ height, display: "block" }}
tabIndex={-1}
/>
)
}
/* ── Sandboxed iframe for subject ── */
function SandboxedSubject({ text }: { text: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
doc.open()
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 22px;
line-height: 1.3;
color: #202124;
overflow: hidden;
white-space: normal;
word-wrap: break-word;
}
</style>
</head>
<body>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body>
</html>`)
doc.close()
}, [text])
return (
<iframe
ref={iframeRef}
sandbox="allow-same-origin"
title="Sujet du message"
className="pointer-events-none w-full border-0"
style={{ height: 32, display: "block" }}
tabIndex={-1}
/>
)
}
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
return (
<>
<div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-[#f8f9fa] to-[#eceff1]">
{kind === "image" ? (
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
) : kind === "pdf" ? (
<div
className="rounded border border-[#dadce0] bg-white px-4 py-5 shadow-sm"
aria-hidden
>
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
</div>
) : (
<File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
)}
</div>
<div className="flex min-h-[38px] items-center gap-2 border-t border-[#eceff1] bg-[#f1f3f4] px-2 py-1.5">
{kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
{name}
</span>
</div>
</>
)
}
function MessageAttachmentPill({
name,
kind,
sizeBytes,
}: {
name: string
kind: EmailAttachmentKind
sizeBytes?: number
}) {
const tip = attachmentPreviewTooltip(name, sizeBytes)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-[#dadce0] bg-[#f8f9fa] py-1.5 pl-2.5 pr-3 text-left text-sm text-[#3c4043] shadow-sm transition hover:border-[#bdc1c6] hover:bg-white hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1a73e8]"
>
{kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 truncate font-medium">{name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs whitespace-pre-line text-xs">
{tip}
</TooltipContent>
</Tooltip>
)
}
function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachment[] }) {
const n = attachments.length
if (n === 0) return null
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
const asPills = shouldUseAttachmentPillsInPreview(attachments)
return (
<div className="border-t border-[#eceff1] px-4 pb-4 pl-[68px] pt-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-[#5f6368]">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#5f6368] hover:bg-black/6"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-xs">
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-[#1a73e8] hover:bg-[#f6f9fe]"
aria-label="Ajouter à UltiDrive"
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
Ajouter à UltiDrive
</button>
</div>
<div
className={
asPills
? "flex flex-wrap gap-2 pb-1"
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
}
role="list"
aria-label="Pièces jointes"
>
{attachments.map((att, index) => {
const kind = resolveAttachmentKind(att.name, att.kind)
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
if (asPills) {
return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
<MessageAttachmentPill name={att.name} kind={kind} sizeBytes={att.sizeBytes} />
</div>
)
}
return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex w-[200px] flex-col overflow-hidden rounded border border-[#dadce0] bg-white text-left shadow-sm transition hover:border-[#bdc1c6] hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1a73e8]"
>
<MessageAttachmentCard name={att.name} kind={kind} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs whitespace-pre-line text-xs">
{tip}
</TooltipContent>
</Tooltip>
</div>
)
})}
</div>
</div>
)
}
/* ── Collapsed conversation message (accordion header) ── */
function CollapsedMessage({
message,
onClick,
}: {
message: ConversationMessage
onClick: () => void
}) {
const name = cleanSenderName(message.sender)
const color = avatarColor(name)
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onClick()
}
}}
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[#f6f9fe]"
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
</ContactHoverCard>
<div className="flex shrink-0 items-center gap-1">
<MailDateText
iso={message.date}
variant="preview"
className="text-xs text-[#5f6368]"
/>
<Star
strokeWidth={1.25}
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
/>
</div>
</div>
<p className="min-w-0 truncate text-sm leading-snug text-[#5f6368]">{message.preview}</p>
</div>
</div>
)
}
/* ── Expanded message card (full body) ── */
function ExpandedMessage({
sender,
senderEmail,
dateIso,
body,
isSpam,
isLast,
starred,
attachments = [],
onToggleStar,
onCollapse,
onPrintConversation,
}: {
sender: string
senderEmail: string
dateIso: string
body: string
isSpam: boolean
isLast: boolean
starred: boolean
attachments?: EmailAttachment[]
onToggleStar?: () => void
onCollapse?: () => void
onPrintConversation?: () => void
}) {
const [showDetails, setShowDetails] = useState(false)
const name = cleanSenderName(sender)
return (
<div>
{/* Sender row */}
<div
className={cn("flex items-start gap-3 px-4 py-3", !isLast && "cursor-pointer")}
onClick={!isLast ? onCollapse : undefined}
>
{isSpam ? (
<div
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full bg-[#e8eaed] text-[#e8710a]"
aria-label="Expéditeur ou message suspect (spam)"
>
<TriangleAlert className="size-[22px]" strokeWidth={2} aria-hidden />
</div>
) : (
<div
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: avatarColor(name) }}
>
{senderInitial(name)}
</div>
)}
<div className="min-w-0 flex-1 flex flex-col gap-1">
<div className="min-w-0 truncate text-sm leading-snug">
<ContactHoverCard
displayName={sender}
email={senderEmail}
onTriggerClick={!isLast ? (e) => e.stopPropagation() : undefined}
className="inline min-w-0 max-w-full align-baseline"
>
<span className="font-semibold text-[#202124]">{name}</span>
<span className="text-[#5f6368]"> &lt;{senderEmail}&gt;</span>
</ContactHoverCard>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="flex items-center gap-0.5 text-xs text-[#5f6368] hover:text-[#202124]"
onClick={(e) => {
e.stopPropagation()
setShowDetails(!showDetails)
}}
>
à moi
<ChevronDown className={cn("h-3 w-3 transition-transform", showDetails && "rotate-180")} />
</button>
</div>
{showDetails && (
<div className="mt-1 space-y-0.5 text-xs text-[#5f6368]">
<p>de : <span className="text-[#3c4043]">{name} &lt;{senderEmail}&gt;</span></p>
<p>à : <span className="text-[#3c4043]">moi</span></p>
<p>
date :{" "}
<MailDateText
iso={dateIso}
variant="detail"
className="text-[#3c4043]"
/>
</p>
{isSpam && (
<p className="text-[#d93025]">sécurité : ce message est marqué comme spam les images et appels externes sont bloqués</p>
)}
</div>
)}
</div>
<div className="flex shrink-0 self-start items-center gap-1 pt-0.5">
<MailDateText
iso={dateIso}
variant="preview"
className="text-xs text-[#5f6368]"
/>
{onToggleStar && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleStar()
}}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
aria-label={starred ? "Retirer des favoris" : "Marquer comme favori"}
>
<Star
strokeWidth={starred ? 0 : 1.25}
className={cn(
"size-4",
starred
? "fill-[#f4cc70] stroke-none text-[#f4cc70]"
: "fill-transparent stroke-[#c2c2c2]"
)}
/>
</button>
)}
{!onToggleStar && (
<Star strokeWidth={1.25} className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]" />
)}
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]"
aria-label="Répondre"
onClick={(e) => e.stopPropagation()}
>
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Répondre</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]"
aria-label="Plus d'actions"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={4}
className={MESSAGE_MORE_MENU_CLASS}
>
<DropdownMenuItem>
<Reply className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Répondre
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Transférer
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Supprimer
</DropdownMenuItem>
<DropdownMenuItem>
<Mail className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Marquer comme non lus à partir d&apos;ici
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Ban className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Bloquer « {name} »
</DropdownMenuItem>
<DropdownMenuItem>
<ShieldAlert className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Signaler comme spam
</DropdownMenuItem>
<DropdownMenuItem>
<Fish className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Signaler comme hameçonnage
</DropdownMenuItem>
<DropdownMenuItem>
<Flag className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Signaler un contenu illégal
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SlidersHorizontal className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Filtrer les messages similaires
</DropdownMenuItem>
<DropdownMenuItem>
<Languages className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Traduire
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onPrintConversation?.()
}}
>
<Printer className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Imprimer
</DropdownMenuItem>
<DropdownMenuItem>
<Download className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Télécharger le message
</DropdownMenuItem>
<DropdownMenuItem>
<Code2 className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Afficher l&apos;original
</DropdownMenuItem>
<DropdownMenuItem>
<MessageCircleWarning className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Partager pour aider à améliorer Google
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Body */}
<div
className={cn(
"px-4 pl-[68px]",
attachments.length > 0 ? "pb-0" : "pb-4"
)}
>
<SandboxedContent html={body} isSpam={isSpam} />
</div>
{attachments.length > 0 && (
<MessageAttachmentsSection attachments={attachments} />
)}
</div>
)
}
/* ── Spam explainer (preview) ── */
function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
return (
<div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-[#e8eaed] bg-[#f1f3f4] px-4 py-3.5">
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm leading-snug text-[#3c4043]">
<span className="font-medium text-[#202124]">Pourquoi ce message est-il dans le spam ?</span>{" "}
Ce message est semblable à des messages identifiés comme spam par le passé.
</p>
{onNotSpam && (
<button
type="button"
onClick={onNotSpam}
className="rounded-md border border-[#dadce0] bg-white px-4 py-2 text-sm font-medium text-[#1a73e8] shadow-sm transition-colors hover:bg-[#f6f9fe]"
>
Signaler comme non-spam
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#5f6368] hover:bg-black/6"
aria-label="En savoir plus sur le filtre anti-spam"
>
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs text-xs">
Les filtres peuvent se tromper. Si le message est légitime, signalez-le comme non-spam pour
l&apos;améliorer.
</TooltipContent>
</Tooltip>
</div>
)
}
/* ── Main EmailView component ── */
export function EmailView({
email,
onToggleStar,
isStarred,
onNavigateToLabel,
onNotSpam,
showLabelChip,
labelBgByText,
emailLabelToSidebarFolderId = {},
getNavItemPrefs = () => ({ messages: "show" }),
folderTree,
labelRows,
currentFolderId,
}: EmailViewProps) {
const conversation = email.conversation ?? []
const hasConversation = conversation.length > 0
const isSpamMessage = email.spam === true
// Track which conversation messages are expanded (by index).
// By default all previous messages are collapsed, only the last (main) is expanded.
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const toggleExpanded = (msgId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(msgId)) {
next.delete(msgId)
} else {
next.add(msgId)
}
return next
})
}
const mainSenderName = cleanSenderName(email.sender)
const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
const { composeWindows } = useComposeWindows()
const { savedThreadReplyDrafts } = useComposeDrafts()
const { openComposeWithInitial } = useComposeActions()
const inlineCompose = useMemo(
() =>
composeWindows.find(
(c) => c.placement === "inline" && c.threadEmailId === email.id
),
[composeWindows, email.id]
)
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
if (email.attachments && email.attachments.length > 0) return email.attachments
if (email.hasAttachment) return [{ name: "Pièce jointe", kind: "other" }]
return []
}, [email.attachments, email.hasAttachment])
const savedThreadDraft = savedThreadReplyDrafts[email.id]
const hasInlineForThread = Boolean(inlineCompose)
const threadComposeFooterRef = useRef<HTMLDivElement>(null)
const scrollThreadComposeIntoView = useCallback(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
threadComposeFooterRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
inline: "nearest",
})
})
})
}, [])
useEffect(() => {
if (!savedThreadDraft || hasInlineForThread) return
openComposeWithInitial(savedThreadDraftToComposePreset(savedThreadDraft))
scrollThreadComposeIntoView()
}, [
email.id,
savedThreadDraft,
hasInlineForThread,
openComposeWithInitial,
scrollThreadComposeIntoView,
])
const startThreadCompose = useCallback(
(kind: ThreadComposeKind) => {
openComposeWithInitial(buildThreadComposePreset(email, kind))
scrollThreadComposeIntoView()
},
[email, openComposeWithInitial, scrollThreadComposeIntoView]
)
const selfIdentity = DEFAULT_IDENTITIES[0]
const selfName = cleanSenderName(selfIdentity.name)
const showReplyForwardBar = !inlineCompose
const calendarInvitation = useMemo(
() => resolveParsedCalendarInvitation(email),
[email]
)
return (
<TooltipProvider delayDuration={400}>
<div className="flex min-w-0 flex-col">
{/* Subject header */}
<div className="flex items-start gap-3 px-6 py-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<SandboxedSubject text={email.subject} />
{labelBgByText && onNavigateToLabel ? (
<MailLabelPillStrip
variant="header"
labels={email.labels ?? ["inbox"]}
labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs}
labelRows={labelRows}
folderTree={folderTree}
currentFolderId={currentFolderId}
onLabelNavigate={onNavigateToLabel}
showLabel={showLabelChip}
resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab}
showRemoveOnPills
spamChip={
isSpamMessage && onNotSpam
? { onNotSpam }
: undefined
}
/>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]"
aria-label="Imprimer"
onClick={() => openConversationPrint(email)}
>
<Printer className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Imprimer tout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]"
aria-label="Ouvrir dans une nouvelle fenêtre"
>
<ExternalLink className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Dans une nouvelle fenêtre</TooltipContent>
</Tooltip>
</div>
</div>
{calendarInvitation ? (
<CalendarInvitationPreview invitation={calendarInvitation} />
) : null}
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
{/* Conversation messages */}
{/* Previous messages in conversation */}
{hasConversation && conversation.map((msg) => {
const isExpanded = expandedIds.has(msg.id)
if (isExpanded) {
return (
<div key={msg.id} className="border-b border-[#eceff1]">
<ExpandedMessage
sender={msg.sender}
senderEmail={msg.senderEmail}
dateIso={msg.date}
body={msg.body}
isSpam={false}
isLast={false}
starred={false}
attachments={msg.attachments ?? []}
onCollapse={() => toggleExpanded(msg.id)}
onPrintConversation={() => openConversationPrint(email)}
/>
</div>
)
}
return (
<div key={msg.id} className="border-b border-[#eceff1]">
<CollapsedMessage
message={msg}
onClick={() => toggleExpanded(msg.id)}
/>
</div>
)
})}
{/* Last / main message — always expanded */}
<ExpandedMessage
sender={mainSenderName}
senderEmail={mainSenderAddr}
dateIso={email.date}
body={email.body || `<p style="color:#5f6368;">${email.preview}</p>`}
isSpam={email.spam === true}
isLast={true}
starred={isStarred}
attachments={mainMessageAttachments}
onToggleStar={() => onToggleStar(email.id)}
onPrintConversation={() => openConversationPrint(email)}
/>
{/* Réponse / transfert : flux normal, juste sous le dernier message */}
<div ref={threadComposeFooterRef} className="min-w-0 shrink-0">
{showReplyForwardBar ? (
<div className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px]">
<button
type="button"
onClick={() => startThreadCompose("reply")}
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-[#dadce0] bg-white px-6 py-2.5 text-sm font-medium text-[#3c4043] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md"
>
<Reply className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Répondre
</button>
<button
type="button"
onClick={() => startThreadCompose("replyAll")}
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-[#dadce0] bg-white px-6 py-2.5 text-sm font-medium text-[#3c4043] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md"
>
<ReplyAll className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Répondre à tous
</button>
<button
type="button"
onClick={() => startThreadCompose("forward")}
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-[#dadce0] bg-white px-6 py-2.5 text-sm font-medium text-[#3c4043] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md"
>
<Forward className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Transférer
</button>
<button
type="button"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md"
aria-label="Réaction"
>
<Smile className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button>
</div>
) : null}
{inlineCompose ? (
<div className="px-4 pb-6 pt-2">
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: avatarColor(selfName) }}
aria-hidden
>
{senderInitial(selfName)}
</div>
<div className="min-w-0 flex-1">
<ComposeWindow
key={inlineCompose.id}
compose={inlineCompose}
threadSourceEmail={email}
/>
</div>
</div>
</div>
) : null}
</div>
</div>
</TooltipProvider>
)
}