ultisuite-client/components/gmail/email-view/email-view-messages.tsx
R3D347HR4Y efaaf16f60
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: update metadata and layout for new product pages
- Refactored metadata for contacts, administration, and Ulticards pages to utilize dynamic app names and descriptions.
- Introduced new product pages for Ultiai, Ultical, Ulticards, Ultidrive, Ultimail, and Ultimeet with appropriate metadata.
- Enhanced layout components to ensure consistent styling and functionality across new product sections.
- Updated various components to replace hardcoded labels with dynamic references to improve maintainability and consistency.
2026-06-19 22:11:42 +02:00

375 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useMemo, useState } from "react"
import { Star, Info, Paperclip } from "lucide-react"
import { useMessage } from "@/lib/api/hooks/use-mail-queries"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { mailFlagIsStarred, messageIsSpam } from "@/lib/mail-flags"
import {
avatarColor,
cleanSenderName,
senderInitial,
} from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text"
import type { ApiMessageFull, Recipient } from "@/lib/api/types"
import { resolveMessageFrom } from "@/lib/mail-message-participants"
import {
buildMessageHeaderDetails,
type MessageHeaderDetails,
} from "@/lib/mail-message-header-details"
import type { EmailAttachment } from "@/lib/email-data"
import { repairMimeBodies, repairSnippet } from "@/lib/mail-mime-body"
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
import { MessageBodyContent } from "@/components/gmail/email-view/message-body-content"
import { MessageAttachmentsSection } from "@/components/gmail/email-view/message-attachments"
import {
MAIL_MESSAGE_HOVER_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
import { plainTextToDisplayHtml } from "@/lib/mail-plain-text-html"
export function plainTextBodyFallback(
full: { body_text?: string; body_html?: string } | null | undefined
): string | undefined {
const { bodyText } = repairMimeBodies(full?.body_text, full?.body_html)
const t = bodyText?.trim()
return t || undefined
}
export function formatApiMessageBody(
full: { body_html?: string; body_text?: string } | null | undefined,
snippet: string | undefined,
loading: boolean
): string {
const preview = repairSnippet(snippet) ?? snippet
const snippetHtml = preview?.trim()
? `<p style="color:var(--muted-foreground);">${preview.trim()}</p>`
: ""
if (loading) {
return snippetHtml
}
const repaired = repairMimeBodies(full?.body_text, full?.body_html)
const html = repaired.bodyHtml?.trim()
if (html) return html
const text = repaired.bodyText?.trim()
if (text) {
return plainTextToDisplayHtml(text)
}
if (full) {
const s = preview?.trim()
if (s) {
return `<p style="color:var(--muted-foreground);">${s}</p>`
}
return `<p style="color:var(--muted-foreground);">Ce message na pas de contenu.</p>`
}
const s = preview?.trim()
return s
? `<p style="color:var(--muted-foreground);">${s}</p>`
: ""
}
/** Prior message in a thread: loads full body on expand via GET /mail/messages/:id. */
export function ThreadPriorMessage({
message,
isExpanded,
onToggle,
onPrintConversation,
onReply,
onForward,
selfEmails,
selfDisplayName,
collapseQuotedReplies = false,
attachments = [],
}: {
message: ApiMessageFull
isExpanded: boolean
onToggle: () => void
onPrintConversation?: () => void
onReply?: () => void
onForward?: () => void
selfEmails: string[]
selfDisplayName?: string
collapseQuotedReplies?: boolean
attachments?: EmailAttachment[]
}) {
const [detailsOpen, setDetailsOpen] = useState(false)
const loadFull = isExpanded || detailsOpen
const { data: fullMessage, isPending } = useMessage(loadFull ? message.id : null)
const merged = fullMessage ?? message
const resolved = useMemo(
() =>
resolveMessageFrom(merged.from, { selfEmails, selfDisplayName }),
[merged.from, selfEmails, selfDisplayName]
)
const headerDetails = useMemo(
() =>
buildMessageHeaderDetails(merged, {
selfEmails,
selfDisplayName,
subject: message.subject,
}),
[merged, selfEmails, selfDisplayName, message.subject]
)
const body = useMemo(
() =>
formatApiMessageBody(
fullMessage,
message.snippet,
isExpanded && isPending && !fullMessage
),
[fullMessage, message.snippet, isExpanded, isPending]
)
const plainTextFallback = useMemo(
() => plainTextBodyFallback(fullMessage),
[fullMessage]
)
const isSpam = messageIsSpam(merged.flags, merged.labels)
if (!isExpanded) {
return (
<CollapsedMessage
message={message}
senderName={resolved.name}
senderEmail={resolved.email}
attachmentCount={
attachments.length > 0
? attachments.length
: message.has_attachments
? 1
: 0
}
onClick={onToggle}
/>
)
}
return (
<ExpandedMessage
sender={resolved.name}
senderEmail={resolved.email}
headerDetails={headerDetails}
dateIso={message.date}
body={body}
isSpam={isSpam}
isLast={false}
starred={mailFlagIsStarred(message.flags ?? [])}
attachments={attachments}
onCollapse={onToggle}
onPrintConversation={onPrintConversation}
onReply={onReply}
onForward={onForward}
detailsOpen={detailsOpen}
onDetailsOpenChange={setDetailsOpen}
collapseQuotedReplies={collapseQuotedReplies}
messageId={message.id}
plainTextFallback={plainTextFallback}
/>
)
}
export function CollapsedMessage({
message,
senderName: senderNameProp,
senderEmail: senderEmailProp,
attachmentCount = 0,
onClick,
}: {
message: ApiMessageFull
senderName?: string
senderEmail?: string
attachmentCount?: number
onClick: () => void
}) {
const senderName = senderNameProp ?? message.from[0]?.name ?? ""
const senderAddr = senderEmailProp ?? message.from[0]?.address ?? ""
const name = cleanSenderName(senderName || senderAddr)
const color = avatarColor(name)
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onClick()
}
}}
className={cn("group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors", MAIL_MESSAGE_HOVER_CLASS)}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="flex min-w-0 items-center justify-between gap-2">
<ContactHoverCard displayName={senderName} email={senderAddr} className="min-w-0">
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard>
<div className="flex shrink-0 items-start gap-1">
{attachmentCount > 0 ? (
<span
className="flex items-center gap-0.5 text-xs text-muted-foreground"
title={
attachmentCount === 1
? "Une pièce jointe"
: `${attachmentCount} pièces jointes`
}
>
<Paperclip className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
{attachmentCount > 1 ? <span>{attachmentCount}</span> : null}
</span>
) : null}
<MailDateText
iso={message.date}
variant="preview"
className="text-xs text-muted-foreground"
/>
<Star
strokeWidth={1.25}
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
/>
</div>
</div>
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">
{repairSnippet(message.snippet) ?? message.snippet}
</p>
</div>
</div>
)
}
export function ExpandedMessage({
sender,
senderEmail,
headerDetails,
dateIso,
body,
isSpam,
isLast,
starred,
attachments = [],
onToggleStar,
onCollapse,
onPrintConversation,
onReply,
onForward,
detailsOpen,
onDetailsOpenChange,
collapseQuotedReplies = false,
messageId,
plainTextFallback,
}: {
sender: string
senderEmail: string
headerDetails: MessageHeaderDetails
messageId: string
dateIso: string
body: string
isSpam: boolean
isLast: boolean
starred: boolean
attachments?: EmailAttachment[]
onToggleStar?: () => void
onCollapse?: () => void
onPrintConversation?: () => void
onReply?: () => void
onForward?: () => void
detailsOpen?: boolean
onDetailsOpenChange?: (open: boolean) => void
collapseQuotedReplies?: boolean
plainTextFallback?: string
}) {
return (
<div>
<EmailViewMessageToolbar
sender={sender}
senderEmail={senderEmail}
headerDetails={headerDetails}
dateIso={dateIso}
isSpam={isSpam}
isLast={isLast}
starred={starred}
onToggleStar={onToggleStar}
onCollapse={onCollapse}
onPrintConversation={onPrintConversation}
onReply={onReply}
onForward={onForward}
detailsOpen={detailsOpen}
onDetailsOpenChange={onDetailsOpenChange}
messageId={messageId}
/>
<div
className={cn(
"px-4 pl-[68px] max-sm:pl-4 max-sm:pr-4",
attachments.length > 0 ? "pb-0" : "pb-4"
)}
data-selectable-text
>
<MessageBodyContent
html={body}
isSpam={isSpam}
senderEmail={senderEmail}
messageId={messageId}
collapseQuotedReplies={collapseQuotedReplies}
plainTextFallback={plainTextFallback}
/>
</div>
{attachments.length > 0 && (
<MessageAttachmentsSection messageId={messageId} attachments={attachments} />
)}
</div>
)
}
export function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
return (
<div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted px-4 py-3.5 max-sm:mx-4">
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm leading-snug text-foreground/80">
<span className="font-medium text-foreground">Pourquoi ce message est-il dans le spam ?</span>{" "}
Ce message est semblable à des messages identifiés comme spam par le passé.
</p>
{onNotSpam && (
<button
type="button"
onClick={onNotSpam}
className="rounded-md border border-border bg-mail-surface px-4 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-accent"
>
Signaler comme non-spam
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="En savoir plus sur le filtre anti-spam"
>
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="left" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
Les filtres peuvent se tromper. Si le message est légitime, signalez-le comme non-spam pour
l&apos;améliorer.
</TooltipContent>
</Tooltip>
</div>
)
}