ultisuite-client/components/gmail/email-view.tsx
R3D347HR4Y 9266aa34cd huhu
2026-05-19 22:20:43 +02:00

1095 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react"
import {
Star,
Reply,
ReplyAll,
Forward,
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,
type ComposeOpenPreset,
savedThreadDraftToComposePreset,
} from "@/lib/compose-context"
import {
buildThreadComposePreset,
withTouchFullscreenComposePreset,
} 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"
import {
MAIL_ICON_BTN,
MAIL_INVITATION_CARD_CLASS,
MAIL_MENU_SURFACE_WIDE_CLASS,
MAIL_MESSAGE_HOVER_CLASS,
MAIL_PREVIEW_SCROLL_CLASS,
MAIL_REPLY_BAR_CLASS,
MAIL_REPLY_BUTTON_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
import { useTheme } from "next-themes"
import {
emailPreviewBaseCss,
emailPreviewDarkOverrideCss,
emailPreviewLightOverrideCss,
emailPreviewSubjectCss,
preprocessEmailHtmlForTheme,
} from "@/lib/email-preview-dark-styles"
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
/** Fil complet (mode message isolé hors conversation). */
threadRoot?: Email | null
/** Affiche uniquement le message courant avec option douvrir le fil. */
isSingleMessageView?: boolean
}
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 = MAIL_MENU_SURFACE_WIDE_CLASS
const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = {
display: "block",
background: "transparent",
}
function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
/* ── 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 { resolvedTheme } = useTheme()
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:;">`
const isDark = documentIsDark()
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
const themeOverrides = isDark
? emailPreviewDarkOverrideCss()
: emailPreviewLightOverrideCss()
doc.open()
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${cspMeta}
<style>
${emailPreviewBaseCss(isDark)}
${themeOverrides}
</style>
</head>
<body>${processedHtml}</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, resolvedTheme])
useEffect(() => {
const cleanup = injectContent()
return () => cleanup?.()
}, [injectContent])
return (
<iframe
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"
className="w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
tabIndex={-1}
/>
)
}
/* ── Sandboxed iframe for subject ── */
function SandboxedSubject({ text }: { text: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
const isDark = documentIsDark()
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>${emailPreviewSubjectCss(isDark)}</style>
</head>
<body>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body>
</html>`)
doc.close()
}, [text, resolvedTheme])
return (
<iframe
ref={iframeRef}
sandbox="allow-same-origin"
title="Sujet du message"
className="pointer-events-none w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: "32px" }}
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-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]">
{kind === "image" ? (
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
) : kind === "pdf" ? (
<div
className="rounded border border-border bg-mail-surface 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-border bg-muted 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-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "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="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-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-muted-foreground">
<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-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "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-primary hover:bg-accent"
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-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MessageAttachmentCard name={att.name} kind={kind} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "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={cn("group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors", MAIL_MESSAGE_HOVER_CLASS)}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<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-foreground">{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-bold text-white"
style={{ backgroundColor: avatarColor(name) }}
>
{senderInitial(name)}
</div>
)}
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<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-foreground">{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 flex-col items-end gap-1 self-start pt-0.5">
<div className="flex items-center gap-1">
<MailDateText
iso={dateIso}
variant="preview"
className="hidden text-xs text-[#5f6368] sm:inline"
/>
{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={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Répondre"
onClick={(e) => e.stopPropagation()}
>
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Répondre</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", MAIL_ICON_BTN)}
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>
<MailDateText
iso={dateIso}
variant="previewShort"
className="text-xs text-[#5f6368] sm:hidden"
/>
</div>
</div>
{/* Body */}
<div
className={cn(
"px-4 pl-[68px] max-sm:pl-4 max-sm:pr-4",
attachments.length > 0 ? "pb-0" : "pb-4"
)}
data-selectable-text
>
<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-border bg-muted px-4 py-3.5 max-sm:mx-4">
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm leading-snug text-[#3c4043]">
<span className="font-medium text-foreground">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-border bg-mail-surface px-4 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-accent"
>
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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "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,
threadRoot = null,
isSingleMessageView = false,
}: EmailViewProps) {
const [showFullThread, setShowFullThread] = useState(false)
const threadForReplies = threadRoot ?? email
const priorCount = Math.max(
0,
(threadForReplies.threadMessageIds?.length ?? 1) - 1
)
const showRepliesCta =
isSingleMessageView && !showFullThread && priorCount > 0
const conversation =
isSingleMessageView && !showFullThread
? []
: (showFullThread ? threadForReplies.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 showReplyForwardBar = !inlineCompose
const previewScrollRef = useRef<HTMLDivElement>(null)
const threadComposeAnchorRef = useRef<HTMLDivElement>(null)
const scrollThreadComposeIntoView = useCallback(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
threadComposeAnchorRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
inline: "nearest",
})
})
})
}, [])
const openThreadCompose = useCallback(
(preset: ComposeOpenPreset) => {
const resolved = withTouchFullscreenComposePreset(preset)
openComposeWithInitial(resolved)
if (resolved.placement === "inline") {
scrollThreadComposeIntoView()
}
},
[openComposeWithInitial, scrollThreadComposeIntoView]
)
useEffect(() => {
if (!savedThreadDraft || hasInlineForThread) return
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
}, [
email.id,
savedThreadDraft,
hasInlineForThread,
openThreadCompose,
])
const startThreadCompose = useCallback(
(kind: ThreadComposeKind) => {
openThreadCompose(buildThreadComposePreset(email, kind))
},
[email, openThreadCompose]
)
const selfIdentity = DEFAULT_IDENTITIES[0]
const selfName = cleanSenderName(selfIdentity.name)
const calendarInvitation = useMemo(
() => resolveParsedCalendarInvitation(email),
[email]
)
return (
<TooltipProvider delayDuration={400}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
{/* Spacer for floating nav buttons on xs */}
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden />
{/* Subject header */}
<div className="flex items-start gap-3 px-6 py-4 max-sm:px-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={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Imprimer"
onClick={() => openConversationPrint(email)}
>
<Printer className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Imprimer tout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Ouvrir dans une nouvelle fenêtre"
>
<ExternalLink className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Dans une nouvelle fenêtre</TooltipContent>
</Tooltip>
</div>
</div>
{calendarInvitation ? (
<CalendarInvitationPreview invitation={calendarInvitation} />
) : null}
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
{showRepliesCta ? (
<div className="border-b border-[#eceff1] px-6 py-3 max-sm:px-4">
<button
type="button"
onClick={() => setShowFullThread(true)}
className="text-sm font-medium text-[#1a73e8] hover:underline"
>
{priorCount === 1
? "Afficher la réponse"
: `Afficher les ${priorCount} réponses`}
</button>
</div>
) : null}
{/* 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)}
/>
{showReplyForwardBar ? (
<div
className={cn(
"z-10 mt-4 hidden flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px] sm:flex",
"max-sm:static sm:sticky sm:bottom-0",
MAIL_REPLY_BAR_CLASS
)}
>
<button
type="button"
onClick={() => startThreadCompose("reply")}
className={MAIL_REPLY_BUTTON_CLASS}
>
<Reply className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
Répondre
</button>
<button
type="button"
onClick={() => startThreadCompose("replyAll")}
className={MAIL_REPLY_BUTTON_CLASS}
>
<ReplyAll className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
Répondre à tous
</button>
<button
type="button"
onClick={() => startThreadCompose("forward")}
className={MAIL_REPLY_BUTTON_CLASS}
>
<Forward className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
Transférer
</button>
</div>
) : null}
{inlineCompose ? (
<div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4">
<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-bold 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>
)
}