351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
"use client"
|
||
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type CSSProperties,
|
||
} from "react"
|
||
import { Star, Reply, ReplyAll, Forward } from "lucide-react"
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip"
|
||
import { cn } from "@/lib/utils"
|
||
import {
|
||
avatarColor,
|
||
cleanSenderName,
|
||
senderInitial,
|
||
} from "@/lib/sender-display"
|
||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
||
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"
|
||
|
||
interface EmailViewProps {
|
||
email: Email
|
||
onToggleStar: (id: string) => void
|
||
isStarred: boolean
|
||
onNavigateToLabel?: (label: string) => void
|
||
/** Message spam : bannière + pastille sujet ; bouton « non-spam » */
|
||
onNotSpam?: () => void
|
||
/** Si défini, les pastilles libellé dont la fonction retourne false sont masquées (préférences barre latérale). */
|
||
showLabelChip?: (label: string) => boolean
|
||
labelBgByText?: Map<string, string>
|
||
emailLabelToSidebarFolderId?: Record<string, string>
|
||
getNavItemPrefs?: (id: string) => { messages: string }
|
||
folderTree?: FolderTreeNode[]
|
||
labelRows?: readonly LabelRowItem[]
|
||
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
|
||
currentFolderId?: string
|
||
/** Fil complet (mode message isolé hors conversation). */
|
||
threadRoot?: Email | null
|
||
/** Affiche uniquement le message courant avec option d’ouvrir le fil. */
|
||
isSingleMessageView?: boolean
|
||
}
|
||
|
||
/* ── Main EmailView component ── */
|
||
|
||
export function EmailView({
|
||
email,
|
||
onToggleStar,
|
||
isStarred,
|
||
onNavigateToLabel,
|
||
onNotSpam,
|
||
showLabelChip,
|
||
labelBgByText,
|
||
emailLabelToSidebarFolderId = {},
|
||
getNavItemPrefs = () => ({ messages: "show" }),
|
||
folderTree,
|
||
labelRows,
|
||
currentFolderId,
|
||
threadRoot = null,
|
||
isSingleMessageView = false,
|
||
}: EmailViewProps) {
|
||
const [showFullThread, setShowFullThread] = useState(false)
|
||
const threadForReplies = threadRoot ?? email
|
||
const priorCount = Math.max(
|
||
0,
|
||
(threadForReplies.threadMessageIds?.length ?? 1) - 1
|
||
)
|
||
const showRepliesCta =
|
||
isSingleMessageView && !showFullThread && priorCount > 0
|
||
|
||
const conversation =
|
||
isSingleMessageView && !showFullThread
|
||
? []
|
||
: (showFullThread ? threadForReplies.conversation : email.conversation) ?? []
|
||
const hasConversation = conversation.length > 0
|
||
const isSpamMessage = email.spam === true
|
||
|
||
// Track which conversation messages are expanded (by index).
|
||
// By default all previous messages are collapsed, only the last (main) is expanded.
|
||
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.sender)
|
||
const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||
|
||
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 mainMessageAttachments = useMemo((): EmailAttachment[] => {
|
||
if (email.attachments && email.attachments.length > 0) return email.attachments
|
||
if (email.hasAttachment) return [{ name: "Pièce jointe", kind: "other" }]
|
||
return []
|
||
}, [email.attachments, email.hasAttachment])
|
||
|
||
const savedThreadDraft = savedThreadReplyDrafts[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]
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!savedThreadDraft || hasInlineForThread) return
|
||
openThreadCompose(savedThreadDraftToComposePreset(savedThreadDraft))
|
||
}, [
|
||
email.id,
|
||
savedThreadDraft,
|
||
hasInlineForThread,
|
||
openThreadCompose,
|
||
])
|
||
|
||
const startThreadCompose = useCallback(
|
||
(kind: ThreadComposeKind) => {
|
||
openThreadCompose(buildThreadComposePreset(email, kind))
|
||
},
|
||
[email, openThreadCompose]
|
||
)
|
||
|
||
const selfIdentity = DEFAULT_IDENTITIES[0]
|
||
const selfName = cleanSenderName(selfIdentity.name)
|
||
|
||
const calendarInvitation = useMemo(
|
||
() => resolveParsedCalendarInvitation(email),
|
||
[email]
|
||
)
|
||
|
||
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}>
|
||
{/* Spacer for floating nav buttons on xs */}
|
||
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden />
|
||
<EmailViewSubjectHeader
|
||
email={email}
|
||
isSpamMessage={isSpamMessage}
|
||
onNotSpam={onNotSpam}
|
||
onNavigateToLabel={onNavigateToLabel}
|
||
showLabelChip={showLabelChip}
|
||
labelBgByText={labelBgByText}
|
||
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
|
||
getNavItemPrefs={getNavItemPrefs}
|
||
folderTree={folderTree}
|
||
labelRows={labelRows}
|
||
currentFolderId={currentFolderId}
|
||
/>
|
||
|
||
{calendarInvitation ? (
|
||
<CalendarInvitationPreview invitation={calendarInvitation} />
|
||
) : null}
|
||
|
||
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
|
||
|
||
{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}
|
||
|
||
{/* Conversation messages */}
|
||
{/* Previous messages in conversation */}
|
||
{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.sender}
|
||
senderEmail={msg.senderEmail}
|
||
dateIso={msg.date}
|
||
body={msg.body}
|
||
isSpam={false}
|
||
isLast={false}
|
||
starred={false}
|
||
attachments={msg.attachments ?? []}
|
||
onCollapse={() => toggleExpanded(msg.id)}
|
||
onPrintConversation={() => openConversationPrint(email)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div key={msg.id} className="border-b border-[#eceff1]">
|
||
<CollapsedMessage
|
||
message={msg}
|
||
onClick={() => toggleExpanded(msg.id)}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* Last / main message — always expanded */}
|
||
<ExpandedMessage
|
||
sender={mainSenderName}
|
||
senderEmail={mainSenderAddr}
|
||
dateIso={email.date}
|
||
body={email.body || `<p style="color:var(--muted-foreground);">${email.preview}</p>`}
|
||
isSpam={email.spam === true}
|
||
isLast={true}
|
||
starred={isStarred}
|
||
attachments={mainMessageAttachments}
|
||
onToggleStar={() => onToggleStar(email.id)}
|
||
onPrintConversation={() => openConversationPrint(email)}
|
||
/>
|
||
|
||
{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={email}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
</div>
|
||
</div>
|
||
</TooltipProvider>
|
||
)
|
||
}
|