ultisuite-client/components/gmail/email-view.tsx
R3D347HR4Y c87670e90f
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
feat(api): offline-first mail sync w/ TanStack Query
Move mail, compose, contacts, and accounts off mocks onto REST + WS.
Add client, auth store, IDB-backed query cache, offline queue, and
sync bar; hybrid Zustand for UI-only state. Settings still local until
backend has preferences API.
2026-05-23 00:04:28 +02:00

433 lines
14 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { Reply, ReplyAll, Forward } from "lucide-react"
import {
TooltipProvider,
} from "@/components/ui/tooltip"
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 type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { useMessage, useThread } from "@/lib/api/hooks/use-mail-queries"
import {
useToggleStar,
useMarkRead,
useUpdateFlags,
useUpdateLabels,
} from "@/lib/api/hooks/use-mail-mutations"
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"
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: msg.snippet,
body: full?.body_html ?? full?.body_text,
date: msg.date,
read: msg.flags.includes("read"),
starred: msg.flags.includes("starred"),
important: msg.flags.includes("important"),
spam: msg.flags.includes("spam") || msg.labels.includes("spam"),
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 } = useMessage(email.id)
const { data: threadMessages } = useThread(email.thread_id ?? null)
const toggleStar = useToggleStar()
const markRead = useMarkRead()
const updateFlags = useUpdateFlags()
const updateLabels = useUpdateLabels()
const flags = fullMessage?.flags ?? email.flags
const isStarred = flags.includes("starred")
const isSpam = flags.includes("spam") || email.labels.includes("spam")
const initialFlagsRef = useRef(flags)
useEffect(() => {
initialFlagsRef.current = email.flags
}, [email.id, email.flags])
useEffect(() => {
if (!initialFlagsRef.current.includes("read")) {
markRead.mutate({ id: email.id, flags: initialFlagsRef.current })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email.id])
const body =
fullMessage?.body_html ??
fullMessage?.body_text ??
`<p style="color:var(--muted-foreground);">${email.snippet}</p>`
const [showFullThread, setShowFullThread] = useState(false)
const priorMessages = useMemo(() => {
if (!threadMessages) return []
return threadMessages.filter((m) => m.id !== email.id)
}, [threadMessages, email.id])
const priorCount = priorMessages.length
const showRepliesCta =
isSingleMessageView && !showFullThread && priorCount > 0
const conversation =
isSingleMessageView && !showFullThread ? [] : priorMessages
const hasConversation = conversation.length > 0
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.from[0]?.name ?? "")
const mainSenderAddr =
email.from[0]?.address ??
`${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
const legacyEmail = useMemo(
() => apiToLegacyEmail(email, fullMessage, 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 selfIdentity = DEFAULT_IDENTITIES[0]
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 (flags.includes("spam")) {
updateFlags.mutate({
id: email.id,
flags: flags.filter((f) => f !== "spam"),
})
}
if (email.labels.includes("spam")) {
updateLabels.mutate({
id: email.id,
labels: email.labels.filter((l) => l !== "spam"),
})
}
}, [email.id, flags, email.labels, updateFlags, updateLabels])
const handlePrint = useCallback(() => {
openConversationPrint(legacyEmail)
}, [legacyEmail])
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}>
<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"
>
{priorCount === 1
? "Afficher la réponse"
: `Afficher les ${priorCount} réponses`}
</button>
</div>
) : null}
{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.from[0]?.name ?? ""}
senderEmail={msg.from[0]?.address ?? ""}
dateIso={msg.date}
body={msg.body_html ?? msg.body_text ?? ""}
isSpam={false}
isLast={false}
starred={msg.flags.includes("starred")}
onCollapse={() => toggleExpanded(msg.id)}
onPrintConversation={handlePrint}
/>
</div>
)
}
return (
<div key={msg.id} className="border-b border-[#eceff1]">
<CollapsedMessage
message={msg}
onClick={() => toggleExpanded(msg.id)}
/>
</div>
)
})}
<ExpandedMessage
sender={mainSenderName}
senderEmail={mainSenderAddr}
dateIso={email.date}
body={body}
isSpam={isSpam}
isLast={true}
starred={isStarred}
attachments={mainMessageAttachments}
onToggleStar={handleToggleStar}
onPrintConversation={handlePrint}
/>
{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={legacyEmail}
/>
</div>
</div>
</div>
) : null}
</div>
</div>
</TooltipProvider>
)
}