ultisuite-client/components/gmail/email-view.tsx
2026-05-20 18:22:36 +02:00

351 lines
11 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 } 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 type { Email, EmailAttachment } from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
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 { EmailViewSubjectHeader } from "./email-view/email-view-header"
import {
MAIL_PREVIEW_SCROLL_CLASS,
MAIL_REPLY_BAR_CLASS,
MAIL_REPLY_BUTTON_CLASS,
} from "@/lib/mail-chrome-classes"
import {
CollapsedMessage,
ExpandedMessage,
SpamWhyBanner,
} from "@/components/gmail/email-view/email-view-messages"
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
}
/* ── 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>
)
}