Some checks failed
E2E / Playwright e2e (push) Has been cancelled
- Updated email and contact detail views to utilize the repairSnippet function for improved snippet display. - Refactored email-view-messages to ensure consistent snippet formatting across different components. - Enhanced mail-mime-body utility to include additional repair logic for handling UTF-8 mojibake, improving text rendering quality.
558 lines
18 KiB
TypeScript
558 lines
18 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 { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
|
|
import { useRecoverMissingMessageAttachments } from "@/lib/api/hooks/use-recover-missing-message-attachments"
|
|
import { resolvePreviewAttachments } 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"
|
|
import {
|
|
ConversationAttachmentsSection,
|
|
type ConversationAttachmentEntry,
|
|
} from "@/components/gmail/email-view/message-attachments"
|
|
|
|
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: repairSnippet(m.snippet) ?? 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 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<Set<string>>(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 allThreadMessages = useMemo((): ApiMessageFull[] => {
|
|
const main: ApiMessageFull = fullMessage
|
|
? { ...email, ...fullMessage }
|
|
: { ...email }
|
|
return [...threadBefore, main, ...threadAfter]
|
|
}, [threadBefore, threadAfter, email, fullMessage])
|
|
|
|
// Thread API used to omit has_attachments; treat undefined as unknown (still fetch).
|
|
const attachmentMessageIds = useMemo(
|
|
() =>
|
|
allThreadMessages
|
|
.filter((m) => m.has_attachments !== false)
|
|
.map((m) => m.id),
|
|
[allThreadMessages]
|
|
)
|
|
|
|
const { byId: attachmentsByMessageId, stateById: attachmentFetchStateById } =
|
|
useListMessageAttachments(attachmentMessageIds)
|
|
|
|
useRecoverMissingMessageAttachments(
|
|
allThreadMessages,
|
|
attachmentsByMessageId,
|
|
attachmentFetchStateById
|
|
)
|
|
|
|
const resolveMessageAttachments = useCallback(
|
|
(msg: Pick<ApiMessageSummary, "id" | "has_attachments">): EmailAttachment[] =>
|
|
resolvePreviewAttachments(
|
|
{ hasAttachment: msg.has_attachments },
|
|
attachmentsByMessageId.get(msg.id),
|
|
attachmentFetchStateById.get(msg.id) ?? "idle"
|
|
),
|
|
[attachmentsByMessageId, attachmentFetchStateById]
|
|
)
|
|
|
|
const mainMessageAttachments = useMemo(
|
|
() => resolveMessageAttachments(email),
|
|
[resolveMessageAttachments, email]
|
|
)
|
|
|
|
const conversationAttachmentEntries = useMemo((): ConversationAttachmentEntry[] => {
|
|
return allThreadMessages
|
|
.map((msg) => {
|
|
const attachments = resolveMessageAttachments(msg)
|
|
if (attachments.length === 0) return null
|
|
const from = resolveMessageFrom(msg.from, {
|
|
selfEmails,
|
|
selfDisplayName,
|
|
})
|
|
return {
|
|
messageId: msg.id,
|
|
senderName: from.name,
|
|
attachments,
|
|
}
|
|
})
|
|
.filter((entry): entry is ConversationAttachmentEntry => entry !== null)
|
|
}, [
|
|
allThreadMessages,
|
|
resolveMessageAttachments,
|
|
selfEmails,
|
|
selfDisplayName,
|
|
])
|
|
|
|
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}
|
|
attachments={resolveMessageAttachments(msg)}
|
|
/>
|
|
</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}
|
|
plainTextFallback={plainTextFallback}
|
|
/>
|
|
|
|
{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}
|
|
attachments={resolveMessageAttachments(msg)}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{otherThreadCount > 0 && conversationAttachmentEntries.length > 0 ? (
|
|
<ConversationAttachmentsSection entries={conversationAttachmentEntries} />
|
|
) : null}
|
|
|
|
{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>
|
|
)
|
|
}
|