Some checks failed
E2E / Playwright e2e (push) Has been cancelled
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.
433 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|