"use client"; import { useState, type DragEvent, type ReactNode } from "react"; import { useTranslations } from "next-intl"; import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { getAttachmentDisplayName } from "@/lib/attachment-name"; import { cn, formatFileSize } from "@/lib/utils"; import { noMediaSavingProps } from "@/lib/device-frame"; import { attachmentIcon } from "./attachment-chips"; export interface ReaderAttachmentItem { id: string; name: string | null; type: string; size: number; blobId?: string; } /** "bar": tiles under the header. "cards": larger previews under the message. */ export type ReaderAttachmentLayout = "bar" | "cards"; export interface AttachmentDragProps { draggable: boolean; onPointerEnter: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; } /** Past this many tiles the bar shows one row less and a "Show N more" tile. */ export const BAR_COLLAPSE_AT = 6; /** * Splits a file name so the extension can stay visible while the rest is * cut short: "prototype-v3.png" → ["prototype-v3", ".png"]. Names without a * short extension come back whole. */ export function splitFileName(name: string): [stem: string, ext: string] { const dot = name.lastIndexOf("."); if (dot <= 0 || name.length - dot > 8) return [name, ""]; return [name.slice(0, dot), name.slice(dot)]; } interface ReaderAttachmentsProps { attachments: T[]; layout?: ReaderAttachmentLayout; /** Object URLs of image attachments, keyed by attachment id. */ thumbUrls: Record; /** Whether a click on the attachment previews it instead of downloading it. */ opensPreview: (attachment: T) => boolean; onOpen: (attachment: T) => void; onDownload: (attachment: T) => void; onDownloadAll: () => void; downloadingAll: boolean; /** * The account's device policy turned downloads off (Organisation > * Devices): previews stay, every way to save a copy goes. */ downloadsOff?: boolean; /** Set when the Files app can take attachments. */ onSaveToFiles?: (attachment: T) => void; /** Plugin actions for one attachment; renders nothing when no plugin offers any. */ pluginMenu?: (attachment: T) => ReactNode; /** * Wraps one tile, so the viewer can add drag-out and the plugin frames * without this component knowing about blobs or plugins. */ renderItem?: (attachment: T, tile: (drag: AttachmentDragProps) => ReactNode) => ReactNode; className?: string; } const NO_DRAG: AttachmentDragProps = { draggable: false, onPointerEnter: () => {}, onDragStart: () => {}, onDragEnd: () => {}, }; /** * The attachments of an open message: a summary line with "Download all", * then either a bar of equal tiles (preview, name, type, size and a download * button that is always there) or larger preview cards whose actions show * on hover, focus and touch screens. */ export function ReaderAttachments({ attachments, layout = "bar", thumbUrls, opensPreview, onOpen, onDownload, onDownloadAll, downloadingAll, downloadsOff = false, onSaveToFiles, pluginMenu, renderItem, className, }: ReaderAttachmentsProps) { const t = useTranslations("email_viewer"); const tFiles = useTranslations("files"); const tDevice = useTranslations("device_policy"); // Opens nothing: no preview, and downloads are off. const locked = (attachment: T) => downloadsOff && !opensPreview(attachment); const openLabel = (attachment: T, name: string) => opensPreview(attachment) ? t("preview_named", { name }) : locked(attachment) ? tDevice("download_off_named", { name }) : t("download_named", { name }); const [expanded, setExpanded] = useState(false); if (attachments.length === 0) return null; const totalSize = attachments.reduce((sum, a) => sum + (a.size || 0), 0); const collapses = layout === "bar" && attachments.length > BAR_COLLAPSE_AT; const shown = collapses && !expanded ? attachments.slice(0, BAR_COLLAPSE_AT - 1) : attachments; const hidden = collapses && !expanded ? attachments.slice(BAR_COLLAPSE_AT - 1) : []; const tile = (attachment: T, drag: AttachmentDragProps) => { const name = getAttachmentDisplayName(attachment.name, attachment.type); const [stem, ext] = splitFileName(name); const kind = ext ? ext.slice(1).toUpperCase() : ""; const preview = opensPreview(attachment); return (
{/* The button covers the whole tile, so a click on the preview opens the file too. */}
{onSaveToFiles && attachment.blobId && ( onSaveToFiles(attachment)}> )} {pluginMenu?.(attachment)} {!downloadsOff && ( onDownload(attachment)} > )}
); }; const card = (attachment: T, drag: AttachmentDragProps) => { const name = getAttachmentDisplayName(attachment.name, attachment.type); const [stem, ext] = splitFileName(name); const kind = ext ? ext.slice(1).toUpperCase() : ""; const preview = opensPreview(attachment); const url = thumbUrls[attachment.id]; const { icon: Icon, className: iconClass } = attachmentIcon(attachment.type, attachment.name); return (
{/* Over the preview, never over the name. Always shown where there is no hover. */}
{onSaveToFiles && attachment.blobId && ( onSaveToFiles(attachment)}> )} {pluginMenu?.(attachment)} {!downloadsOff && ( onDownload(attachment)}> )}
); }; const draw = layout === "cards" ? card : tile; return (
{downloadsOff && ( {tDevice("downloads_off")} )} {attachments.length > 1 && !downloadsOff && ( )}
{shown.map((attachment) => (
{renderItem ? renderItem(attachment, (drag) => draw(attachment, drag)) : draw(attachment, NO_DRAG)}
))} {hidden.length > 0 && ( )} {collapses && expanded && ( )}
); } /** * The image itself when there is a thumbnail, else the file type's icon over * its extension, in the type's colour. */ function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) { if (url) { return ; } const { icon: Icon, className } = attachmentIcon(attachment.type, attachment.name); return ( ); }