Files
element-web/packages/shared-components/src/message-body/FileBodyView/FileBodyView.tsx
T
rbondesson d791e3fe8a Refactor MFileBody using MVVM and move to shared-components (#32730)
* Refactor MFileBody using MVVM and move to shared component

* Simplyfing rendering properties

* Create a first version of view model for the component

* Simplifying component properties and make it possible to override module css using data-* attributes

* Create a MBodyFactory in element-web and use it to render MFileBodyView from MessageEvent

* Use <MediaBody instead of <button to support legacy rendering

* Updated styling and comments

* Refactoring className from snapshot to component property

* Rename MFileBody* to FileBody*

* Rename MFileBody* to FileBody*

* Refactoring render branches to allow for displaying nothing

* Fix styling issues

* Fix lint errors

* Fix for css selectors in playwright tests

* Remove the MFileBody component and change all callers to use MBodyFactory:FileBodyView

* Remove unused strings in element-web

* Revert to render text in story iframes

* Fix for prettier error

* Fix playwright test css selectors

* Apply legacy styling in element-web

* Add legacy styling for mx_MFileBody

* Restore file

* Change from <div to <button

* Calculate span width ad update screenshots

* Remove width calculation and update snapshots

* Fix for letter-spacing and better content in story

* Updated playwright screenshots

* Updated snapshots

* Fixing Sonar errors/warnings

* Removed extra parentheses

* Changes after review

* Change border-radius to px and updated snapshots

* Fix typo in description

* And another typo fix

* Changes after review
2026-03-16 08:47:23 +00:00

316 lines
11 KiB
TypeScript

/*
* 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<HTMLAnchorElement>) => void;
/**
* Load handler for encrypted-download iframe.
*/
onDownloadIframeLoad?: () => void;
}
/**
* The view model for the control.
*/
export type FileBodyViewModel = ViewModel<FileBodyViewSnapshot> & 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<HTMLIFrameElement>;
/**
* Optional hidden anchor ref used for encrypted download flow
*/
refLink?: React.RefObject<HTMLAnchorElement>;
}
/**
* Create the correct icon to render in the view
*/
function getInfoIcon(infoIcon?: FileBodyViewInfoIcon): ComponentType<React.SVGAttributes<SVGElement>> {
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
* <FileBodyView vm={fileBodyViewModel} />
* ```
*/
export function FileBodyView({ vm, refIFrame, refLink, className }: Readonly<FileBodyViewProps>): 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 ? (
<Tooltip description={resolvedInfoTooltip} placement="right">
<MediaBody data-type="info">
<Button
as="button"
size="sm"
kind="secondary"
aria-label={resolvedInfoLabel}
Icon={resolvedInfoIcon}
onClick={vm.onInfoClick}
>
<span>{resolvedInfoLabel}</span>
</Button>
</MediaBody>
</Tooltip>
) : null;
const classes = classNames(styles.content, className);
const resolvedDownloadLabel = downloadLabel ?? _t("action|download");
const resolvedDownloadTitle = downloadTitle ?? resolvedDownloadLabel;
switch (state) {
case FileBodyViewState.EXPORT:
return (
<span className={classes}>
<a href={infoHref}>{info}</a>
</span>
);
case FileBodyViewState.DECRYPTION_PENDING:
return (
<span className={classes}>
{info}
{showDownload && (
<div data-type="download">
{/* Decrypt/download is triggered by the view model action, not by an anchor `href`. */}
<Button size="sm" kind="secondary" Icon={DownloadIcon} onClick={vm.onDownloadClick}>
{resolvedDownloadLabel}
</Button>
</div>
)}
</span>
);
case FileBodyViewState.ENCRYPTED:
return (
<span className={classes}>
{info}
{showDownload && (
<div data-type="download">
<div aria-hidden style={{ display: "none" }}>
{/*
* 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 */}
<Button size="sm" kind="secondary" Icon={DownloadIcon} as="a" ref={refLink} />
</div>
{/*
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.
*/}
<iframe
aria-hidden
title={resolvedDownloadTitle}
src="usercontent/"
onLoad={vm.onDownloadIframeLoad}
ref={refIFrame}
sandbox="allow-scripts allow-downloads"
/>
</div>
)}
</span>
);
case FileBodyViewState.UNENCRYPTED:
return (
<span className={classes}>
{info}
{showDownload && (
<div data-type="download">
{/* Unencrypted media uses an anchor element with VM-controlled click behavior. */}
<Button
size="sm"
kind="secondary"
Icon={DownloadIcon}
as="a"
href={downloadHref}
target="_blank"
rel="noreferrer noopener"
onClick={vm.onDownloadLinkClick}
>
{resolvedDownloadLabel}
</Button>
</div>
)}
</span>
);
case FileBodyViewState.INVALID:
default:
return (
<>
<span className={classes}>{info}</span>
<span className={classNames(classes, styles.invalid)}>{_t("timeline|m.file|error_invalid")}</span>
</>
);
}
}