ultisuite-client/components/gmail/email-view.tsx
2026-05-25 13:52:40 +02:00

485 lines
16 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { Reply, ReplyAll, Forward } from "lucide-react"
import { cn } from "@/lib/utils"
import {
avatarColor,
cleanSenderName,
senderInitial,
} from "@/lib/sender-display"
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
import type { Email, EmailAttachment } from "@/lib/email-data"
import {
mailFlagIsRead,
mailFlagIsStarred,
mailFlagIsImportant,
messageIsSpam,
messageHasFlag,
messageHasLabel,
} from "@/lib/mail-flags"
import { repairSnippet } from "@/lib/mail-mime-body"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { useMessage, useThread, unwrapThreadMessages } from "@/lib/api/hooks/use-mail-queries"
import {
useToggleStar,
useUpdateFlags,
useUpdateLabels,
} from "@/lib/api/hooks/use-mail-mutations"
import {
useComposeActions,
useComposeDrafts,
useComposeWindows,
type ThreadComposeKind,
type ComposeOpenPreset,
savedThreadDraftToComposePreset,
} from "@/lib/compose-context"
import { resolveComposeIdentity } from "@/lib/compose/resolve-compose-identity"
import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { resolveMessageFrom } from "@/lib/mail-message-participants"
import { buildMessageHeaderDetails } from "@/lib/mail-message-header-details"
import { splitThreadAroundOpenMessage } from "@/lib/mail-thread"
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 {
ExpandedMessage,
SpamWhyBanner,
ThreadPriorMessage,
formatApiMessageBody,
} from "@/components/gmail/email-view/email-view-messages"
function apiToLegacyEmail(
msg: ApiMessageSummary,
full?: ApiMessageFull | null,
thread?: ApiMessageFull[] | null
): Email {
const senderName = msg.from[0]?.name ?? ""
return {
id: msg.id,
sender: senderName,
senderEmail: msg.from[0]?.address,
subject: msg.subject,
preview: repairSnippet(msg.snippet) ?? msg.snippet,
body: full?.body_html ?? full?.body_text,
date: msg.date,
read: mailFlagIsRead(msg.flags),
starred: mailFlagIsStarred(msg.flags),
important: mailFlagIsImportant(msg.flags, msg.labels),
spam: messageIsSpam(msg.flags, msg.labels),
labels: msg.labels,
hasAttachment: msg.has_attachments,
conversation: thread
?.filter((m) => m.id !== msg.id)
.map((m) => ({
id: m.id,
sender: m.from[0]?.name ?? "",
senderEmail: m.from[0]?.address ?? "",
date: m.date,
body: m.body_html ?? m.body_text ?? "",
preview: m.snippet,
})),
}
}
interface EmailViewProps {
email: ApiMessageSummary
onNavigateToLabel?: (label: string) => void
showLabelChip?: (label: string) => boolean
labelBgByText?: Map<string, string>
emailLabelToSidebarFolderId?: Record<string, string>
getNavItemPrefs?: (id: string) => { messages: string }
folderTree?: FolderTreeNode[]
labelRows?: readonly LabelRowItem[]
currentFolderId?: string
isSingleMessageView?: boolean
}
export function EmailView({
email,
onNavigateToLabel,
showLabelChip,
labelBgByText,
emailLabelToSidebarFolderId = {},
getNavItemPrefs = () => ({ messages: "show" }),
folderTree,
labelRows,
currentFolderId,
isSingleMessageView = false,
}: EmailViewProps) {
const { data: fullMessage, isPending: fullMessagePending } = useMessage(email.id)
const { data: threadMessages } = useThread(email.thread_id ?? null)
const selfEmails = useSelfMailEmails()
const chromeIdentity = useChromeIdentity()
const selfDisplayName = chromeIdentity?.name
const toggleStar = useToggleStar()
const updateFlags = useUpdateFlags()
const updateLabels = useUpdateLabels()
const flags = fullMessage?.flags ?? email.flags
const isStarred = mailFlagIsStarred(flags)
const isSpam = messageIsSpam(flags, fullMessage?.labels ?? email.labels)
const body = useMemo(
() =>
formatApiMessageBody(
fullMessage,
email.snippet,
fullMessagePending && !fullMessage
),
[fullMessage, fullMessagePending, email.snippet]
)
const [showFullThread, setShowFullThread] = useState(false)
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
const threadOrdered = useMemo(
() => unwrapThreadMessages(threadMessages),
[threadMessages]
)
const { before: threadBefore, after: threadAfter } = useMemo(
() => splitThreadAroundOpenMessage(threadOrdered, email.id),
[threadOrdered, email.id]
)
const otherThreadCount = threadBefore.length + threadAfter.length
const showRepliesCta =
isSingleMessageView && !showFullThread && otherThreadCount > 0
const showFullThreadList = !isSingleMessageView || showFullThread
const messagesBefore = showFullThreadList ? threadBefore : []
const messagesAfter = showFullThreadList ? threadAfter : []
/** Conversation preview: all thread messages expanded (each gets its own remote-content banner). */
const expandAllThreadMessages =
showFullThreadList && (!isSingleMessageView || showFullThread)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const isThreadMessageExpanded = useCallback(
(msgId: string) => expandAllThreadMessages || expandedIds.has(msgId),
[expandAllThreadMessages, expandedIds]
)
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 mainFrom = useMemo(
() =>
resolveMessageFrom(fullMessage?.from ?? email.from, {
selfEmails,
selfDisplayName,
}),
[fullMessage?.from, email.from, selfEmails, selfDisplayName]
)
const mainHeaderDetails = useMemo(
() =>
buildMessageHeaderDetails(
{
...email,
...fullMessage,
from: fullMessage?.from ?? email.from,
to: fullMessage?.to ?? email.to,
cc: fullMessage?.cc,
subject: email.subject,
},
{ selfEmails, selfDisplayName, subject: email.subject }
),
[email, fullMessage, selfEmails, selfDisplayName]
)
const legacyEmail = useMemo(
() => apiToLegacyEmail(email, fullMessage, unwrapThreadMessages(threadMessages)),
[email, fullMessage, threadMessages]
)
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
if (email.has_attachments)
return [{ name: "Pièce jointe", kind: "other" }]
return []
}, [email.has_attachments])
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 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]
)
const savedThreadDraft = savedThreadReplyDrafts[email.id]
useEffect(() => {
if (!savedThreadDraft || hasInlineForThread) return
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
}, [email.id, savedThreadDraft, hasInlineForThread, openThreadCompose])
const startThreadCompose = useCallback(
(kind: ThreadComposeKind) => {
openThreadCompose(buildThreadComposePreset(legacyEmail, kind))
},
[legacyEmail, openThreadCompose]
)
const onToolbarReply = useCallback(
() => startThreadCompose("reply"),
[startThreadCompose]
)
const onToolbarForward = useCallback(
() => startThreadCompose("forward"),
[startThreadCompose]
)
const selfIdentity = resolveComposeIdentity()
const selfName = cleanSenderName(selfIdentity.name)
const calendarInvitation = useMemo(
() => resolveParsedCalendarInvitation(legacyEmail),
[legacyEmail]
)
const handleToggleStar = useCallback(() => {
toggleStar.mutate({ id: email.id, flags, starred: isStarred })
}, [email.id, flags, isStarred, toggleStar])
const handleNotSpam = useCallback(() => {
if (messageHasFlag(flags, "spam")) {
updateFlags.mutate({
id: email.id,
flags: flags.filter((f) => f.toLowerCase() !== "spam"),
})
}
if (messageHasLabel(email.labels, "spam")) {
updateLabels.mutate({
id: email.id,
labels: (email.labels ?? []).filter((l) => l.toLowerCase() !== "spam"),
})
}
}, [email.id, flags, email.labels, updateFlags, updateLabels])
const handlePrint = useCallback(() => {
openConversationPrint(legacyEmail)
}, [legacyEmail])
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
<div
className="h-[52px] shrink-0 bg-mail-surface sm:hidden"
aria-hidden
/>
<EmailViewSubjectHeader
email={email}
isSpamMessage={isSpam}
onNotSpam={isSpam ? handleNotSpam : undefined}
onPrint={handlePrint}
onNavigateToLabel={onNavigateToLabel}
showLabelChip={showLabelChip}
labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs}
folderTree={folderTree}
labelRows={labelRows}
currentFolderId={currentFolderId}
/>
{calendarInvitation ? (
<CalendarInvitationPreview invitation={calendarInvitation} />
) : null}
{isSpam && <SpamWhyBanner onNotSpam={handleNotSpam} />}
{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"
>
{otherThreadCount === 1
? "Afficher l'autre message du fil"
: `Afficher les ${otherThreadCount} autres messages du fil`}
</button>
</div>
) : null}
{messagesBefore.map((msg) => (
<div key={msg.id} className="border-b border-[#eceff1]">
<ThreadPriorMessage
message={msg}
isExpanded={isThreadMessageExpanded(msg.id)}
onToggle={() => toggleExpanded(msg.id)}
onPrintConversation={handlePrint}
onReply={onToolbarReply}
onForward={onToolbarForward}
selfEmails={selfEmails}
selfDisplayName={selfDisplayName}
collapseQuotedReplies={otherThreadCount > 0}
/>
</div>
))}
<ExpandedMessage
sender={mainFrom.name}
senderEmail={mainFrom.email}
headerDetails={mainHeaderDetails}
dateIso={email.date}
body={body}
isSpam={isSpam}
isLast={messagesAfter.length === 0}
starred={isStarred}
attachments={mainMessageAttachments}
onToggleStar={handleToggleStar}
onPrintConversation={handlePrint}
onReply={onToolbarReply}
onForward={onToolbarForward}
detailsOpen={mainDetailsOpen}
onDetailsOpenChange={setMainDetailsOpen}
collapseQuotedReplies={otherThreadCount > 0}
messageId={email.id}
/>
{messagesAfter.map((msg) => (
<div key={msg.id} className="border-b border-[#eceff1]">
<ThreadPriorMessage
message={msg}
isExpanded={isThreadMessageExpanded(msg.id)}
onToggle={() => toggleExpanded(msg.id)}
onPrintConversation={handlePrint}
onReply={onToolbarReply}
onForward={onToolbarForward}
selfEmails={selfEmails}
selfDisplayName={selfDisplayName}
collapseQuotedReplies={otherThreadCount > 0}
/>
</div>
))}
{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 max-sm:px-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={legacyEmail}
/>
</div>
</div>
</div>
) : null}
</div>
</div>
)
}