diff --git a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts index 28dd3b922d..ff8e3f89b0 100644 --- a/apps/web/playwright/e2e/audio-player/audio-player.spec.ts +++ b/apps/web/playwright/e2e/audio-player/audio-player.spec.ts @@ -245,9 +245,9 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await expect(tile.getByRole("region", { name: "Audio player" })).toBeVisible(); // Assert that replied audio file is rendered as file button inside ReplyChain - const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']"); + const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody [role='button']"); // Assert that the file button has file name - await expect(button.locator(".mx_MFileBody_info_filename")).toBeVisible(); + await expect(button.locator("span")).toBeVisible(); await takeSnapshots(page, app, "Selected EventTile of audio player with a reply"); }, @@ -307,9 +307,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { // Assert that the file button contains the name of the file sent at first await expect( - replyChain - .locator(".mx_MFileBody_info[role='button']") - .locator(".mx_MFileBody_info_filename", { hasText: "upload-first.ogg" }), + replyChain.locator(".mx_MFileBody [role='button']").locator("span", { hasText: "upload-first.ogg" }), ).toBeVisible(); // Take snapshots @@ -357,9 +355,7 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { const composer = thread.locator(".mx_MessageComposer--compact"); // Assert that the reply preview contains audio ReplyTile the file info button - await expect( - composer.locator(".mx_ReplyPreview .mx_ReplyTile .mx_MFileBody_info[role='button']"), - ).toBeVisible(); + await expect(composer.locator(".mx_ReplyPreview .mx_ReplyTile .mx_MFileBody [role='button']")).toBeVisible(); // Select :smile: emoji and send it await composer.getByTestId("basicmessagecomposer").fill(":smile:"); @@ -367,6 +363,6 @@ test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => { await composer.getByTestId("basicmessagecomposer").press("Enter"); // Assert that the file name is rendered on the file button - await expect(threadTile.locator(".mx_ReplyTile .mx_MFileBody_info[role='button']")).toBeVisible(); + await expect(threadTile.locator(".mx_ReplyTile .mx_MFileBody [role='button']")).toBeVisible(); }); }); diff --git a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts index 69e17eeef1..dd3f3f4409 100644 --- a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts +++ b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts @@ -85,7 +85,7 @@ test.describe("FilePanel", () => { await expect(filePanelMessageList.locator(".mx_EventTile")).toHaveCount(3); // Assert that the download links are rendered - await expect(filePanelMessageList.locator(".mx_MFileBody_download,.mx_MFileBody_info")).toHaveCount(3); + await expect(filePanelMessageList.locator(".mx_MFileBody")).toHaveCount(3); // Assert that the sender of the files is rendered on all of the tiles await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3); @@ -103,9 +103,7 @@ test.describe("FilePanel", () => { // Detect the JSON file // Assert that the tile is rendered as a button - const file = filePanelMessageList.locator( - ".mx_EventTile_mediaLine .mx_MFileBody .mx_MFileBody_info[role='button'] .mx_MFileBody_info_filename", - ); + const file = filePanelMessageList.locator(".mx_EventTile_mediaLine .mx_MFileBody [role='button']"); // Assert that the file name is rendered inside the button with ellipsis await expect(file.getByText(/matrix.*?\.json/)).toBeVisible(); @@ -177,8 +175,7 @@ test.describe("FilePanel", () => { const tile = page.locator(".mx_FilePanel .mx_EventTile"); // Assert that the file size is displayed in kibibytes, not kilobytes (1000 bytes) // See: https://github.com/vector-im/element-web/issues/24866 - await expect(tile.locator(".mx_MFileBody_info_filename", { hasText: size })).toBeVisible(); - await expect(tile.locator(".mx_MFileBody_info", { hasText: size })).toBeVisible(); + await expect(tile.locator(".mx_MFileBody [data-type='info']", { hasText: size })).toBeVisible(); }); }); @@ -194,7 +191,7 @@ test.describe("FilePanel", () => { ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody", ); - const link = imageBody.locator(".mx_MFileBody_download a"); + const link = imageBody.locator(".mx_MFileBody a"); const downloadPromise = page.waitForEvent("download"); diff --git a/apps/web/playwright/e2e/timeline/timeline.spec.ts b/apps/web/playwright/e2e/timeline/timeline.spec.ts index 5553bbd3de..22d5742dfc 100644 --- a/apps/web/playwright/e2e/timeline/timeline.spec.ts +++ b/apps/web/playwright/e2e/timeline/timeline.spec.ts @@ -779,7 +779,7 @@ test.describe("Timeline", () => { // Assert that the file size is displayed in kibibytes (1024 bytes), not kilobytes (1000 bytes) // See: https://github.com/vector-im/element-web/issues/24866 await expect( - page.locator(".mx_EventTile_last .mx_MFileBody_info_filename").getByText(/1.12 KB/), + page.locator(".mx_EventTile_last .mx_MFileBody [data-type='info']").getByText(/1.12 KB/), ).toBeVisible(); }); diff --git a/apps/web/playwright/e2e/widgets/stickers.spec.ts b/apps/web/playwright/e2e/widgets/stickers.spec.ts index e7b86e411d..79ae4cca0d 100644 --- a/apps/web/playwright/e2e/widgets/stickers.spec.ts +++ b/apps/web/playwright/e2e/widgets/stickers.spec.ts @@ -103,7 +103,7 @@ async function expectTimelineSticker(page: Page, serverName: string, roomId: str } async function expectFileTile(page: Page, roomId: string, contentUri: string) { - await expect(page.locator(".mx_MFileBody_info_filename")).toContainText(STICKER_NAME); + await expect(page.locator(".mx_MFileBody [data-type='info']")).toContainText(STICKER_NAME); } async function setWidgetAccountData( diff --git a/apps/web/res/css/structures/_FilePanel.pcss b/apps/web/res/css/structures/_FilePanel.pcss index fff90832da..615e2a32ae 100644 --- a/apps/web/res/css/structures/_FilePanel.pcss +++ b/apps/web/res/css/structures/_FilePanel.pcss @@ -41,7 +41,7 @@ Please see LICENSE files in the repository root for full details. padding-inline-start: 0; } - .mx_MFileBody_download { + .mx_MFileBody [data-type="download"] { margin-top: var(--cpd-space-4x); } diff --git a/apps/web/res/css/views/dialogs/_ForwardDialog.pcss b/apps/web/res/css/views/dialogs/_ForwardDialog.pcss index b155bd1f4a..150ad4cbc9 100644 --- a/apps/web/res/css/views/dialogs/_ForwardDialog.pcss +++ b/apps/web/res/css/views/dialogs/_ForwardDialog.pcss @@ -41,7 +41,7 @@ Please see LICENSE files in the repository root for full details. /* We also hide download links to not encourage users to try interacting */ .mx_EventTile_msgOption, .mx_EventTile_e2eIcon, - .mx_MFileBody_download { + .mx_MFileBody [data-type="download"] { display: none; } } diff --git a/apps/web/res/css/views/messages/_MFileBody.pcss b/apps/web/res/css/views/messages/_MFileBody.pcss index 6293270c68..1f8ab80576 100644 --- a/apps/web/res/css/views/messages/_MFileBody.pcss +++ b/apps/web/res/css/views/messages/_MFileBody.pcss @@ -6,56 +6,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -.mx_MFileBody_download { +.mx_MFileBody [data-type="download"] { color: $accent; height: var(--cpd-space-9x); -} -.mx_MFileBody_download object { - margin-left: -16px; - padding-right: 4px; - margin-top: -4px; - vertical-align: middle; - pointer-events: none; -} - -/* Remove the border and padding for iframes for download links. */ -.mx_MFileBody_download iframe { - margin: 0px; - padding: 0px; - border: none; - width: 100%; -} - -.mx_MFileBody_info { - cursor: pointer; - - .mx_MFileBody_info_icon { - background-color: $system; - border-radius: 20px; - display: inline-block; - width: 16px; - height: 16px; - padding: var(--cpd-space-2x); - vertical-align: middle; - margin-right: 12px; - - svg { - color: $secondary-content; - width: inherit; - height: inherit; - display: block; - } - } - - .mx_MFileBody_info_filename { - font: var(--cpd-font-body-md-regular); - color: var(--cpd-color-text-primary); - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - display: inline-block; - width: calc(100% - 32px - 12px); /* 32px icon, 12px margin on the icon */ + & object { + margin-left: -16px; + padding-right: 4px; + margin-top: -4px; vertical-align: middle; + pointer-events: none; + } +} + +.mx_MFileBody [data-type="info"] { + svg { + color: $secondary-content !important; + background-color: $system !important; + border-radius: 20px !important; } } diff --git a/apps/web/src/components/views/messages/MAudioBody.tsx b/apps/web/src/components/views/messages/MAudioBody.tsx index 967e0d5fc7..52630959b0 100644 --- a/apps/web/src/components/views/messages/MAudioBody.tsx +++ b/apps/web/src/components/views/messages/MAudioBody.tsx @@ -15,12 +15,12 @@ import { AudioPlayerView, useCreateAutoDisposedViewModel } from "@element-hq/web import { type Playback } from "../../../audio/Playback"; import InlineSpinner from "../elements/InlineSpinner"; import { _t } from "../../../languageHandler"; -import MFileBody from "./MFileBody"; import { type IBodyProps } from "./IBodyProps"; import { PlaybackManager } from "../../../audio/PlaybackManager"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import MediaProcessingError from "./shared/MediaProcessingError"; import { AudioPlayerViewModel } from "../../../viewmodels/audio/AudioPlayerViewModel"; +import { FileBodyViewFactory, renderMBody } from "./MBodyFactory"; interface IState { error?: boolean; @@ -111,7 +111,7 @@ export default class MAudioBody extends React.PureComponent return ( - {this.showFileBody && } + {this.showFileBody && renderMBody({ ...this.props, showFileInfo: false }, FileBodyViewFactory)} ); } diff --git a/apps/web/src/components/views/messages/MBodyFactory.tsx b/apps/web/src/components/views/messages/MBodyFactory.tsx new file mode 100644 index 0000000000..7be9181fda --- /dev/null +++ b/apps/web/src/components/views/messages/MBodyFactory.tsx @@ -0,0 +1,83 @@ +/* +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 JSX, type RefObject, useContext, useEffect, useRef } from "react"; +import { MsgType } from "matrix-js-sdk/src/matrix"; +import { FileBodyView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import { type IBodyProps } from "./IBodyProps"; +import RoomContext from "../../../contexts/RoomContext"; +import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel"; + +interface FileBodyViewProps { + /* + * Whether file-style message bodies should render their info row/placeholder. + * Used by file-body rendering paths (for example FileBodyViewModel via MBodyFactory). + */ + showFileInfo?: boolean; +} + +type MBodyComponent = React.ComponentType; + +// Adapter that binds RoomContext data and lifecycle updates to the +// FileBody view model before rendering the shared view component. +function FileBodyViewWrapped({ + mxEvent, + mediaEventHelper, + forExport, + showFileInfo, +}: IBodyProps & FileBodyViewProps): JSX.Element { + const { timelineRenderingType } = useContext(RoomContext); + const refIFrame = useRef(null) as RefObject; + const refLink = useRef(null) as RefObject; + + const vm = useCreateAutoDisposedViewModel( + () => + new FileBodyViewModel({ + mxEvent, + mediaEventHelper, + forExport, + showFileInfo, + timelineRenderingType, + refIFrame, + refLink, + }), + ); + + useEffect(() => { + vm.setProps({ + mxEvent, + mediaEventHelper, + forExport, + showFileInfo, + timelineRenderingType, + }); + }, [mxEvent, mediaEventHelper, forExport, showFileInfo, timelineRenderingType, vm]); + + return ; +} + +// Exported for explicit fallback usage where callers want file-body rendering. +export const FileBodyViewFactory: MBodyComponent = (props) => ; + +// Message body factory registry. +// Start small: only m.file currently routes to the new FileBodyView path. +const MESSAGE_BODY_TYPES = new Map([[MsgType.File, FileBodyViewFactory]]); + +// Render a body using the picked factory. +// Falls back to the provided factory when msgtype has no specific handler. +export function renderMBody( + props: IBodyProps & FileBodyViewProps, + fallbackFactory?: MBodyComponent, +): JSX.Element | null { + const BodyType = MESSAGE_BODY_TYPES.get(props.mxEvent.getContent().msgtype as string) ?? fallbackFactory; + if (!BodyType) { + return null; + } + + return ; +} diff --git a/apps/web/src/components/views/messages/MFileBody.tsx b/apps/web/src/components/views/messages/MFileBody.tsx deleted file mode 100644 index b6efbed1cf..0000000000 --- a/apps/web/src/components/views/messages/MFileBody.tsx +++ /dev/null @@ -1,347 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015-2021 The Matrix.org Foundation C.I.C. - -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 AllHTMLAttributes, createRef } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; -import { type MediaEventContent } from "matrix-js-sdk/src/types"; -import { MsgType } from "matrix-js-sdk/src/matrix"; -import { Button } from "@vector-im/compound-web"; -import { - AttachmentIcon, - DownloadIcon, - VideoCallSolidIcon, - VolumeOnSolidIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; -import Modal from "../../../Modal"; -import AccessibleButton from "../elements/AccessibleButton"; -import { mediaFromContent } from "../../../customisations/Media"; -import ErrorDialog from "../dialogs/ErrorDialog"; -import { downloadLabelForFile, presentableTextForFile } from "../../../utils/FileUtils"; -import { type IBodyProps } from "./IBodyProps"; -import { FileDownloader } from "../../../utils/FileDownloader"; -import TextWithTooltip from "../elements/TextWithTooltip"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; - -export let DOWNLOAD_ICON_URL: string; // cached copy of the download.svg asset for the sandboxed iframe later on - -async function cacheDownloadIcon(): Promise { - if (DOWNLOAD_ICON_URL) return; // cached already - // eslint-disable-next-line @typescript-eslint/no-require-imports - const svg = await fetch(require("@vector-im/compound-design-tokens/icons/download.svg").default).then((r) => - r.text(), - ); - DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg); -} - -// Cache the asset immediately -// noinspection JSIgnoredPromiseFromCall -cacheDownloadIcon(); - -// User supplied content can contain scripts, we have to be careful that -// we don't accidentally run those script within the same origin as the -// client. Otherwise those scripts written by remote users can read -// the access token and end-to-end keys that are in local storage. -// -// For attachments downloaded directly from the homeserver we can use -// Content-Security-Policy headers to disable script execution. -// -// But attachments with end-to-end encryption are more difficult to handle. -// We need to decrypt the attachment on the client and then display it. -// To display the attachment we need to turn the decrypted bytes into a URL. -// -// There are two ways to turn bytes into URLs, data URL and blob URLs. -// Data URLs aren't suitable for downloading a file because Chrome has a -// 2MB limit on the size of URLs that can be viewed in the browser or -// downloaded. This limit does not seem to apply when the url is used as -// the source attribute of an image tag. -// -// Blob URLs are generated using window.URL.createObjectURL and unfortunately -// for our purposes they inherit the origin of the page that created them. -// This means that any scripts that run when the URL is viewed will be able -// to access local storage. -// -// The easiest solution is to host the code that generates the blob URL on -// a different domain to the client. -// Another possibility is to generate the blob URL within a sandboxed iframe. -// The downside of using a second domain is that it complicates hosting, -// the downside of using a sandboxed iframe is that the browers are overly -// restrictive in what you are allowed to do with the generated URL. - -/** - * Get the current CSS style for a DOMElement. - * @param {HTMLElement} element The element to get the current style of. - * @return {string} The CSS style encoded as a string. - */ -export function computedStyle(element: HTMLElement | null): string { - if (!element) { - return ""; - } - const style = window.getComputedStyle(element, null); - let cssText = style.cssText; - // noinspection EqualityComparisonWithCoercionJS - if (cssText == "") { - // Firefox doesn't implement ".cssText" for computed styles. - // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 - for (const rule of style) { - cssText += rule + ":"; - cssText += style.getPropertyValue(rule) + ";"; - } - } - return cssText; -} - -interface IProps extends IBodyProps { - /* whether or not to show the default placeholder for the file. Defaults to true. */ - showGenericPlaceholder?: boolean; -} - -interface IState { - decryptedBlob?: Blob; -} - -export default class MFileBody extends React.Component { - public static contextType = RoomContext; - declare public context: React.ContextType; - - public state: IState = {}; - private iframe = createRef(); - private dummyLink = createRef(); - private userDidClick = false; - private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current); - - private getContentUrl(): string | null { - if (this.props.forExport) return null; - const media = mediaFromContent(this.props.mxEvent.getContent()); - return media.srcHttp; - } - private get content(): MediaEventContent { - return this.props.mxEvent.getContent(); - } - - private get fileName(): string { - return this.props.mediaEventHelper?.fileName || _t("common|attachment"); - } - - private get linkText(): string { - return downloadLabelForFile(this.content, true); - } - - private downloadFile(fileName: string, text: string): void { - if (!this.state.decryptedBlob) return; - this.fileDownloader.download({ - blob: this.state.decryptedBlob, - name: fileName, - autoDownload: this.userDidClick, - opts: { - imgSrc: DOWNLOAD_ICON_URL, - imgStyle: null, - style: computedStyle(this.dummyLink.current), - textContent: text, - }, - }); - } - - private decryptFile = async (): Promise => { - if (this.state.decryptedBlob) { - return; - } - try { - this.userDidClick = true; - this.setState({ - decryptedBlob: await this.props.mediaEventHelper!.sourceBlob.value, - }); - } catch (err) { - logger.warn("Unable to decrypt attachment: ", err); - Modal.createDialog(ErrorDialog, { - title: _t("common|error"), - description: _t("timeline|m.file|error_decrypting"), - }); - } - }; - - private onPlaceholderClick = async (): Promise => { - const mediaHelper = this.props.mediaEventHelper; - if (mediaHelper?.media.isEncrypted) { - await this.decryptFile(); - this.downloadFile(this.fileName, this.linkText); - } else { - // As a button we're missing the `download` attribute for styling reasons, so - // download with the file downloader. - this.fileDownloader.download({ - blob: await mediaHelper!.sourceBlob.value, - name: this.fileName, - }); - } - }; - - public render(): React.ReactNode { - const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted; - const contentUrl = this.getContentUrl(); - const fileType = this.content.info?.mimetype ?? "application/octet-stream"; - // defaultProps breaks types on IBodyProps, so instead define the default here. - const showGenericPlaceholder = this.props.showGenericPlaceholder ?? true; - - let showDownloadLink = - !showGenericPlaceholder || - (this.context.timelineRenderingType !== TimelineRenderingType.Room && - this.context.timelineRenderingType !== TimelineRenderingType.Search && - this.context.timelineRenderingType !== TimelineRenderingType.Pinned); - - let placeholder: React.ReactNode = null; - if (showGenericPlaceholder) { - let icon = ; - // MFileBody is not generally used for Audio/Video but can be as part of ReplyTile - if (this.content.msgtype === MsgType.Audio) { - icon = ; - } else if (this.content.msgtype === MsgType.Video) { - icon = ; - } - - placeholder = ( - - {icon} - - - {presentableTextForFile(this.content, _t("common|attachment"), true, true)} - - - - ); - showDownloadLink = false; - } - - if (this.props.forExport) { - const content = this.props.mxEvent.getContent(); - // During export, the content url will point to the MSC, which will later point to a local url - return ( - - {placeholder} - - ); - } - - if (this.context.timelineRenderingType === TimelineRenderingType.Thread) { - showDownloadLink = false; - } - - if (isEncrypted) { - if (!this.state.decryptedBlob) { - // Need to decrypt the attachment - // Wait for the user to click on the link before downloading - // and decrypting the attachment. - - // This button should actually Download because usercontent/ will try to click itself - // but it is not guaranteed between various browsers' settings. - return ( - - {placeholder} - {showDownloadLink && ( -
- -
- )} -
- ); - } - - const url = "usercontent/"; // XXX: this path should probably be passed from the skin - - // If the attachment is encrypted then put the link inside an iframe. - return ( - - {placeholder} - {showDownloadLink && ( -
-
- {/* - * 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. - */} -