1034 lines
36 KiB
TypeScript
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, "<").replace(/>/g, ">")}</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]"> <{senderEmail}></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} <{senderEmail}></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'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'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'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>
|
|
)
|
|
}
|