"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 { useMessageAttachments } from "@/lib/api/hooks/use-message-attachments" import { attachmentsForEmailList } from "@/lib/attachment-display" 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, plainTextBodyFallback, } 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 emailLabelToSidebarFolderId?: Record 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 plainTextFallback = useMemo( () => plainTextBodyFallback(fullMessage), [fullMessage] ) 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 : [] const [expandedIds, setExpandedIds] = useState>(new Set()) const isThreadMessageExpanded = useCallback( (msgId: string) => expandedIds.has(msgId), [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 { data: fetchedAttachments } = useMessageAttachments( email.id, email.has_attachments ) const mainMessageAttachments = useMemo( (): EmailAttachment[] => attachmentsForEmailList({ hasAttachment: email.has_attachments, attachments: fetchedAttachments, }), [email.has_attachments, fetchedAttachments] ) 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(null) const threadComposeAnchorRef = useRef(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 (
{calendarInvitation ? ( ) : null} {isSpam && } {showRepliesCta ? (
) : null} {messagesBefore.map((msg) => (
toggleExpanded(msg.id)} onPrintConversation={handlePrint} onReply={onToolbarReply} onForward={onToolbarForward} selfEmails={selfEmails} selfDisplayName={selfDisplayName} collapseQuotedReplies={otherThreadCount > 0} />
))} 0} messageId={email.id} plainTextFallback={plainTextFallback} /> {messagesAfter.map((msg) => (
toggleExpanded(msg.id)} onPrintConversation={handlePrint} onReply={onToolbarReply} onForward={onToolbarForward} selfEmails={selfEmails} selfDisplayName={selfDisplayName} collapseQuotedReplies={otherThreadCount > 0} />
))} {showReplyForwardBar ? ( ) : null} {inlineCompose ? (
{senderInitial(selfName)}
) : null}
) }