import { DownloadSimpleIcon } from '@phosphor-icons/react' import classNames from 'classnames' import React from 'react' import Bubble from '../_shared/Bubble' import CompactDocumentRow from '../_shared/CompactDocumentRow' import DismissButton from '../_shared/DismissButton' import { filenameFromUrl } from '../_shared/fileMeta' import { triggerDownload } from '../_shared/triggerDownload' import { bubbleVariantForState, type BubbleVariant, type ComposerExtras, type FileItem, type MessageAttachmentBaseProps, type MessageAttachmentState, } from '../types' export interface FileAttachmentSharedProps extends MessageAttachmentBaseProps { /** Source URL of the file (used as the download target). */ src?: string /** Filename — drives the title + the meta line + the download default name. */ filename?: string fileSize?: number /** MIME type — drives the type icon. Defaults to `application/octet-stream`. */ mimeType?: string /** * Override displayed title (defaults to `filename`). Useful when a * sender renames the attachment before sending. */ title?: string /** * Stacked files. Takes precedence over `src` when set. Each item * renders as its own row inside the bubble; clicking a row downloads * that file. Sent + Received only — the composer surface accepts a * single attachment at a time. */ items?: FileItem[] /** * Forwarded to the row trigger. When omitted the click still * downloads the file. Supply this for analytics, or return `false` * to fully intercept the download (e.g. show a confirmation modal, * route to a custom preview, throttle large files). Any other * return value — including `void`/`undefined` — lets the built-in * download proceed. For stacked attachments the `index` argument * identifies the row. */ onClick?: (index: number) => boolean | void } const resolveItems = ({ src, filename, fileSize, mimeType, title, items, }: { src?: string filename?: string fileSize?: number mimeType?: string title?: string items?: FileItem[] }): FileItem[] => { if (items && items.length > 0) return items if (src) return [{ src, filename, fileSize, mimeType, title }] return [] } interface FileRowButtonProps { variant: BubbleVariant item: FileItem index: number onActivate: (index: number) => void trailingAction: React.ReactNode } /** * Single tappable file row — used both for the lone file case and * for each child of a stacked attachment. The icon + filename area * is the click target (downloads the asset); the trailing slot is * decorative on Sent / Received and hosts the dismiss control on * Composer. */ const FileRowButton: React.FC = ({ variant, item, index, onActivate, trailingAction, }) => { const resolvedFilename = item.filename ?? filenameFromUrl(item.src) return ( onActivate(index)} activateLabel={`Download ${resolvedFilename}`} trailingAction={trailingAction} /> ) } interface InternalFileRowProps extends FileAttachmentSharedProps { state: MessageAttachmentState onDismiss?: () => void } const FileAttachmentRow: React.FC = ({ state, src, filename, fileSize, mimeType, title, items, text, groupPosition, onClick, onDismiss, }) => { const variant = bubbleVariantForState(state) const showDismiss = state === 'composer' && !!onDismiss const resolvedItems = resolveItems({ src, filename, fileSize, mimeType, title, items, }) // `useCallback` would be pointless here — `resolvedItems` is rebuilt // every render, so memoizing on it produces a fresh function every // render anyway. Keep this as a plain function. const handleActivate = (index: number) => { // `onClick` returning `false` cancels the built-in download so // consumers can route the click to a confirmation modal, custom // preview, etc. Any other return value (including void) lets the // default download proceed. if (onClick?.(index) === false) return const item = resolvedItems[index] if (!item) return const resolvedFilename = item.filename ?? filenameFromUrl(item.src) void triggerDownload(item.src, resolvedFilename) } if (resolvedItems.length === 0) { return null } const downloadIcon = ( ) return ( {/* Stacked rows get a small vertical gap so each file reads as a discrete attachment rather than one merged blob. Single row falls through to a no-op `gap-0`. */}
{resolvedItems.map((item, index) => { // Composer only supports a single attachment so the dismiss // control sits on the only row; otherwise every row gets // the trailing download icon hint. const trailingAction = showDismiss && index === 0 ? ( ) : ( downloadIcon ) return ( ) })}
) } /** * Composer-only props. Single file (`src`) only — the composer surface * accepts a single attachment at a time, so `items` is not supported. * Captions (`text`) and `groupPosition` are also dropped: the composer * renders a standalone draft, not part of a same-author run, and * captions live in the message-input textarea, not inside the draft * attachment preview. */ export interface FileComposerProps extends ComposerExtras { src?: string filename?: string fileSize?: number mimeType?: string title?: string onClick?: (index: number) => boolean | void } export type FileSentProps = FileAttachmentSharedProps export type FileReceivedProps = FileAttachmentSharedProps const FileComposer: React.FC = (props) => ( ) const FileSent: React.FC = (props) => ( ) const FileReceived: React.FC = (props) => ( ) const FileAttachment = { Composer: FileComposer, Sent: FileSent, Received: FileReceived, } export default FileAttachment