772 lines
25 KiB
TypeScript
772 lines
25 KiB
TypeScript
"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 d’ouvrir 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'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>
|
||
)
|
||
}
|