ultisuite-client/components/gmail/email-view/message-attachments.tsx
R3D347HR4Y 6ec95262af Add OnlyOffice integration and update project configurations
- Updated .env.example to include configuration for OnlyOffice Document Server.
- Modified the workspace configuration to remove the drive-suite path.
- Adjusted TypeScript environment imports for consistency.
- Enhanced Next.js configuration to disable canvas in Webpack.
- Updated package.json to include new dependencies for OnlyOffice and PDF.js.
- Added global styles for OnlyOffice theme integration in the CSS.
- Created new layout and page components for the Drive feature, including public sharing and editing functionalities.
- Updated metadata handling across various layouts to reflect the new app structure.
2026-06-07 15:49:21 +02:00

441 lines
17 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
Info,
HardDrive,
File,
FileText,
Image as ImageIcon,
ExternalLink,
} from "lucide-react"
import { toast } from "sonner"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { EmailAttachment, EmailAttachmentKind } from "@/lib/email-data"
import {
attachmentPreviewTooltip,
resolveAttachmentKind,
shouldUseAttachmentPillsInPreview,
} from "@/lib/attachment-display"
import { MAIL_TOOLTIP_CONTENT_CLASS } from "@/lib/mail-chrome-classes"
import { MailDriveFolderPicker } from "@/components/mail/mail-drive-folder-picker"
import { useSaveMessageAttachmentsToDrive } from "@/lib/api/hooks/use-mail-drive-save"
import {
mailDriveFileHref,
mailDriveFolderHref,
mailDriveFolderLabel,
mailDriveFolderPathLabel,
mailDriveSaveErrorMessage,
mailDriveSaveSuccessMessage,
} from "@/lib/mail/mail-drive"
import {
mailAttachmentPreviewable,
openMailAttachmentsPreview,
} from "@/lib/mail/mail-attachment-preview"
import {
MailAttachmentPillThumb,
MailAttachmentThumbnail,
} from "@/components/gmail/email-view/mail-attachment-thumbnail"
function MessageAttachmentCard({
attachment,
kind,
}: {
attachment: EmailAttachment
kind: EmailAttachmentKind
}) {
return (
<>
<MailAttachmentThumbnail attachment={attachment} />
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
{kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
{attachment.name}
</span>
</div>
</>
)
}
function DriveLocationBadge({ folderPath }: { folderPath: string }) {
const label = mailDriveFolderPathLabel(folderPath)
return (
<Link
href={mailDriveFolderHref(folderPath)}
className="inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md py-1 pl-1 pr-2 text-sm text-primary hover:bg-accent"
title={folderPath}
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
<span className="min-w-0 truncate">{label}</span>
<ExternalLink className="size-3.5 shrink-0 opacity-70" aria-hidden />
</Link>
)
}
export function MessageAttachmentsSection({
messageId,
attachments,
}: {
messageId: string
attachments: EmailAttachment[]
}) {
const n = attachments.length
const router = useRouter()
const [pickerOpen, setPickerOpen] = useState(false)
const saveAll = useSaveMessageAttachmentsToDrive(messageId)
const savedCount = attachments.filter((a) => a.drivePath).length
const allSaved = n > 0 && savedCount === n
const noneSaved = savedCount === 0
const uniqueSaveFolders = useMemo(() => {
const folders = new Set(
attachments
.map((a) => a.drivePath)
.filter(Boolean)
.map((p) => {
const idx = p!.lastIndexOf("/")
return idx > 0 ? p!.slice(0, idx) : p!
})
)
return [...folders]
}, [attachments])
if (n === 0) return null
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
const asPills = shouldUseAttachmentPillsInPreview(attachments)
const openPreview = (index: number) => {
if (!attachments.some((a) => a.id)) {
toast.message("Pièce jointe non disponible")
return
}
if (!mailAttachmentPreviewable(attachments[index]!)) {
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
return
}
openMailAttachmentsPreview(messageId, attachments, index)
}
const onSaveAll = async (folderPath: string) => {
try {
await saveAll.mutateAsync(folderPath)
setPickerOpen(false)
const folderLabel = mailDriveFolderPathLabel(folderPath)
toast.success(
n === 1
? mailDriveSaveSuccessMessage(folderPath)
: `${n} pièces jointes enregistrées dans ${folderLabel}`,
{
action: {
label: "Ouvrir le dossier",
onClick: () => router.push(mailDriveFolderHref(folderPath)),
},
}
)
} catch (err) {
toast.error(mailDriveSaveErrorMessage(err))
}
}
return (
<>
<MailDriveFolderPicker
open={pickerOpen}
onOpenChange={setPickerOpen}
title={n === 1 ? "Enregistrer dans UltiDrive" : `Enregistrer ${n} pièces jointes`}
description="Choisissez un dossier dans votre Drive."
confirmLabel="Enregistrer ici"
pending={saveAll.isPending}
onConfirm={onSaveAll}
/>
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
{allSaved && uniqueSaveFolders.length === 1 ? (
<DriveLocationBadge folderPath={uniqueSaveFolders[0]!} />
) : allSaved && uniqueSaveFolders.length > 1 ? (
<span className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
<HardDrive className="size-[18px] shrink-0 text-primary" strokeWidth={1.5} aria-hidden />
Enregistré dans UltiDrive ({savedCount}/{n})
</span>
) : (
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent disabled:opacity-50"
aria-label="Ajouter à UltiDrive"
disabled={saveAll.isPending}
onClick={() => setPickerOpen(true)}
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
{noneSaved
? "Ajouter à UltiDrive"
: `Ajouter le reste à UltiDrive (${n - savedCount})`}
</button>
)}
</div>
<div
className={
asPills
? "flex flex-wrap gap-2 pb-1"
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
}
role="list"
aria-label="Pièces jointes"
>
{attachments.map((att, index) => {
const kind = resolveAttachmentKind(att.name, att.kind)
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
const previewable = mailAttachmentPreviewable(att)
if (asPills) {
return (
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPreview(index)}
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MailAttachmentPillThumb attachment={att} />
<span className="min-w-0 truncate font-medium">{att.name}</span>
{att.drivePath ? (
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
{previewable ? "\nCliquer pour prévisualiser" : ""}
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
</TooltipContent>
</Tooltip>
</div>
)
}
return (
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPreview(index)}
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MessageAttachmentCard attachment={att} kind={kind} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
{previewable ? "\nCliquer pour prévisualiser" : ""}
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
</TooltipContent>
</Tooltip>
</div>
)
})}
</div>
</div>
</>
)
}
export type ConversationAttachmentEntry = {
messageId: string
senderName: string
attachments: EmailAttachment[]
}
export function ConversationAttachmentsSection({
entries,
}: {
entries: ConversationAttachmentEntry[]
}) {
const flat = useMemo(() => {
const items: {
messageId: string
senderName: string
attachments: EmailAttachment[]
index: number
attachment: EmailAttachment
}[] = []
for (const entry of entries) {
entry.attachments.forEach((attachment, index) => {
items.push({
messageId: entry.messageId,
senderName: entry.senderName,
attachments: entry.attachments,
index,
attachment,
})
})
}
return items
}, [entries])
const n = flat.length
if (n === 0) return null
const summary =
n === 1
? "Une pièce jointe dans cette conversation"
: `${n} pièces jointes dans cette conversation`
const asPills = shouldUseAttachmentPillsInPreview(flat.map((item) => item.attachment))
const openPreview = (messageId: string, attachments: EmailAttachment[], index: number) => {
if (!attachments.some((a) => a.id)) {
toast.message("Pièce jointe non disponible")
return
}
const att = attachments[index]
if (!att || !mailAttachmentPreviewable(att)) {
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
return
}
openMailAttachmentsPreview(messageId, attachments, index)
}
return (
<div className="mt-2 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
</div>
<div
className={
asPills
? "flex flex-wrap gap-2 pb-1"
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
}
role="list"
aria-label="Pièces jointes de la conversation"
>
{flat.map((item, flatIndex) => {
const kind = resolveAttachmentKind(item.attachment.name, item.attachment.kind)
const previewable = mailAttachmentPreviewable(item.attachment)
const tip = [
item.senderName,
attachmentPreviewTooltip(item.attachment.name, item.attachment.sizeBytes),
previewable ? "Cliquer pour prévisualiser" : "",
item.attachment.drivePath ? mailDriveFolderLabel(item.attachment.drivePath) : "",
]
.filter(Boolean)
.join("\n")
if (asPills) {
return (
<div
key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
className="shrink-0"
role="listitem"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MailAttachmentPillThumb attachment={item.attachment} />
<span className="min-w-0 truncate font-medium">{item.attachment.name}</span>
{item.attachment.drivePath ? (
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
) : null}
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
>
{tip}
</TooltipContent>
</Tooltip>
</div>
)
}
return (
<div
key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
className="shrink-0"
role="listitem"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MessageAttachmentCard attachment={item.attachment} kind={kind} />
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
>
{tip}
</TooltipContent>
</Tooltip>
</div>
)
})}
</div>
</div>
)
}