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

772 lines
25 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,
Info,
HardDrive,
File,
FileText,
Image as ImageIcon,
} from "lucide-react"
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 { EmailViewSubjectHeader } from "./email-view/email-view-header"
import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar"
import {
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,
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 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}
/>
)
}
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-muted-foreground"
/>
<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-muted-foreground">{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
}) {
return (
<div>
<EmailViewMessageToolbar
sender={sender}
senderEmail={senderEmail}
dateIso={dateIso}
isSpam={isSpam}
isLast={isLast}
starred={starred}
onToggleStar={onToggleStar}
onCollapse={onCollapse}
onPrintConversation={onPrintConversation}
/>
{/* 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-foreground/80">
<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-muted-foreground hover:bg-accent"
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 />
<EmailViewSubjectHeader
email={email}
isSpamMessage={isSpamMessage}
onNotSpam={onNotSpam}
onNavigateToLabel={onNavigateToLabel}
showLabelChip={showLabelChip}
labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs}
folderTree={folderTree}
labelRows={labelRows}
currentFolderId={currentFolderId}
/>
{calendarInvitation ? (
<CalendarInvitationPreview invitation={calendarInvitation} />
) : null}
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
{showRepliesCta ? (
<div className="border-b border-border px-6 py-3 max-sm:px-4">
<button
type="button"
onClick={() => setShowFullThread(true)}
className="text-sm font-medium text-primary 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-border">
<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:var(--muted-foreground);">${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>
)
}