/* * Copyright 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial * Please see LICENSE files in the repository root for full details. */ import React, { type ComponentType, type JSX, type MouseEvent } from "react"; import classNames from "classnames"; import { Button, Tooltip } from "@vector-im/compound-web"; import { AttachmentIcon, DownloadIcon, VideoCallSolidIcon, VolumeOnSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./FileBodyView.module.css"; import { type ViewModel } from "../../viewmodel/ViewModel"; import { useViewModel } from "../../viewmodel/useViewModel"; import { useI18n } from "../../utils/i18nContext"; import { MediaBody } from "../MediaBody"; /** * Which visual state to render for the component. */ export enum FileBodyViewState { /** Export-only rendering where the info row links to the source file. */ EXPORT = "EXPORT", /** Encrypted file before decryption has completed; shows the button that starts the flow. */ DECRYPTION_PENDING = "DECRYPTION_PENDING", /** Encrypted file after decryption; renders the sandboxed iframe download path. */ ENCRYPTED = "ENCRYPTED", /** Unencrypted file with a direct download link. */ UNENCRYPTED = "UNENCRYPTED", /** Fallback for missing or unusable file metadata. */ INVALID = "INVALID", } /** * Which info icon to render. */ export enum FileBodyViewInfoIcon { ATTACHMENT = "ATTACHMENT", AUDIO = "AUDIO", DOWNLOAD = "DOWNLOAD", VIDEO = "VIDEO", } export interface FileBodyViewSnapshot { /** * Primary rendering branch for the component. * Controls the overall state (export, encrypted, unencrypted, invalid). */ state: FileBodyViewState; /** * Whether to render the info row (icon + label + tooltip). */ showInfo?: boolean; /** * Optional info label (normally the file name). Defaults to 'Attachment'. */ infoLabel?: string; /** * Optional tooltip for info button. Defaults to infoLabel. */ infoTooltip?: string; /** * Optional icon. Defaults to `ATTACHMENT`. */ infoIcon?: FileBodyViewInfoIcon; /** * Optional URL used by the info row in `EXPORT`. */ infoHref?: string; /** * Whether to render download controls for the current rendering branch. */ showDownload?: boolean; /** * Optional download label (normally file/action text). Defaults to 'Download'. */ downloadLabel?: string; /** * Optional title for encrypted iframe download flow. * Defaults to `downloadLabel`. */ downloadTitle?: string; /** * Optional URL used for `UNENCRYPTED` download links. */ downloadHref?: string; } export interface FileBodyViewActions { /** * Click handler for the info row. */ onInfoClick?: () => void; /** * Click handler for a download button. */ onDownloadClick?: () => void; /** * Click handler for the unencrypted download anchor. */ onDownloadLinkClick?: (event: MouseEvent) => void; /** * Load handler for encrypted-download iframe. */ onDownloadIframeLoad?: () => void; } /** * The view model for the control. */ export type FileBodyViewModel = ViewModel & FileBodyViewActions; interface FileBodyViewProps { /** * The view model for the component. */ vm: FileBodyViewModel; /** * Optional CSS class for host-level styling. */ className?: string; /** * Optional iframe ref for encrypted download flow */ refIFrame?: React.RefObject; /** * Optional hidden anchor ref used for encrypted download flow */ refLink?: React.RefObject; } /** * Create the correct icon to render in the view */ function getInfoIcon(infoIcon?: FileBodyViewInfoIcon): ComponentType> { if (infoIcon === FileBodyViewInfoIcon.AUDIO) { return VolumeOnSolidIcon; } else if (infoIcon === FileBodyViewInfoIcon.DOWNLOAD) { return DownloadIcon; } else if (infoIcon === FileBodyViewInfoIcon.VIDEO) { return VideoCallSolidIcon; } return AttachmentIcon; } /** * Renders the body of a file message for info, export, and download flows. * * Rendering is selected by `snapshot.state` from the view model and supports: * - export link (`EXPORT`) * - encrypted download flows (`DECRYPTION_PENDING`, `ENCRYPTED`) * - unencrypted download flow (`UNENCRYPTED`) * - invalid-file fallback (`INVALID`) * * Visibility/content for the info row and download controls are driven by snapshot fields: * - `showInfo`, `infoLabel`, `infoTooltip`, `infoIcon`, `infoHref` * - `showDownload`, `downloadLabel`, `downloadTitle`, `downloadHref` * * Common usage patterns: * - info-only display: set `showInfo: true`, `showDownload: false` * - export link (`EXPORT`) * - download-only display: set `showInfo: false`, `showDownload: true` * * Note on using the encrypted iframe, `ENCRYPTED`: * To make this rendering branch work, it is expected that a `usercontent/` target * is available relative to the root of the application as is described in detail here, * https://github.com/element-hq/element-web/blob/develop/docs/usercontent.md * * @example * ```tsx * * ``` */ export function FileBodyView({ vm, refIFrame, refLink, className }: Readonly): JSX.Element { const { translate: _t } = useI18n(); const { state, showInfo, infoLabel, infoTooltip, infoIcon, infoHref, showDownload, downloadLabel, downloadTitle, downloadHref, } = useViewModel(vm); const resolvedInfoLabel = infoLabel ?? _t("common|attachment"); const resolvedInfoTooltip = infoTooltip ?? resolvedInfoLabel; const resolvedInfoIcon = getInfoIcon(infoIcon); const info = showInfo ? ( ) : null; const classes = classNames(styles.content, className); const resolvedDownloadLabel = downloadLabel ?? _t("action|download"); const resolvedDownloadTitle = downloadTitle ?? resolvedDownloadLabel; switch (state) { case FileBodyViewState.EXPORT: return ( {info} ); case FileBodyViewState.DECRYPTION_PENDING: return ( {info} {showDownload && (
{/* Decrypt/download is triggered by the view model action, not by an anchor `href`. */}
)}
); case FileBodyViewState.ENCRYPTED: return ( {info} {showDownload && (
{/* * Add dummy copy of the button * We'll use it to learn how the download button * would have been styled if it was rendered inline. * this violates multiple eslint rules so ignore it completely */}
{/* TODO: Move iframe (and dummy link) into FileDownloader. We currently have it set up this way because of styles applied to the iframe itself which cannot be easily handled/overridden by the FileDownloader. In future, the download link may disappear entirely at which point it could also be suitable to just remove this bit of code. */}