mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-17 02:16:16 +02:00
Phase 2 Refactor MImageBody to MVVM and remove legacy component (#33212)
* MVVMing of MImageBody and removing legacy component + css * Fix Prettier * update small image to large image in test * Update test * Preserve MImageBody legacy class names * Click image in custom component download test * Update snapshots * Update MBodyFactory snapshots * Added new tests to pass coverage * Fix prettier * Remove legacy import that was removed * Add MImageReplayBody test for coverage * Remove legacy MImageBody selectors from image view * Update image body selectors in Playwright tests * Keep file panel image body spacing compact * Update apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * added documentation to component * Fix hidden media placeholder import --------- Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
parent
13dd1a0b5e
commit
1e7c9f672a
@ -208,7 +208,7 @@ test.describe("Composer", () => {
|
||||
});
|
||||
await app.viewRoomByName("Bob");
|
||||
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
|
||||
await expect(page.locator(".mx_MImageBody")).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -198,7 +198,7 @@ test.describe("Composer", () => {
|
||||
|
||||
test("can paste a file", async ({ page, bot, app }) => {
|
||||
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
|
||||
await expect(page.locator(".mx_MImageBody")).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can paste a file in a thread", async ({ page, app }) => {
|
||||
@ -213,7 +213,7 @@ test.describe("Composer", () => {
|
||||
await tile.getByRole("button", { name: "Reply in thread" }).click();
|
||||
|
||||
await app.composerDragAndPasteFile("thread", getSampleFilePath("riot.png"), "image/png");
|
||||
await expect(page.locator(".mx_MImageBody")).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("when Control+Enter is required to send", () => {
|
||||
|
||||
@ -37,7 +37,7 @@ test.describe("Image Upload", () => {
|
||||
test("should allow upload via drag and drop", { tag: "@screenshot" }, async ({ page, app }) => {
|
||||
await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png");
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -105,12 +105,16 @@ test.describe("Custom Component API", () => {
|
||||
});
|
||||
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
const image = imgTile.getByRole("img", { name: "bad.png" });
|
||||
await expect(image).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
|
||||
await imgTile.click();
|
||||
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
|
||||
await image.click();
|
||||
const imageView = page.getByLabel("Image view");
|
||||
await expect(imageView).toBeVisible();
|
||||
await expect(imageView.getByLabel("Download")).not.toBeVisible();
|
||||
});
|
||||
test("should allow downloading media when the allowDownloading hint is set to true", async ({
|
||||
page,
|
||||
@ -127,12 +131,16 @@ test.describe("Custom Component API", () => {
|
||||
});
|
||||
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
const image = imgTile.getByRole("img", { name: "good.png" });
|
||||
await expect(image).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
||||
await imgTile.click();
|
||||
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
|
||||
await image.click();
|
||||
const imageView = page.getByLabel("Image view");
|
||||
await expect(imageView).toBeVisible();
|
||||
await expect(imageView.getByLabel("Download")).toBeVisible();
|
||||
});
|
||||
test(
|
||||
"should render the next registered component if the filter function throws",
|
||||
|
||||
@ -87,9 +87,9 @@ test.describe("FilePanel", () => {
|
||||
await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3);
|
||||
|
||||
// Detect the image file
|
||||
const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody");
|
||||
const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody");
|
||||
// Assert that the image is specified as thumbnail and has the alt string
|
||||
await expect(image.locator("img[class='mx_MImageBody_thumbnail']")).toBeVisible();
|
||||
await expect(image.locator("img.mx_ImageBody_image")).toBeVisible();
|
||||
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
|
||||
|
||||
// Detect the audio file
|
||||
@ -113,7 +113,7 @@ test.describe("FilePanel", () => {
|
||||
"flex-end",
|
||||
);
|
||||
// Assert that all of the file tiles are visible before taking a snapshot
|
||||
await expect(filePanelMessageList.locator(".mx_MImageBody")).toBeVisible(); // top
|
||||
await expect(filePanelMessageList.locator(".mx_ImageBody")).toBeVisible(); // top
|
||||
await expect(filePanelMessageList.locator(".mx_MAudioBody")).toBeVisible(); // middle
|
||||
const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails");
|
||||
await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible();
|
||||
@ -184,7 +184,7 @@ test.describe("FilePanel", () => {
|
||||
|
||||
// Detect the image file on the panel
|
||||
const imageBody = page.locator(
|
||||
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody",
|
||||
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody",
|
||||
);
|
||||
|
||||
const link = imageBody.locator(".mx_MFileBody a");
|
||||
|
||||
@ -907,7 +907,7 @@ test.describe("Timeline", () => {
|
||||
|
||||
await sendImage(bot, room.roomId, NEW_AVATAR);
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await page.getByRole("button", { name: "Hide" }).click();
|
||||
@ -1314,7 +1314,7 @@ test.describe("Timeline", () => {
|
||||
|
||||
await sendImage(app.client, room.roomId, NEW_AVATAR);
|
||||
await app.timeline.scrollToBottom();
|
||||
await expect(page.locator(".mx_MImageBody").first()).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody").first()).toBeVisible();
|
||||
|
||||
// Exclude timestamp and read marker from snapshot
|
||||
const screenshotOptions = {
|
||||
|
||||
@ -224,7 +224,6 @@
|
||||
@import "./views/messages/_DisambiguatedProfile.pcss";
|
||||
@import "./views/messages/_LegacyCallEvent.pcss";
|
||||
@import "./views/messages/_MFileBody.pcss";
|
||||
@import "./views/messages/_MImageBody.pcss";
|
||||
@import "./views/messages/_MImageReplyBody.pcss";
|
||||
@import "./views/messages/_MLocationBody.pcss";
|
||||
@import "./views/messages/_MPollBody.pcss";
|
||||
|
||||
@ -45,6 +45,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-top: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.mx_ImageBody {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* anchor link as wrapper */
|
||||
.mx_EventTile_senderDetailsLink {
|
||||
text-decoration: none;
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016 OpenMarket 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.
|
||||
*/
|
||||
|
||||
.mx_MImageBody_banner {
|
||||
position: absolute;
|
||||
bottom: $spacing-4;
|
||||
left: $spacing-4;
|
||||
padding: $spacing-4;
|
||||
border-radius: var(--MBody-border-radius);
|
||||
font-size: $font-15px;
|
||||
user-select: none; /* prevent banner text from being selected */
|
||||
pointer-events: none; /* let the cursor go through to the media underneath */
|
||||
|
||||
/* Trying to match the width of the image is surprisingly difficult, so arbitrarily break it off early. */
|
||||
max-width: min(100%, 350px);
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
/* Hardcoded colours because it's the same on all themes */
|
||||
background-color: rgb(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.mx_MImageBody_placeholder {
|
||||
/* Position the placeholder on top of the thumbnail, so that the reveal animation can work */
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
background-color: $background;
|
||||
|
||||
.mx_Blurhash > canvas {
|
||||
animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
border-radius: var(--MBody-border-radius);
|
||||
|
||||
/* Necessary for the border radius to apply correctly to the placeholder */
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail {
|
||||
display: block;
|
||||
|
||||
/* Force the image to be the full size of the container, even if the */
|
||||
/* pixel size is smaller. The problem here is that we don't know what */
|
||||
/* thumbnail size the HS is going to give us, but we have to commit to */
|
||||
/* a container size immediately and not change it when the image loads */
|
||||
/* or we'll get a scroll jump (or have to leave blank space). */
|
||||
/* This will obviously result in an upscaled image which will be a bit */
|
||||
/* blurry. The best fix would be for the HS to advertise what size thumbnails */
|
||||
/* it guarantees to produce. */
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx_MImageBody_gifLabel {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0px;
|
||||
left: 14px;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: $imagebody-giflabel;
|
||||
border: 2px solid $imagebody-giflabel-border;
|
||||
color: $imagebody-giflabel-color;
|
||||
pointer-events: none;
|
||||
}
|
||||
@ -6,6 +6,64 @@ 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_MImageReplyBody,
|
||||
.mx_MStickerBody_wrapper {
|
||||
.mx_MImageBody_banner {
|
||||
position: absolute;
|
||||
bottom: $spacing-4;
|
||||
left: $spacing-4;
|
||||
padding: $spacing-4;
|
||||
border-radius: var(--MBody-border-radius);
|
||||
font-size: $font-15px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
max-width: min(100%, 350px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background-color: rgb(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.mx_MImageBody_placeholder {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: $background;
|
||||
|
||||
.mx_Blurhash > canvas {
|
||||
animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
border-radius: var(--MBody-border-radius);
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx_MImageBody_gifLabel {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0px;
|
||||
left: 14px;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: $imagebody-giflabel;
|
||||
border: 2px solid $imagebody-giflabel-border;
|
||||
color: $imagebody-giflabel-color;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageReplyBody {
|
||||
display: flex;
|
||||
column-gap: $spacing-4;
|
||||
|
||||
@ -156,8 +156,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
padding-right: 48px !important;
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
.mx_ImageBody {
|
||||
.mx_ImageBody_container {
|
||||
justify-content: center;
|
||||
min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
|
||||
min-width: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
|
||||
@ -181,8 +181,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
.mx_EventTile_line {
|
||||
border-bottom-right-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
@ -220,8 +220,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-inline-start: auto;
|
||||
border-bottom-left-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
@ -334,16 +334,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_ImageBody {
|
||||
width: 100%;
|
||||
|
||||
.mx_MImageBody_thumbnail.mx_MImageBody_thumbnail--blurhash {
|
||||
position: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* noinspection CssReplaceWithShorthandSafely */
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MediaBody {
|
||||
border-radius: unset;
|
||||
@ -375,9 +371,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_continuation[data-self="false"] .mx_EventTile_line {
|
||||
border-top-left-radius: 0;
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
@ -387,9 +383,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_lastInSection[data-self="false"] .mx_EventTile_line {
|
||||
border-bottom-left-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
@ -400,9 +396,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_continuation[data-self="true"] .mx_EventTile_line {
|
||||
border-top-right-radius: 0;
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
@ -412,9 +408,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_lastInSection[data-self="true"] .mx_EventTile_line {
|
||||
border-bottom-right-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
|
||||
@ -78,8 +78,8 @@ $left-gutter: 64px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
.mx_ImageBody {
|
||||
.mx_ImageBody_container {
|
||||
display: flex;
|
||||
align-items: center; /* on every layout */
|
||||
}
|
||||
@ -156,8 +156,8 @@ $left-gutter: 64px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
.mx_ImageBody {
|
||||
.mx_ImageBody_container {
|
||||
justify-content: flex-start;
|
||||
min-height: $font-44px;
|
||||
min-width: $font-44px;
|
||||
|
||||
@ -7,9 +7,11 @@ 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 { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import {
|
||||
DecryptionFailureBodyView,
|
||||
FileBodyView,
|
||||
ImageBodyView,
|
||||
RedactedBodyView,
|
||||
VideoBodyView,
|
||||
useCreateAutoDisposedViewModel,
|
||||
@ -21,8 +23,10 @@ import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDevi
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
|
||||
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
|
||||
import { ImageBodyViewModel } from "../../../viewmodels/message-body/ImageBodyViewModel";
|
||||
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
|
||||
import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs";
|
||||
|
||||
type MBodyComponent = React.ComponentType<IBodyProps>;
|
||||
|
||||
@ -134,6 +138,122 @@ export function VideoBodyFactory({
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageBodyFactory({
|
||||
mxEvent,
|
||||
mediaEventHelper,
|
||||
forExport,
|
||||
maxImageHeight,
|
||||
permalinkCreator,
|
||||
showFileInfo,
|
||||
}: Readonly<
|
||||
Pick<
|
||||
IBodyProps,
|
||||
"mxEvent" | "mediaEventHelper" | "forExport" | "maxImageHeight" | "permalinkCreator" | "showFileInfo"
|
||||
>
|
||||
>): JSX.Element {
|
||||
const { timelineRenderingType } = useContext(RoomContext);
|
||||
const [mediaVisible, setMediaVisible] = useMediaVisible(mxEvent);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const content = mxEvent.getContent<ImageContent>();
|
||||
const shouldFallbackToFileBody =
|
||||
mediaEventHelper?.media.isEncrypted === true &&
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info;
|
||||
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new ImageBodyViewModel({
|
||||
mxEvent,
|
||||
mediaEventHelper,
|
||||
forExport,
|
||||
maxImageHeight,
|
||||
mediaVisible,
|
||||
permalinkCreator,
|
||||
timelineRenderingType,
|
||||
imageRef,
|
||||
setMediaVisible,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.loadInitialMediaIfVisible();
|
||||
}, [shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setEvent(mxEvent, mediaEventHelper);
|
||||
}, [mediaEventHelper, mxEvent, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setForExport(forExport);
|
||||
}, [forExport, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setMaxImageHeight(maxImageHeight);
|
||||
}, [maxImageHeight, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setMediaVisible(mediaVisible);
|
||||
}, [mediaVisible, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setPermalinkCreator(permalinkCreator);
|
||||
}, [permalinkCreator, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setTimelineRenderingType(timelineRenderingType);
|
||||
}, [shouldFallbackToFileBody, timelineRenderingType, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setSetMediaVisible(setMediaVisible);
|
||||
}, [setMediaVisible, shouldFallbackToFileBody, vm]);
|
||||
|
||||
const showFileBody =
|
||||
!forExport &&
|
||||
timelineRenderingType !== TimelineRenderingType.Room &&
|
||||
timelineRenderingType !== TimelineRenderingType.Pinned &&
|
||||
timelineRenderingType !== TimelineRenderingType.Search &&
|
||||
timelineRenderingType !== TimelineRenderingType.Thread &&
|
||||
timelineRenderingType !== TimelineRenderingType.ThreadsList;
|
||||
|
||||
if (shouldFallbackToFileBody) {
|
||||
return (
|
||||
<FileBodyFactory
|
||||
mxEvent={mxEvent}
|
||||
mediaEventHelper={mediaEventHelper}
|
||||
forExport={forExport}
|
||||
showFileInfo={showFileInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageBodyView
|
||||
vm={vm}
|
||||
className="mx_ImageBody"
|
||||
containerClassName="mx_ImageBody_container"
|
||||
imageClassName="mx_ImageBody_image"
|
||||
imageRef={imageRef}
|
||||
>
|
||||
{showFileBody ? (
|
||||
<FileBodyFactory
|
||||
mxEvent={mxEvent}
|
||||
mediaEventHelper={mediaEventHelper}
|
||||
forExport={forExport}
|
||||
showFileInfo={false}
|
||||
/>
|
||||
) : null}
|
||||
</ImageBodyView>
|
||||
);
|
||||
}
|
||||
|
||||
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
|
||||
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
|
||||
|
||||
@ -164,6 +284,7 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick<IBodyProps,
|
||||
|
||||
// Message body factory registry for bodies that already route through view-model-backed wrappers.
|
||||
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([
|
||||
[MsgType.Image, ImageBodyFactory],
|
||||
[MsgType.File, FileBodyFactory],
|
||||
[MsgType.Video, VideoBodyFactory],
|
||||
]);
|
||||
|
||||
@ -1,714 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
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 ComponentProps, createRef, type ReactNode } from "react";
|
||||
import { Blurhash } from "react-blurhash";
|
||||
import classNames from "classnames";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components";
|
||||
|
||||
import Modal from "../../../Modal";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { type Media, mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { createReconnectedListener } from "../../../utils/connection";
|
||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
enum Placeholder {
|
||||
NoImage,
|
||||
Blurhash,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contentUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
isAnimated?: boolean;
|
||||
error?: unknown;
|
||||
imgError: boolean;
|
||||
imgLoaded: boolean;
|
||||
loadedImageDimensions?: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
hover: boolean;
|
||||
focus: boolean;
|
||||
placeholder: Placeholder;
|
||||
}
|
||||
|
||||
interface IProps extends IBodyProps {
|
||||
/**
|
||||
* Should the media be behind a preview.
|
||||
*/
|
||||
mediaVisible: boolean;
|
||||
/**
|
||||
* Set the visibility of the media event.
|
||||
* @param visible Should the event be visible.
|
||||
*/
|
||||
setMediaVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private Only use for inheritance. Use the default export for presentation.
|
||||
*/
|
||||
export class MImageBodyInner extends React.Component<IProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private unmounted = false;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
private placeholder = createRef<HTMLDivElement>();
|
||||
private timeout?: number;
|
||||
private sizeWatcher?: string;
|
||||
|
||||
public state: IState = {
|
||||
contentUrl: null,
|
||||
thumbUrl: null,
|
||||
imgError: false,
|
||||
imgLoaded: false,
|
||||
hover: false,
|
||||
focus: false,
|
||||
placeholder: Placeholder.NoImage,
|
||||
};
|
||||
|
||||
protected onClick = (ev: React.MouseEvent): void => {
|
||||
if (ev.button === 0 && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
if (!this.props.mediaVisible) {
|
||||
this.props.setMediaVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
let httpUrl = this.state.contentUrl;
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
|
||||
) {
|
||||
// contentUrl will be a blob URI mime-type=application/octet-stream so fall back to the thumbUrl instead
|
||||
httpUrl = this.state.thumbUrl;
|
||||
}
|
||||
|
||||
if (!httpUrl) return;
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
||||
if (content.info) {
|
||||
params.width = content.info.w;
|
||||
params.height = content.info.h;
|
||||
params.fileSize = content.info.size;
|
||||
}
|
||||
|
||||
if (this.image.current) {
|
||||
const clientRect = this.image.current.getBoundingClientRect();
|
||||
|
||||
params.thumbnailInfo = {
|
||||
width: clientRect.width,
|
||||
height: clientRect.height,
|
||||
positionX: clientRect.x,
|
||||
positionY: clientRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}
|
||||
};
|
||||
|
||||
private get shouldAutoplay(): boolean {
|
||||
return !(
|
||||
!this.state.contentUrl ||
|
||||
!this.props.mediaVisible ||
|
||||
!this.state.isAnimated ||
|
||||
SettingsStore.getValue("autoplayGifs")
|
||||
);
|
||||
}
|
||||
|
||||
protected onImageEnter = (): void => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
protected onImageLeave = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
private onFocus = (): void => {
|
||||
this.setState({ focus: true });
|
||||
};
|
||||
|
||||
private onBlur = (): void => {
|
||||
this.setState({ focus: false });
|
||||
};
|
||||
|
||||
private reconnectedListener = createReconnectedListener((): void => {
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.setState({ imgError: false });
|
||||
});
|
||||
|
||||
private onImageError = (): void => {
|
||||
// If the thumbnail failed to load then try again using the contentUrl
|
||||
if (this.state.thumbUrl) {
|
||||
this.setState({
|
||||
thumbUrl: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
this.setState({
|
||||
imgError: true,
|
||||
});
|
||||
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
|
||||
};
|
||||
|
||||
private onImageLoad = (): void => {
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
let loadedImageDimensions: IState["loadedImageDimensions"];
|
||||
|
||||
if (this.image.current) {
|
||||
const { naturalWidth, naturalHeight } = this.image.current;
|
||||
// this is only used as a fallback in case content.info.w/h is missing
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
this.setState({ imgLoaded: true, loadedImageDimensions });
|
||||
};
|
||||
|
||||
private getContentUrl(): string | null {
|
||||
// During export, the content url will point to the MSC, which will later point to a local url
|
||||
if (this.props.forExport) return this.media.srcMxc;
|
||||
return this.media.srcHttp;
|
||||
}
|
||||
|
||||
private get media(): Media {
|
||||
return mediaFromContent(this.props.mxEvent.getContent());
|
||||
}
|
||||
|
||||
private getThumbUrl(): string | null {
|
||||
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
|
||||
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
||||
// thumbnail resolution will be unnecessarily reduced.
|
||||
// custom timeline widths seems preferable.
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
const media = mediaFromContent(content);
|
||||
const info = content.info;
|
||||
|
||||
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
|
||||
// Special-case to return clientside sender-generated thumbnails for SVGs, if any,
|
||||
// given we deliberately don't thumbnail them serverside to prevent billion lol attacks and similar.
|
||||
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
|
||||
}
|
||||
|
||||
// we try to download the correct resolution for hi-res images (like retina screenshots).
|
||||
// Synapse only supports 800x600 thumbnails for now though,
|
||||
// so we'll need to download the original image for this to work well for now.
|
||||
// First, let's try a few cases that let us avoid downloading the original, including:
|
||||
// - When displaying a GIF, we always want to thumbnail so that we can
|
||||
// properly respect the user's GIF autoplay setting (which relies on
|
||||
// thumbnailing to produce the static preview image)
|
||||
// - On a low DPI device, always thumbnail to save bandwidth
|
||||
// - If there's no sizing info in the event, default to thumbnail
|
||||
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
// We should only request thumbnails if the image is bigger than 800x600 (or 1600x1200 on retina) otherwise
|
||||
// the image in the timeline will just end up resampled and de-retina'd for no good reason.
|
||||
// Ideally the server would pre-gen 1600x1200 thumbnails in order to provide retina thumbnails,
|
||||
// but we don't do this currently in synapse for fear of disk space.
|
||||
// As a compromise, let's switch to non-retina thumbnails only if the original image is both
|
||||
// physically too large and going to be massive to load in the timeline (e.g. >1MB).
|
||||
|
||||
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
|
||||
const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
// image is too large physically and byte-wise to clutter our timeline so,
|
||||
// we ask for a thumbnail, despite knowing that it will be max 800x600
|
||||
// despite us being retina (as synapse doesn't do 1600x1200 thumbs yet).
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
// download the original image otherwise, so we can scale it client side to take pixelRatio into account.
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<void> {
|
||||
if (this.state.contentUrl) return; // already downloaded
|
||||
|
||||
let thumbUrl: string | null;
|
||||
let contentUrl: string | null;
|
||||
if (this.props.mediaEventHelper?.media.isEncrypted) {
|
||||
try {
|
||||
[contentUrl, thumbUrl] = await Promise.all([
|
||||
this.props.mediaEventHelper.sourceUrl.value,
|
||||
this.props.mediaEventHelper.thumbnailUrl.value,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (error instanceof DecryptError) {
|
||||
logger.error("Unable to decrypt attachment: ", error);
|
||||
} else if (error instanceof DownloadError) {
|
||||
logger.error("Unable to download attachment to decrypt it: ", error);
|
||||
} else {
|
||||
logger.error("Error encountered when downloading encrypted attachment: ", error);
|
||||
}
|
||||
|
||||
// Set a placeholder image when we can't decrypt the image.
|
||||
this.setState({ error });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
thumbUrl = this.getThumbUrl();
|
||||
contentUrl = this.getContentUrl();
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype);
|
||||
|
||||
// If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server
|
||||
// because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail.
|
||||
if (isAnimated && !SettingsStore.getValue("autoplayGifs")) {
|
||||
if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
|
||||
const img = document.createElement("img");
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
img.crossOrigin = "Anonymous"; // CORS allow canvas access
|
||||
img.src = contentUrl ?? "";
|
||||
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch (error) {
|
||||
logger.error("Unable to download attachment: ", error);
|
||||
this.setState({ error: error as Error });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If we didn't receive the MSC4230 is_animated flag
|
||||
// then we need to check if the image is animated by downloading it.
|
||||
if (
|
||||
content.info?.["org.matrix.msc4230.is_animated"] === false ||
|
||||
(await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false
|
||||
) {
|
||||
isAnimated = false;
|
||||
}
|
||||
|
||||
if (isAnimated) {
|
||||
const thumb = await createThumbnail(
|
||||
img,
|
||||
img.width,
|
||||
img.height,
|
||||
content.info?.mimetype ?? "image/jpeg",
|
||||
false,
|
||||
);
|
||||
thumbUrl = URL.createObjectURL(thumb.thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
// This is a non-critical failure, do not surface the error or bail the method here
|
||||
logger.warn("Unable to generate thumbnail for animated image: ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
contentUrl,
|
||||
thumbUrl,
|
||||
isAnimated,
|
||||
});
|
||||
}
|
||||
|
||||
private clearBlurhashTimeout(): void {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
|
||||
if (this.props.mediaVisible) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.downloadImage();
|
||||
}
|
||||
|
||||
// Add a 150ms timer for blurhash to first appear.
|
||||
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
|
||||
this.clearBlurhashTimeout();
|
||||
this.timeout = window.setTimeout(() => {
|
||||
if (!this.state.imgLoaded || !this.state.imgError) {
|
||||
this.setState({
|
||||
placeholder: Placeholder.Blurhash,
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
|
||||
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||
if (!prevProps.mediaVisible && this.props.mediaVisible) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.clearBlurhashTimeout();
|
||||
SettingsStore.unwatchSetting(this.sizeWatcher);
|
||||
if (this.state.isAnimated && this.state.thumbUrl) {
|
||||
URL.revokeObjectURL(this.state.thumbUrl);
|
||||
}
|
||||
}
|
||||
|
||||
protected getBanner(content: ImageContent): ReactNode {
|
||||
// Hide it for the threads list & the file panel where we show it as text anyway.
|
||||
if (
|
||||
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="mx_MImageBody_banner">
|
||||
{presentableTextForFile(content, _t("common|image"), true, true)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
protected messageContent(
|
||||
contentUrl: string | null,
|
||||
thumbUrl: string | null,
|
||||
content: ImageContent,
|
||||
forcedHeight?: number,
|
||||
): ReactNode {
|
||||
if (!thumbUrl) thumbUrl = contentUrl; // fallback
|
||||
|
||||
// magic number
|
||||
// edge case for this not to be set by conditions below
|
||||
let infoWidth = 500;
|
||||
let infoHeight = 500;
|
||||
let infoSvg = false;
|
||||
|
||||
if (content.info?.w && content.info?.h) {
|
||||
infoWidth = content.info.w;
|
||||
infoHeight = content.info.h;
|
||||
infoSvg = content.info.mimetype === "image/svg+xml";
|
||||
} else if (thumbUrl && contentUrl) {
|
||||
// Whilst the image loads, display nothing. We also don't display a blurhash image
|
||||
// because we don't really know what size of image we'll end up with.
|
||||
//
|
||||
// Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`.
|
||||
//
|
||||
// By doing this, the image "pops" into the timeline, but is still restricted
|
||||
// by the same width and height logic below.
|
||||
if (!this.state.loadedImageDimensions) {
|
||||
let imageElement: JSX.Element;
|
||||
if (!this.props.mediaVisible) {
|
||||
imageElement = (
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
);
|
||||
} else {
|
||||
imageElement = (
|
||||
<img
|
||||
style={{ display: "none" }}
|
||||
src={thumbUrl}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.wrapImage(contentUrl, imageElement);
|
||||
}
|
||||
infoWidth = this.state.loadedImageDimensions.naturalWidth;
|
||||
infoHeight = this.state.loadedImageDimensions.naturalHeight;
|
||||
}
|
||||
|
||||
// The maximum size of the thumbnail as it is rendered as an <img>,
|
||||
// accounting for any height constraints
|
||||
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
|
||||
SettingsStore.getValue("Images.size") as ImageSize,
|
||||
{ w: infoWidth, h: infoHeight },
|
||||
forcedHeight ?? this.props.maxImageHeight,
|
||||
);
|
||||
|
||||
let img: JSX.Element | undefined;
|
||||
let placeholder: JSX.Element | undefined;
|
||||
let gifLabel: JSX.Element | undefined;
|
||||
|
||||
if (!this.props.forExport && !this.state.imgLoaded) {
|
||||
const classes = classNames("mx_MImageBody_placeholder", {
|
||||
"mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
|
||||
});
|
||||
|
||||
placeholder = (
|
||||
<div className={classes} ref={this.placeholder}>
|
||||
{this.getPlaceholder(maxWidth, maxHeight)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let showPlaceholder = Boolean(placeholder);
|
||||
|
||||
const hoverOrFocus = this.state.hover || this.state.focus;
|
||||
if (thumbUrl && !this.state.imgError) {
|
||||
let url = thumbUrl;
|
||||
if (hoverOrFocus && this.shouldAutoplay) {
|
||||
url = this.state.contentUrl!;
|
||||
}
|
||||
|
||||
// Restrict the width of the thumbnail here, otherwise it will fill the container
|
||||
// which has the same width as the timeline
|
||||
// mx_MImageBody_thumbnail resizes img to exactly container size
|
||||
img = (
|
||||
<img
|
||||
className="mx_MImageBody_thumbnail"
|
||||
src={url}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.props.mediaVisible) {
|
||||
img = (
|
||||
<div style={{ width: maxWidth, height: maxHeight }}>
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
</div>
|
||||
);
|
||||
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
|
||||
}
|
||||
|
||||
if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) {
|
||||
// XXX: Arguably we may want a different label when the animated image is WEBP and not GIF
|
||||
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
||||
}
|
||||
|
||||
let banner: ReactNode | undefined;
|
||||
if (this.props.mediaVisible && hoverOrFocus) {
|
||||
banner = this.getBanner(content);
|
||||
}
|
||||
|
||||
// many SVGs don't have an intrinsic size if used in <img> elements.
|
||||
// due to this we have to set our desired width directly.
|
||||
// this way if the image is forced to shrink, the height adapts appropriately.
|
||||
const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth };
|
||||
|
||||
if (!this.props.forExport) {
|
||||
placeholder = (
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
classNames="mx_rtg--fade"
|
||||
key={`img-${showPlaceholder}`}
|
||||
timeout={300}
|
||||
nodeRef={this.placeholder}
|
||||
>
|
||||
{
|
||||
showPlaceholder ? (
|
||||
placeholder
|
||||
) : (
|
||||
<div ref={this.placeholder} />
|
||||
) /* Transition always expects a child */
|
||||
}
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipProps = this.getTooltipProps();
|
||||
let thumbnail = (
|
||||
<div
|
||||
className="mx_MImageBody_thumbnail_container"
|
||||
style={{ maxHeight, maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}
|
||||
tabIndex={tooltipProps ? 0 : undefined}
|
||||
>
|
||||
{placeholder}
|
||||
|
||||
<div style={sizing}>
|
||||
{img}
|
||||
{gifLabel}
|
||||
{banner}
|
||||
</div>
|
||||
|
||||
{/* HACK: This div fills out space while the image loads, to prevent scroll jumps */}
|
||||
{!this.props.forExport && !this.state.imgLoaded && !placeholder && (
|
||||
<div style={{ height: maxHeight, width: maxWidth }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltipProps) {
|
||||
// We specify isTriggerInteractive=true and make the div interactive manually as a workaround for
|
||||
// https://github.com/element-hq/compound/issues/294
|
||||
thumbnail = (
|
||||
<Tooltip {...tooltipProps} isTriggerInteractive={true}>
|
||||
{thumbnail}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return this.wrapImage(contentUrl, thumbnail);
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode {
|
||||
if (contentUrl) {
|
||||
return (
|
||||
<a
|
||||
href={contentUrl}
|
||||
target={this.props.forExport ? "_blank" : undefined}
|
||||
onClick={this.onClick}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected getPlaceholder(width: number, height: number): ReactNode {
|
||||
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
|
||||
|
||||
if (blurhash) {
|
||||
if (this.state.placeholder === Placeholder.NoImage) {
|
||||
return null;
|
||||
} else if (this.state.placeholder === Placeholder.Blurhash) {
|
||||
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
|
||||
}
|
||||
}
|
||||
return <Spinner size={32} />;
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected getTooltipProps(): ComponentProps<typeof Tooltip> | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected getFileBody(): ReactNode {
|
||||
if (this.props.forExport) return null;
|
||||
/*
|
||||
* In the room timeline or the thread context we don't need the download
|
||||
* link as the message action bar will fulfill that
|
||||
*/
|
||||
const hasMessageActionBar =
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Room ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Pinned ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Search ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
|
||||
if (!hasMessageActionBar) {
|
||||
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
// Fall back to file-body view if we are unable to render this image e.g. in the case of a blob svg
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info
|
||||
) {
|
||||
return renderMBody(this.props, FileBodyFactory);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
let errorText = _t("timeline|m.image|error");
|
||||
if (this.state.error instanceof DecryptError) {
|
||||
errorText = _t("timeline|m.image|error_decrypting");
|
||||
} else if (this.state.error instanceof DownloadError) {
|
||||
errorText = _t("timeline|m.image|error_downloading");
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaProcessingError className="mx_MImageBody" Icon={ImageErrorIcon}>
|
||||
{errorText}
|
||||
</MediaProcessingError>
|
||||
);
|
||||
}
|
||||
|
||||
let contentUrl = this.state.contentUrl;
|
||||
let thumbUrl: string | null;
|
||||
if (this.props.forExport) {
|
||||
contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url;
|
||||
thumbUrl = contentUrl;
|
||||
} else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) {
|
||||
thumbUrl = contentUrl;
|
||||
} else {
|
||||
thumbUrl = this.state.thumbUrl ?? this.state.contentUrl;
|
||||
}
|
||||
|
||||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||
const fileBody = this.getFileBody();
|
||||
|
||||
return (
|
||||
<div className="mx_MImageBody">
|
||||
{thumbnail}
|
||||
{fileBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap MImageBody component so we can use a hook here.
|
||||
const MImageBody: React.FC<IBodyProps> = (props) => {
|
||||
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
|
||||
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
|
||||
};
|
||||
|
||||
export default MImageBody;
|
||||
@ -6,16 +6,621 @@ 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.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react";
|
||||
import { Blurhash } from "react-blurhash";
|
||||
import classNames from "classnames";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components";
|
||||
|
||||
import { MImageBodyInner } from "./MImageBody";
|
||||
import Modal from "../../../Modal";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { type Media, mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { createReconnectedListener } from "../../../utils/connection";
|
||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
enum Placeholder {
|
||||
NoImage,
|
||||
Blurhash,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contentUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
isAnimated?: boolean;
|
||||
error?: unknown;
|
||||
imgError: boolean;
|
||||
imgLoaded: boolean;
|
||||
loadedImageDimensions?: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
hover: boolean;
|
||||
focus: boolean;
|
||||
placeholder: Placeholder;
|
||||
}
|
||||
|
||||
export interface ImageBodyBaseProps extends IBodyProps {
|
||||
mediaVisible: boolean;
|
||||
setMediaVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export class ImageBodyBaseInner extends React.Component<ImageBodyBaseProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private unmounted = false;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
private placeholder = createRef<HTMLDivElement>();
|
||||
private timeout?: number;
|
||||
private sizeWatcher?: string;
|
||||
|
||||
public state: IState = {
|
||||
contentUrl: null,
|
||||
thumbUrl: null,
|
||||
imgError: false,
|
||||
imgLoaded: false,
|
||||
hover: false,
|
||||
focus: false,
|
||||
placeholder: Placeholder.NoImage,
|
||||
};
|
||||
|
||||
protected onClick = (ev: React.MouseEvent): void => {
|
||||
if (ev.button === 0 && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
if (!this.props.mediaVisible) {
|
||||
this.props.setMediaVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
let httpUrl = this.state.contentUrl;
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
|
||||
) {
|
||||
httpUrl = this.state.thumbUrl;
|
||||
}
|
||||
|
||||
if (!httpUrl) return;
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
||||
if (content.info) {
|
||||
params.width = content.info.w;
|
||||
params.height = content.info.h;
|
||||
params.fileSize = content.info.size;
|
||||
}
|
||||
|
||||
if (this.image.current) {
|
||||
const clientRect = this.image.current.getBoundingClientRect();
|
||||
|
||||
params.thumbnailInfo = {
|
||||
width: clientRect.width,
|
||||
height: clientRect.height,
|
||||
positionX: clientRect.x,
|
||||
positionY: clientRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}
|
||||
};
|
||||
|
||||
private get shouldAutoplay(): boolean {
|
||||
return !(
|
||||
!this.state.contentUrl ||
|
||||
!this.props.mediaVisible ||
|
||||
!this.state.isAnimated ||
|
||||
SettingsStore.getValue("autoplayGifs")
|
||||
);
|
||||
}
|
||||
|
||||
protected onImageEnter = (): void => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
protected onImageLeave = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
private onFocus = (): void => {
|
||||
this.setState({ focus: true });
|
||||
};
|
||||
|
||||
private onBlur = (): void => {
|
||||
this.setState({ focus: false });
|
||||
};
|
||||
|
||||
private reconnectedListener = createReconnectedListener((): void => {
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.setState({ imgError: false });
|
||||
});
|
||||
|
||||
private onImageError = (): void => {
|
||||
if (this.state.thumbUrl) {
|
||||
this.setState({
|
||||
thumbUrl: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
this.setState({
|
||||
imgError: true,
|
||||
});
|
||||
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
|
||||
};
|
||||
|
||||
private onImageLoad = (): void => {
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
let loadedImageDimensions: IState["loadedImageDimensions"];
|
||||
|
||||
if (this.image.current) {
|
||||
const { naturalWidth, naturalHeight } = this.image.current;
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
this.setState({ imgLoaded: true, loadedImageDimensions });
|
||||
};
|
||||
|
||||
private getContentUrl(): string | null {
|
||||
if (this.props.forExport) return this.media.srcMxc;
|
||||
return this.media.srcHttp;
|
||||
}
|
||||
|
||||
private get media(): Media {
|
||||
return mediaFromContent(this.props.mxEvent.getContent());
|
||||
}
|
||||
|
||||
private getThumbUrl(): string | null {
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
const media = mediaFromContent(content);
|
||||
const info = content.info;
|
||||
|
||||
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
|
||||
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
|
||||
}
|
||||
|
||||
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
|
||||
const isLargeFileSize = info.size > 1 * 1024 * 1024;
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<void> {
|
||||
if (this.state.contentUrl) return;
|
||||
|
||||
let thumbUrl: string | null;
|
||||
let contentUrl: string | null;
|
||||
if (this.props.mediaEventHelper?.media.isEncrypted) {
|
||||
try {
|
||||
[contentUrl, thumbUrl] = await Promise.all([
|
||||
this.props.mediaEventHelper.sourceUrl.value,
|
||||
this.props.mediaEventHelper.thumbnailUrl.value,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (error instanceof DecryptError) {
|
||||
logger.error("Unable to decrypt attachment: ", error);
|
||||
} else if (error instanceof DownloadError) {
|
||||
logger.error("Unable to download attachment to decrypt it: ", error);
|
||||
} else {
|
||||
logger.error("Error encountered when downloading encrypted attachment: ", error);
|
||||
}
|
||||
|
||||
this.setState({ error });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
thumbUrl = this.getThumbUrl();
|
||||
contentUrl = this.getContentUrl();
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype);
|
||||
|
||||
if (isAnimated && !SettingsStore.getValue("autoplayGifs")) {
|
||||
if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
|
||||
const img = document.createElement("img");
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
img.crossOrigin = "Anonymous";
|
||||
img.src = contentUrl ?? "";
|
||||
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch (error) {
|
||||
logger.error("Unable to download attachment: ", error);
|
||||
this.setState({ error: error as Error });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
content.info?.["org.matrix.msc4230.is_animated"] === false ||
|
||||
(await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false
|
||||
) {
|
||||
isAnimated = false;
|
||||
}
|
||||
|
||||
if (isAnimated) {
|
||||
const thumb = await createThumbnail(
|
||||
img,
|
||||
img.width,
|
||||
img.height,
|
||||
content.info?.mimetype ?? "image/jpeg",
|
||||
false,
|
||||
);
|
||||
thumbUrl = URL.createObjectURL(thumb.thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Unable to generate thumbnail for animated image: ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
contentUrl,
|
||||
thumbUrl,
|
||||
isAnimated,
|
||||
});
|
||||
}
|
||||
|
||||
private clearBlurhashTimeout(): void {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
|
||||
if (this.props.mediaVisible) {
|
||||
void this.downloadImage();
|
||||
}
|
||||
|
||||
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
|
||||
this.clearBlurhashTimeout();
|
||||
this.timeout = window.setTimeout(() => {
|
||||
if (!this.state.imgLoaded || !this.state.imgError) {
|
||||
this.setState({
|
||||
placeholder: Placeholder.Blurhash,
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<ImageBodyBaseProps>): void {
|
||||
if (!prevProps.mediaVisible && this.props.mediaVisible) {
|
||||
void this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.clearBlurhashTimeout();
|
||||
SettingsStore.unwatchSetting(this.sizeWatcher);
|
||||
if (this.state.isAnimated && this.state.thumbUrl) {
|
||||
URL.revokeObjectURL(this.state.thumbUrl);
|
||||
}
|
||||
}
|
||||
|
||||
protected getBanner(content: ImageContent): ReactNode {
|
||||
if (
|
||||
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="mx_MImageBody_banner">
|
||||
{presentableTextForFile(content, _t("common|image"), true, true)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
protected messageContent(
|
||||
contentUrl: string | null,
|
||||
thumbUrl: string | null,
|
||||
content: ImageContent,
|
||||
forcedHeight?: number,
|
||||
): ReactNode {
|
||||
if (!thumbUrl) thumbUrl = contentUrl;
|
||||
|
||||
let infoWidth = 500;
|
||||
let infoHeight = 500;
|
||||
let infoSvg = false;
|
||||
|
||||
if (content.info?.w && content.info?.h) {
|
||||
infoWidth = content.info.w;
|
||||
infoHeight = content.info.h;
|
||||
infoSvg = content.info.mimetype === "image/svg+xml";
|
||||
} else if (thumbUrl && contentUrl) {
|
||||
if (!this.state.loadedImageDimensions) {
|
||||
let imageElement: JSX.Element;
|
||||
if (!this.props.mediaVisible) {
|
||||
imageElement = (
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
);
|
||||
} else {
|
||||
imageElement = (
|
||||
<img
|
||||
style={{ display: "none" }}
|
||||
src={thumbUrl}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.wrapImage(contentUrl, imageElement);
|
||||
}
|
||||
infoWidth = this.state.loadedImageDimensions.naturalWidth;
|
||||
infoHeight = this.state.loadedImageDimensions.naturalHeight;
|
||||
}
|
||||
|
||||
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
|
||||
SettingsStore.getValue("Images.size") as ImageSize,
|
||||
{ w: infoWidth, h: infoHeight },
|
||||
forcedHeight ?? this.props.maxImageHeight,
|
||||
);
|
||||
|
||||
let img: JSX.Element | undefined;
|
||||
let placeholder: JSX.Element | undefined;
|
||||
let gifLabel: JSX.Element | undefined;
|
||||
|
||||
if (!this.props.forExport && !this.state.imgLoaded) {
|
||||
const classes = classNames("mx_MImageBody_placeholder", {
|
||||
"mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
|
||||
});
|
||||
|
||||
placeholder = (
|
||||
<div className={classes} ref={this.placeholder}>
|
||||
{this.getPlaceholder(maxWidth, maxHeight)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let showPlaceholder = Boolean(placeholder);
|
||||
|
||||
const hoverOrFocus = this.state.hover || this.state.focus;
|
||||
if (thumbUrl && !this.state.imgError) {
|
||||
let url = thumbUrl;
|
||||
if (hoverOrFocus && this.shouldAutoplay) {
|
||||
url = this.state.contentUrl!;
|
||||
}
|
||||
|
||||
img = (
|
||||
<img
|
||||
className="mx_MImageBody_thumbnail"
|
||||
src={url}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.props.mediaVisible) {
|
||||
img = (
|
||||
<div style={{ width: maxWidth, height: maxHeight }}>
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
</div>
|
||||
);
|
||||
showPlaceholder = false;
|
||||
}
|
||||
|
||||
if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) {
|
||||
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
||||
}
|
||||
|
||||
let banner: ReactNode | undefined;
|
||||
if (this.props.mediaVisible && hoverOrFocus) {
|
||||
banner = this.getBanner(content);
|
||||
}
|
||||
|
||||
const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth };
|
||||
|
||||
if (!this.props.forExport) {
|
||||
placeholder = (
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
classNames="mx_rtg--fade"
|
||||
key={`img-${showPlaceholder}`}
|
||||
timeout={300}
|
||||
nodeRef={this.placeholder}
|
||||
>
|
||||
{showPlaceholder ? placeholder : <div ref={this.placeholder} />}
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipProps = this.getTooltipProps();
|
||||
let thumbnail = (
|
||||
<div
|
||||
className="mx_MImageBody_thumbnail_container"
|
||||
style={{ maxHeight, maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}
|
||||
tabIndex={tooltipProps ? 0 : undefined}
|
||||
>
|
||||
{placeholder}
|
||||
|
||||
<div style={sizing}>
|
||||
{img}
|
||||
{gifLabel}
|
||||
{banner}
|
||||
</div>
|
||||
|
||||
{!this.props.forExport && !this.state.imgLoaded && !placeholder && (
|
||||
<div style={{ height: maxHeight, width: maxWidth }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltipProps) {
|
||||
thumbnail = (
|
||||
<Tooltip {...tooltipProps} isTriggerInteractive={true}>
|
||||
{thumbnail}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return this.wrapImage(contentUrl, thumbnail);
|
||||
}
|
||||
|
||||
protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode {
|
||||
if (contentUrl) {
|
||||
return (
|
||||
<a
|
||||
href={contentUrl}
|
||||
target={this.props.forExport ? "_blank" : undefined}
|
||||
onClick={this.onClick}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
protected getPlaceholder(width: number, height: number): ReactNode {
|
||||
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
|
||||
|
||||
if (blurhash) {
|
||||
if (this.state.placeholder === Placeholder.NoImage) {
|
||||
return null;
|
||||
} else if (this.state.placeholder === Placeholder.Blurhash) {
|
||||
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
|
||||
}
|
||||
}
|
||||
return <Spinner size={32} />;
|
||||
}
|
||||
|
||||
protected getTooltipProps(): ComponentProps<typeof Tooltip> | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected getFileBody(): ReactNode {
|
||||
if (this.props.forExport) return null;
|
||||
const hasMessageActionBar =
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Room ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Pinned ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Search ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
|
||||
if (!hasMessageActionBar) {
|
||||
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info
|
||||
) {
|
||||
return renderMBody(this.props, FileBodyFactory);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
let errorText = _t("timeline|m.image|error");
|
||||
if (this.state.error instanceof DecryptError) {
|
||||
errorText = _t("timeline|m.image|error_decrypting");
|
||||
} else if (this.state.error instanceof DownloadError) {
|
||||
errorText = _t("timeline|m.image|error_downloading");
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaProcessingError className="mx_MImageBody" Icon={ImageErrorIcon}>
|
||||
{errorText}
|
||||
</MediaProcessingError>
|
||||
);
|
||||
}
|
||||
|
||||
let contentUrl = this.state.contentUrl;
|
||||
let thumbUrl: string | null;
|
||||
if (this.props.forExport) {
|
||||
contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url;
|
||||
thumbUrl = contentUrl;
|
||||
} else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) {
|
||||
thumbUrl = contentUrl;
|
||||
} else {
|
||||
thumbUrl = this.state.thumbUrl ?? this.state.contentUrl;
|
||||
}
|
||||
|
||||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||
const fileBody = this.getFileBody();
|
||||
|
||||
return (
|
||||
<div className="mx_MImageBody">
|
||||
{thumbnail}
|
||||
{fileBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const FORCED_IMAGE_HEIGHT = 44;
|
||||
|
||||
class MImageReplyBodyInner extends MImageBodyInner {
|
||||
class MImageReplyBodyInner extends ImageBodyBaseInner {
|
||||
public onClick = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
@ -37,6 +642,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
|
||||
return <div className="mx_MImageReplyBody">{thumbnail}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
|
||||
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
|
||||
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
|
||||
|
||||
@ -9,13 +9,13 @@ import React, { type JSX, type ComponentProps, type ReactNode } from "react";
|
||||
import { type Tooltip } from "@vector-im/compound-web";
|
||||
import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { MImageBodyInner } from "./MImageBody";
|
||||
import { ImageBodyBaseInner } from "./MImageReplyBody";
|
||||
import { BLURHASH_FIELD } from "../../../utils/image-media";
|
||||
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
|
||||
class MStickerBodyInner extends MImageBodyInner {
|
||||
class MStickerBodyInner extends ImageBodyBaseInner {
|
||||
// Mostly empty to prevent default behaviour of MImageBody
|
||||
protected onClick = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
|
||||
@ -25,7 +25,6 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir";
|
||||
import { type IMediaBody } from "./IMediaBody";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import MImageBody from "./MImageBody";
|
||||
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
||||
import MStickerBody from "./MStickerBody";
|
||||
import MPollBody from "./MPollBody";
|
||||
@ -36,6 +35,7 @@ import { MjolnirBodyViewModel } from "../../../viewmodels/room/timeline/event-ti
|
||||
import {
|
||||
DecryptionFailureBodyFactory,
|
||||
FileBodyFactory,
|
||||
ImageBodyFactory,
|
||||
RedactedBodyFactory,
|
||||
VideoBodyFactory,
|
||||
renderMBody,
|
||||
@ -67,7 +67,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
||||
[MsgType.Text, TextualBodyFactory],
|
||||
[MsgType.Notice, TextualBodyFactory],
|
||||
[MsgType.Emote, TextualBodyFactory],
|
||||
[MsgType.Image, MImageBody],
|
||||
[MsgType.Image, ImageBodyFactory],
|
||||
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
||||
[MsgType.Audio, MVoiceOrAudioBody],
|
||||
[MsgType.Video, VideoBodyFactory],
|
||||
@ -283,7 +283,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
||||
}
|
||||
|
||||
if (
|
||||
((BodyType === MImageBody || BodyType === VideoBodyFactory) &&
|
||||
((BodyType === ImageBodyFactory || BodyType === VideoBodyFactory) &&
|
||||
!this.validateImageOrVideoMimetype(content)) ||
|
||||
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
||||
) {
|
||||
|
||||
674
apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts
Normal file
674
apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts
Normal file
@ -0,0 +1,674 @@
|
||||
/*
|
||||
* 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 { type ComponentProps, type MouseEvent, type RefObject } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import {
|
||||
BaseViewModel,
|
||||
ImageBodyViewPlaceholder,
|
||||
ImageBodyViewState,
|
||||
type ImageBodyViewModel as ImageBodyViewModelInterface,
|
||||
type ImageBodyViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import Modal from "../../Modal";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { mediaFromContent } from "../../customisations/Media";
|
||||
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../settings/enums/ImageSize";
|
||||
import { presentableTextForFile } from "../../utils/FileUtils";
|
||||
import { type MediaEventHelper } from "../../utils/MediaEventHelper";
|
||||
import { blobIsAnimated, mayBeAnimated } from "../../utils/Image";
|
||||
import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import { createReconnectedListener } from "../../utils/connection";
|
||||
import { DecryptError, DownloadError } from "../../utils/DecryptFile";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../utils/image-media";
|
||||
import { isMimeTypeAllowed } from "../../utils/blobs";
|
||||
import ImageView from "../../components/views/elements/ImageView";
|
||||
|
||||
export interface ImageBodyViewModelProps {
|
||||
/**
|
||||
* Image event being rendered.
|
||||
*/
|
||||
mxEvent: MatrixEvent;
|
||||
/**
|
||||
* Helper for resolving encrypted media sources.
|
||||
*/
|
||||
mediaEventHelper?: MediaEventHelper;
|
||||
/**
|
||||
* Whether the image is being rendered for export instead of the live timeline.
|
||||
*/
|
||||
forExport?: boolean;
|
||||
/**
|
||||
* Optional maximum height applied when computing the rendered image dimensions.
|
||||
*/
|
||||
maxImageHeight?: number;
|
||||
/**
|
||||
* Whether the media should currently be shown instead of the hidden-media preview.
|
||||
*/
|
||||
mediaVisible: boolean;
|
||||
/**
|
||||
* Permalink helper passed to the image lightbox.
|
||||
*/
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
/**
|
||||
* Timeline context used to decide which labels and supplemental content should be shown.
|
||||
*/
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
/**
|
||||
* Ref to the underlying image element used for load dimensions and lightbox animation.
|
||||
*/
|
||||
imageRef: RefObject<HTMLImageElement | null>;
|
||||
/**
|
||||
* Callback invoked when hidden media is revealed.
|
||||
*/
|
||||
setMediaVisible?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface LoadedImageDimensions {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
}
|
||||
|
||||
interface InternalState {
|
||||
contentUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
isAnimated: boolean;
|
||||
error: unknown | null;
|
||||
imgError: boolean;
|
||||
imgLoaded: boolean;
|
||||
loadedImageDimensions?: LoadedImageDimensions;
|
||||
placeholder: ImageBodyViewPlaceholder;
|
||||
imageSize: ImageSize;
|
||||
generatedThumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
type ImageInfoWithAnimationFlag = NonNullable<ImageContent["info"]> & {
|
||||
"org.matrix.msc4230.is_animated"?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* View model for the image message body, encapsulating media loading, sizing,
|
||||
* visibility, animated-image previews, and lightbox interactions.
|
||||
*/
|
||||
export class ImageBodyViewModel
|
||||
extends BaseViewModel<ImageBodyViewSnapshot, ImageBodyViewModelProps>
|
||||
implements ImageBodyViewModelInterface
|
||||
{
|
||||
private state: InternalState;
|
||||
private blurhashTimeout?: number;
|
||||
|
||||
private readonly reconnectedListener = createReconnectedListener((): void => {
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
|
||||
if (!this.state.imgError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imgError: false,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
});
|
||||
|
||||
public constructor(props: ImageBodyViewModelProps) {
|
||||
const initialState = ImageBodyViewModel.createInitialState(props.mxEvent);
|
||||
super(props, ImageBodyViewModel.computeSnapshot(props, initialState));
|
||||
this.state = initialState;
|
||||
|
||||
const imageSizeWatcherRef = SettingsStore.watchSetting("Images.size", null, (_s, _r, _l, _nvl, value) => {
|
||||
this.setImageSize(value as ImageSize);
|
||||
});
|
||||
this.disposables.track(() => SettingsStore.unwatchSetting(imageSizeWatcherRef));
|
||||
}
|
||||
|
||||
private static createInitialState(mxEvent: MatrixEvent): InternalState {
|
||||
return {
|
||||
contentUrl: null,
|
||||
thumbUrl: null,
|
||||
isAnimated: false,
|
||||
error: null,
|
||||
imgError: false,
|
||||
imgLoaded: false,
|
||||
loadedImageDimensions: undefined,
|
||||
placeholder: mxEvent.getContent<ImageContent>().info?.[BLURHASH_FIELD]
|
||||
? ImageBodyViewPlaceholder.NONE
|
||||
: ImageBodyViewPlaceholder.SPINNER,
|
||||
imageSize: SettingsStore.getValue("Images.size") as ImageSize,
|
||||
generatedThumbnailUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
private static getImageDimensions(
|
||||
props: ImageBodyViewModelProps,
|
||||
state: InternalState,
|
||||
): Pick<ImageBodyViewSnapshot, "maxWidth" | "maxHeight" | "aspectRatio" | "isSvg"> {
|
||||
const content = props.mxEvent.getContent<ImageContent>();
|
||||
const info = content.info;
|
||||
const naturalWidth = info?.w ?? state.loadedImageDimensions?.naturalWidth;
|
||||
const naturalHeight = info?.h ?? state.loadedImageDimensions?.naturalHeight;
|
||||
|
||||
if (!naturalWidth || !naturalHeight) {
|
||||
return {
|
||||
maxWidth: undefined,
|
||||
maxHeight: undefined,
|
||||
aspectRatio: undefined,
|
||||
isSvg: info?.mimetype === "image/svg+xml",
|
||||
};
|
||||
}
|
||||
|
||||
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
|
||||
state.imageSize,
|
||||
{ w: naturalWidth, h: naturalHeight },
|
||||
props.maxImageHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
aspectRatio: `${naturalWidth}/${naturalHeight}`,
|
||||
isSvg: info?.mimetype === "image/svg+xml",
|
||||
};
|
||||
}
|
||||
|
||||
private static computeErrorLabel(error: unknown, imgError: boolean): string {
|
||||
if (error instanceof DecryptError) return _t("timeline|m.image|error_decrypting");
|
||||
if (error instanceof DownloadError) return _t("timeline|m.image|error_downloading");
|
||||
if (imgError || error) return _t("timeline|m.image|error");
|
||||
|
||||
return _t("timeline|m.image|error");
|
||||
}
|
||||
|
||||
private static shouldShowBanner(timelineRenderingType: TimelineRenderingType): boolean {
|
||||
return ![TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(timelineRenderingType);
|
||||
}
|
||||
|
||||
private static computeSnapshot(props: ImageBodyViewModelProps, state: InternalState): ImageBodyViewSnapshot {
|
||||
const content = props.mxEvent.getContent<ImageContent>();
|
||||
const dimensions = ImageBodyViewModel.getImageDimensions(props, state);
|
||||
const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean;
|
||||
const contentUrl = ImageBodyViewModel.getContentUrl(props, state);
|
||||
const thumbnailSrc = props.forExport
|
||||
? (contentUrl ?? undefined)
|
||||
: state.isAnimated && autoplayGifs
|
||||
? (contentUrl ?? undefined)
|
||||
: (state.thumbUrl ?? contentUrl ?? undefined);
|
||||
|
||||
if (state.error || state.imgError) {
|
||||
return {
|
||||
state: ImageBodyViewState.ERROR,
|
||||
errorLabel: ImageBodyViewModel.computeErrorLabel(state.error, state.imgError),
|
||||
...dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
if (!props.mediaVisible) {
|
||||
return {
|
||||
state: ImageBodyViewState.HIDDEN,
|
||||
hiddenButtonLabel: _t("timeline|m.image|show_image"),
|
||||
...dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: ImageBodyViewState.READY,
|
||||
alt: content.body,
|
||||
src: contentUrl ?? undefined,
|
||||
thumbnailSrc,
|
||||
showAnimatedContentOnHover: state.isAnimated && !autoplayGifs && !!contentUrl,
|
||||
placeholder: !props.forExport && !state.imgLoaded ? state.placeholder : ImageBodyViewPlaceholder.NONE,
|
||||
blurhash: content.info?.[BLURHASH_FIELD],
|
||||
gifLabel: state.isAnimated && !autoplayGifs ? "GIF" : undefined,
|
||||
bannerLabel: ImageBodyViewModel.shouldShowBanner(props.timelineRenderingType)
|
||||
? presentableTextForFile(content, _t("common|image"), true, true)
|
||||
: undefined,
|
||||
linkUrl: contentUrl ?? undefined,
|
||||
linkTarget: props.forExport ? "_blank" : undefined,
|
||||
...dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
private static getContentUrl(props: ImageBodyViewModelProps, state: InternalState): string | null {
|
||||
if (props.forExport) {
|
||||
return (
|
||||
props.mxEvent.getContent<ImageContent>().url ??
|
||||
props.mxEvent.getContent<ImageContent>().file?.url ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return state.contentUrl;
|
||||
}
|
||||
|
||||
public loadInitialMediaIfVisible(): void {
|
||||
if (!this.props.mediaVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleBlurhashPlaceholder();
|
||||
void this.downloadImage();
|
||||
}
|
||||
|
||||
private updateSnapshotFromState(): void {
|
||||
this.snapshot.set(ImageBodyViewModel.computeSnapshot(this.props, this.state));
|
||||
}
|
||||
|
||||
private resetState(mxEvent: MatrixEvent): void {
|
||||
this.clearBlurhashTimeout();
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.revokeGeneratedThumbnailUrl();
|
||||
this.state = ImageBodyViewModel.createInitialState(mxEvent);
|
||||
}
|
||||
|
||||
private revokeGeneratedThumbnailUrl(): void {
|
||||
if (!this.state.generatedThumbnailUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(this.state.generatedThumbnailUrl);
|
||||
this.state = {
|
||||
...this.state,
|
||||
generatedThumbnailUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
private clearBlurhashTimeout(): void {
|
||||
if (!this.blurhashTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.blurhashTimeout);
|
||||
this.blurhashTimeout = undefined;
|
||||
}
|
||||
|
||||
private scheduleBlurhashPlaceholder(): void {
|
||||
if (
|
||||
!this.props.mxEvent.getContent<ImageContent>().info?.[BLURHASH_FIELD] ||
|
||||
this.state.imgLoaded ||
|
||||
this.state.imgError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
this.blurhashTimeout = window.setTimeout(() => {
|
||||
if (this.isDisposed || this.state.imgLoaded || this.state.imgError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
placeholder: ImageBodyViewPlaceholder.BLURHASH,
|
||||
};
|
||||
this.snapshot.merge({ placeholder: ImageBodyViewPlaceholder.BLURHASH });
|
||||
}, 150);
|
||||
}
|
||||
|
||||
private getThumbUrl(): string | null {
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
const media = mediaFromContent(content);
|
||||
const info = content.info;
|
||||
|
||||
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
|
||||
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
|
||||
}
|
||||
|
||||
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
|
||||
const isLargeFileSize = info.size > 1 * 1024 * 1024;
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<void> {
|
||||
if (this.state.contentUrl || this.props.forExport) {
|
||||
return;
|
||||
}
|
||||
|
||||
let thumbUrl: string | null;
|
||||
let contentUrl: string | null;
|
||||
|
||||
if (this.props.mediaEventHelper?.media.isEncrypted) {
|
||||
try {
|
||||
[contentUrl, thumbUrl] = await Promise.all([
|
||||
this.props.mediaEventHelper.sourceUrl.value,
|
||||
this.props.mediaEventHelper.thumbnailUrl.value,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof DecryptError) {
|
||||
logger.error("Unable to decrypt attachment: ", error);
|
||||
} else if (error instanceof DownloadError) {
|
||||
logger.error("Unable to download attachment to decrypt it: ", error);
|
||||
} else {
|
||||
logger.error("Error encountered when downloading encrypted attachment: ", error);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
error: error as Error,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
contentUrl = mediaFromContent(this.props.mxEvent.getContent<ImageContent>()).srcHttp;
|
||||
thumbUrl = this.getThumbUrl();
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
let generatedThumbnailUrl: string | null = null;
|
||||
let isAnimated = (content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"];
|
||||
if (isAnimated === undefined) {
|
||||
isAnimated = mayBeAnimated(content.info?.mimetype);
|
||||
}
|
||||
|
||||
const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean;
|
||||
if (isAnimated && !autoplayGifs) {
|
||||
if (!thumbUrl || !content.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
|
||||
const image = document.createElement("img");
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
image.onload = (): void => resolve();
|
||||
image.onerror = (): void => reject(new Error("Unable to load image"));
|
||||
});
|
||||
|
||||
image.crossOrigin = "Anonymous";
|
||||
image.src = contentUrl ?? "";
|
||||
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch (error) {
|
||||
logger.error("Unable to download attachment: ", error);
|
||||
this.state = {
|
||||
...this.state,
|
||||
error: error as Error,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
(content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"] ===
|
||||
false ||
|
||||
(this.props.mediaEventHelper &&
|
||||
(await blobIsAnimated(await this.props.mediaEventHelper.sourceBlob.value)) === false)
|
||||
) {
|
||||
isAnimated = false;
|
||||
}
|
||||
|
||||
if (isAnimated) {
|
||||
const thumbnail = await createThumbnail(
|
||||
image,
|
||||
image.width,
|
||||
image.height,
|
||||
content.info?.mimetype ?? "image/jpeg",
|
||||
false,
|
||||
);
|
||||
generatedThumbnailUrl = URL.createObjectURL(thumbnail.thumbnail);
|
||||
thumbUrl = generatedThumbnailUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Unable to generate thumbnail for animated image: ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isDisposed) {
|
||||
if (generatedThumbnailUrl) {
|
||||
URL.revokeObjectURL(generatedThumbnailUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.revokeGeneratedThumbnailUrl();
|
||||
this.state = {
|
||||
...this.state,
|
||||
contentUrl,
|
||||
thumbUrl,
|
||||
isAnimated,
|
||||
error: null,
|
||||
generatedThumbnailUrl,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
private openImageViewer(event: MouseEvent<HTMLAnchorElement>): void {
|
||||
if (event.button !== 0 || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.props.mediaVisible) {
|
||||
this.props.setMediaVisible?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
let httpUrl = this.state.contentUrl;
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
|
||||
) {
|
||||
httpUrl = this.state.thumbUrl;
|
||||
}
|
||||
|
||||
if (!httpUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
||||
if (content.info) {
|
||||
params.width = content.info.w;
|
||||
params.height = content.info.h;
|
||||
params.fileSize = content.info.size;
|
||||
}
|
||||
|
||||
if (this.props.imageRef.current) {
|
||||
const clientRect = this.props.imageRef.current.getBoundingClientRect();
|
||||
params.thumbnailInfo = {
|
||||
width: clientRect.width,
|
||||
height: clientRect.height,
|
||||
positionX: clientRect.x,
|
||||
positionY: clientRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}
|
||||
|
||||
public onLinkClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
this.openImageViewer(event);
|
||||
};
|
||||
|
||||
public onHiddenButtonClick = (): void => {
|
||||
this.props.setMediaVisible?.(true);
|
||||
};
|
||||
|
||||
public onImageError = (): void => {
|
||||
if (this.state.thumbUrl && this.state.thumbUrl !== this.state.contentUrl) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
thumbUrl: null,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
if (this.state.imgError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imgError: true,
|
||||
};
|
||||
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.updateSnapshotFromState();
|
||||
};
|
||||
|
||||
public onImageLoad = (): void => {
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
let loadedImageDimensions: LoadedImageDimensions | undefined;
|
||||
if (this.props.imageRef.current) {
|
||||
const { naturalWidth, naturalHeight } = this.props.imageRef.current;
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imgLoaded: true,
|
||||
loadedImageDimensions,
|
||||
placeholder: ImageBodyViewPlaceholder.NONE,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
};
|
||||
|
||||
public setEvent(mxEvent: MatrixEvent, mediaEventHelper?: MediaEventHelper): void {
|
||||
if (this.props.mxEvent === mxEvent && this.props.mediaEventHelper === mediaEventHelper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisible = this.props.mediaVisible;
|
||||
this.props = {
|
||||
...this.props,
|
||||
mxEvent,
|
||||
mediaEventHelper,
|
||||
};
|
||||
this.resetState(mxEvent);
|
||||
this.updateSnapshotFromState();
|
||||
|
||||
if (previousVisible) {
|
||||
this.scheduleBlurhashPlaceholder();
|
||||
void this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public setForExport(forExport?: boolean): void {
|
||||
if (this.props.forExport === forExport) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
forExport,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
public setMaxImageHeight(maxImageHeight?: number): void {
|
||||
if (this.props.maxImageHeight === maxImageHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
maxImageHeight,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
public setMediaVisible(mediaVisible: boolean): void {
|
||||
if (this.props.mediaVisible === mediaVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasVisible = this.props.mediaVisible;
|
||||
this.props = {
|
||||
...this.props,
|
||||
mediaVisible,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
|
||||
if (!wasVisible && mediaVisible) {
|
||||
this.scheduleBlurhashPlaceholder();
|
||||
void this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public setPermalinkCreator(permalinkCreator?: RoomPermalinkCreator): void {
|
||||
if (this.props.permalinkCreator === permalinkCreator) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
permalinkCreator,
|
||||
};
|
||||
}
|
||||
|
||||
public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void {
|
||||
if (this.props.timelineRenderingType === timelineRenderingType) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
timelineRenderingType,
|
||||
};
|
||||
this.snapshot.merge(ImageBodyViewModel.computeSnapshot(this.props, this.state));
|
||||
}
|
||||
|
||||
public setSetMediaVisible(setMediaVisible?: (visible: boolean) => void): void {
|
||||
if (this.props.setMediaVisible === setMediaVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
setMediaVisible,
|
||||
};
|
||||
}
|
||||
|
||||
private setImageSize(imageSize: ImageSize): void {
|
||||
if (this.state.imageSize === imageSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imageSize,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearBlurhashTimeout();
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.revokeGeneratedThumbnailUrl();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -20,17 +20,26 @@ import {
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import {
|
||||
DecryptionFailureBodyFactory,
|
||||
FileBodyFactory,
|
||||
ImageBodyFactory,
|
||||
RedactedBodyFactory,
|
||||
VideoBodyFactory,
|
||||
renderMBody,
|
||||
} from "../../../../../src/components/views/messages/MBodyFactory";
|
||||
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
|
||||
|
||||
jest.mock("matrix-encrypt-attachment", () => ({
|
||||
decryptAttachment: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
|
||||
__esModule: true,
|
||||
useMediaVisible: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MBodyFactory", () => {
|
||||
const userId = "@user:server";
|
||||
const deviceId = "DEADB33F";
|
||||
@ -59,7 +68,7 @@ describe("MBodyFactory", () => {
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
|
||||
};
|
||||
const mkEvent = (msgtype?: string): MatrixEvent =>
|
||||
const mkEvent = (msgtype?: string, content: Record<string, unknown> = {}): MatrixEvent =>
|
||||
new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: userId,
|
||||
@ -68,13 +77,26 @@ describe("MBodyFactory", () => {
|
||||
body: "alt",
|
||||
...(msgtype ? { msgtype } : {}),
|
||||
url: "mxc://server/file",
|
||||
...content,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
||||
jest.mocked(useMediaVisible).mockReturnValue([true, jest.fn()]);
|
||||
});
|
||||
|
||||
const encryptedImageHelper = (): MediaEventHelper =>
|
||||
({
|
||||
media: { isEncrypted: true },
|
||||
sourceUrl: { value: Promise.resolve("blob:source") },
|
||||
thumbnailUrl: { value: Promise.resolve("blob:thumbnail") },
|
||||
sourceBlob: {
|
||||
value: Promise.resolve(new Blob(["image"], { type: "image/jpeg" })),
|
||||
cachedValue: new Blob(["image"], { type: "image/jpeg" }),
|
||||
},
|
||||
}) as unknown as MediaEventHelper;
|
||||
|
||||
describe("renderMBody", () => {
|
||||
it("renders download button for m.file in file rendering type", () => {
|
||||
const mediaEvent = mkEvent("m.file");
|
||||
@ -102,6 +124,10 @@ describe("MBodyFactory", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory);
|
||||
});
|
||||
|
||||
it("returns the image body factory for m.image", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent("m.image") })?.type).toBe(ImageBodyFactory);
|
||||
});
|
||||
|
||||
it("returns null when msgtype is missing", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
|
||||
});
|
||||
@ -156,4 +182,160 @@ describe("MBodyFactory", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
describe("ImageBodyFactory", () => {
|
||||
const imageContent = {
|
||||
info: {
|
||||
mimetype: "image/jpeg",
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("renders the shared image view in room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.image", imageContent);
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the file fallback child in notification timelines", () => {
|
||||
const mediaEvent = mkEvent("m.image", imageContent);
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only a file body for encrypted unsafe images without thumbnails", () => {
|
||||
const mediaEvent = mkEvent("m.image", {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
},
|
||||
});
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={{ media: { isEncrypted: true } } as MediaEventHelper}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("button", { name: "alt" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the image body for encrypted unsafe images when a thumbnail is available", () => {
|
||||
const mediaEvent = mkEvent("m.image", {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
thumbnail_info: { mimetype: "image/jpeg" },
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory {...props} mxEvent={mediaEvent} mediaEventHelper={encryptedImageHelper()} />
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoBodyFactory", () => {
|
||||
const videoContent = {
|
||||
info: {
|
||||
mimetype: "video/mp4",
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("renders without a file fallback in room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.video", videoContent);
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<VideoBodyFactory
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
forExport={false}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the file fallback child outside room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.video", videoContent);
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
|
||||
<VideoBodyFactory
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
forExport={false}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the redacted body wrapper", () => {
|
||||
const mediaEvent = mkEvent("m.text");
|
||||
|
||||
const { container } = render(<RedactedBodyFactory mxEvent={mediaEvent} />);
|
||||
|
||||
expect(container.querySelector(".mx_RedactedBody")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the decryption failure body wrapper", () => {
|
||||
const mediaEvent = mkEvent("m.text");
|
||||
Object.defineProperty(mediaEvent, "decryptionFailureReason", {
|
||||
configurable: true,
|
||||
value: "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
});
|
||||
|
||||
const { container } = render(<DecryptionFailureBodyFactory mxEvent={mediaEvent} />);
|
||||
|
||||
expect(container.querySelector(".mx_DecryptionFailureBody")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,376 +0,0 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2022 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 from "react";
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within } from "jest-matrix-react";
|
||||
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
import encrypt from "matrix-encrypt-attachment";
|
||||
import { mocked } from "jest-mock";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import MImageBody from "../../../../../src/components/views/messages/MImageBody";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsCrypto,
|
||||
mockClientMethodsDevice,
|
||||
mockClientMethodsServer,
|
||||
mockClientMethodsUser,
|
||||
withClientContextRenderOptions,
|
||||
} from "../../../../test-utils";
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
|
||||
|
||||
jest.mock("matrix-encrypt-attachment", () => ({
|
||||
decryptAttachment: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<MImageBody/>", () => {
|
||||
const ourUserId = "@user:server";
|
||||
const senderUserId = "@other_use:server";
|
||||
const deviceId = "DEADB33F";
|
||||
const cli = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(ourUserId),
|
||||
...mockClientMethodsServer(),
|
||||
...mockClientMethodsDevice(deviceId),
|
||||
...mockClientMethodsCrypto(),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getRoom: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": true,
|
||||
"org.matrix.msc3886": true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const url = "https://server/_matrix/media/v3/download/server/encrypted-image";
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
cli.mxcUrlToHttp.mockImplementation(
|
||||
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
|
||||
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
|
||||
},
|
||||
);
|
||||
const encryptedMediaEvent = new MatrixEvent({
|
||||
event_id: "$foo:bar",
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/png",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-image",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = {
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room(encryptedMediaEvent.getRoomId()!, cli, cli.getUserId()!)),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
mocked(encrypt.decryptAttachment).mockReset();
|
||||
});
|
||||
|
||||
it("should show a thumbnail while image is being downloaded", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
// thumbnail with dimensions present
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show error when encrypted media cannot be downloaded", async () => {
|
||||
fetchMock.getOnce(url, { status: 500 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveFetched(url);
|
||||
|
||||
await screen.findByText("Error downloading image");
|
||||
});
|
||||
|
||||
it("should show error when encrypted media cannot be decrypted", async () => {
|
||||
fetchMock.getOnce(url, "thisistotallyanencryptedpng");
|
||||
mocked(encrypt.decryptAttachment).mockRejectedValue(new Error("Failed to decrypt"));
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
await screen.findByText("Error decrypting image");
|
||||
});
|
||||
|
||||
describe("with image previews/thumbnails disabled", () => {
|
||||
beforeEach(() => {
|
||||
const origFn = SettingsStore.getValue;
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
|
||||
if (setting === "mediaPreviewConfig") {
|
||||
return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off };
|
||||
}
|
||||
return origFn(setting, ...args);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not download image", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show image")).toBeInTheDocument();
|
||||
|
||||
expect(fetchMock).toHaveFetchedTimes(0, url);
|
||||
});
|
||||
|
||||
it("should render hidden image placeholder", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show image")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(fetchMock).toHaveFetched(url);
|
||||
|
||||
// Show image is asynchronous since it applies through a settings watcher hook, so
|
||||
// be sure to wait here.
|
||||
await waitFor(() => {
|
||||
// spinner while downloading image
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to /download/ if /thumbnail/ fails", async () => {
|
||||
const thumbUrl = "https://server/_matrix/media/v3/thumbnail/server/image?width=800&height=600&method=scale";
|
||||
const downloadUrl = "https://server/_matrix/media/v3/download/server/image";
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
|
||||
expect(img).toHaveProperty("src", thumbUrl);
|
||||
|
||||
fireEvent.error(img);
|
||||
expect(img).toHaveProperty("src", downloadUrl);
|
||||
});
|
||||
|
||||
it("should generate a thumbnail if one isn't included for animated media", async () => {
|
||||
Object.defineProperty(global.Image.prototype, "src", {
|
||||
set(src) {
|
||||
window.setTimeout(() => this.onload?.());
|
||||
},
|
||||
});
|
||||
Object.defineProperty(global.Image.prototype, "height", {
|
||||
get() {
|
||||
return 600;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(global.Image.prototype, "width", {
|
||||
get() {
|
||||
return 800;
|
||||
},
|
||||
});
|
||||
|
||||
mocked(global.URL.createObjectURL).mockReturnValue("blob:generated-thumb");
|
||||
|
||||
fetchMock.getOnce("https://server/_matrix/media/v3/download/server/image", {
|
||||
body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")),
|
||||
});
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/webp",
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
// Wait for spinners to go away
|
||||
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
|
||||
// thumbnail with dimensions present
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show banner on hover", async () => {
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
|
||||
await userEvent.hover(img);
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody_banner")).toHaveTextContent("...alt for a test image");
|
||||
});
|
||||
|
||||
it("should render MFileBody for svg with no thumbnail", async () => {
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, asFragment } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MFileBody")).toHaveTextContent("Attachment");
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should open ImageView using thumbnail for encrypted svg", async () => {
|
||||
const url = "https://server/_matrix/media/v3/download/server/encrypted-svg";
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
const thumbUrl = "https://server/_matrix/media/v3/download/server/svg-thumbnail";
|
||||
fetchMock.getOnce(thumbUrl, { status: 200 });
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
origin_server_ts: 1234567890,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_file: {
|
||||
url: "mxc://server/svg-thumbnail",
|
||||
},
|
||||
thumbnail_info: { mimetype: "image/png" },
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mediaEventHelper = new MediaEventHelper(event);
|
||||
mediaEventHelper.thumbnailUrl["prom"] = Promise.resolve(thumbUrl);
|
||||
mediaEventHelper.sourceUrl["prom"] = Promise.resolve(url);
|
||||
|
||||
const { findByRole } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={mediaEventHelper} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole("link"));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await expect(within(dialog).findByRole("img")).resolves.toHaveAttribute(
|
||||
"src",
|
||||
"https://server/_matrix/media/v3/download/server/svg-thumbnail",
|
||||
);
|
||||
expect(dialog).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,620 @@
|
||||
/*
|
||||
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, { createRef } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import { ClientEvent, EventType, getHttpUriForMxc, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import Modal from "../../../../../src/Modal";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { ImageSize } from "../../../../../src/settings/enums/ImageSize";
|
||||
import { mediaFromContent } from "../../../../../src/customisations/Media";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../../../../src/utils/image-media";
|
||||
import { blobIsAnimated } from "../../../../../src/utils/Image";
|
||||
import { DecryptError, DownloadError } from "../../../../../src/utils/DecryptFile";
|
||||
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||
import MImageReplyBody, { ImageBodyBaseInner } from "../../../../../src/components/views/messages/MImageReplyBody";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsCrypto,
|
||||
mockClientMethodsDevice,
|
||||
mockClientMethodsServer,
|
||||
mockClientMethodsUser,
|
||||
} from "../../../../test-utils";
|
||||
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
|
||||
|
||||
jest.mock("../../../../../src/customisations/Media", () => ({
|
||||
mediaFromContent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/Image", () => ({
|
||||
...jest.requireActual("../../../../../src/utils/Image"),
|
||||
blobIsAnimated: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/image-media", () => ({
|
||||
...jest.requireActual("../../../../../src/utils/image-media"),
|
||||
createThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
|
||||
__esModule: true,
|
||||
useMediaVisible: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<MImageReplyBody />", () => {
|
||||
const userId = "@user:server";
|
||||
const deviceId = "DEADB33F";
|
||||
const cli = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
...mockClientMethodsServer(),
|
||||
...mockClientMethodsDevice(deviceId),
|
||||
...mockClientMethodsCrypto(),
|
||||
getRoom: jest.fn(),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": true,
|
||||
"org.matrix.msc3886": true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
cli.mxcUrlToHttp.mockImplementation(
|
||||
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
|
||||
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
|
||||
},
|
||||
);
|
||||
|
||||
const mockedMediaFromContent = jest.mocked(mediaFromContent);
|
||||
const mockedUseMediaVisible = jest.mocked(useMediaVisible);
|
||||
const mockedBlobIsAnimated = jest.mocked(blobIsAnimated);
|
||||
const mockedCreateThumbnail = jest.mocked(createThumbnail);
|
||||
const originalGetValue = SettingsStore.getValue.bind(SettingsStore);
|
||||
|
||||
const createEvent = ({
|
||||
body = "demo image",
|
||||
content = {},
|
||||
}: {
|
||||
body?: string;
|
||||
content?: Record<string, unknown>;
|
||||
} = {}): MatrixEvent => {
|
||||
const { info: infoOverride, ...restContent } = content;
|
||||
const info =
|
||||
infoOverride === null
|
||||
? undefined
|
||||
: {
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
mimetype: "image/jpeg",
|
||||
...(infoOverride as Record<string, unknown> | undefined),
|
||||
};
|
||||
|
||||
return new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
room_id: "!room:server",
|
||||
event_id: "$image:server",
|
||||
sender: userId,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body,
|
||||
url: "mxc://server/image",
|
||||
...restContent,
|
||||
...(info ? { info } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createMockMedia = (content: Record<string, any>) => ({
|
||||
isEncrypted: !!content.file,
|
||||
srcMxc: content.url ?? content.file?.url ?? "mxc://server/image",
|
||||
srcHttp: "https://server/full.png",
|
||||
thumbnailMxc: content.info?.thumbnail_url ?? "mxc://server/thumb",
|
||||
thumbnailHttp: "https://server/thumb.png",
|
||||
hasThumbnail: content.info?.thumbnail_url !== null,
|
||||
getThumbnailHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
|
||||
getThumbnailOfSourceHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
|
||||
getSquareThumbnailHttp: jest.fn(),
|
||||
downloadSource: jest.fn(),
|
||||
});
|
||||
|
||||
const createMediaEventHelper = ({
|
||||
encrypted = true,
|
||||
thumbnailUrl = "blob:thumbnail",
|
||||
sourceUrl = "blob:source",
|
||||
sourceBlob = new Blob(["image"], { type: "image/jpeg" }),
|
||||
}: {
|
||||
encrypted?: boolean;
|
||||
thumbnailUrl?: string | null | Promise<string | null>;
|
||||
sourceUrl?: string | null | Promise<string | null>;
|
||||
sourceBlob?: Blob | Promise<Blob>;
|
||||
} = {}): MediaEventHelper =>
|
||||
({
|
||||
media: { isEncrypted: encrypted },
|
||||
thumbnailUrl: { value: Promise.resolve(thumbnailUrl) },
|
||||
sourceUrl: { value: Promise.resolve(sourceUrl) },
|
||||
sourceBlob: { value: Promise.resolve(sourceBlob), cachedValue: sourceBlob },
|
||||
}) as unknown as MediaEventHelper;
|
||||
|
||||
const props = {
|
||||
mxEvent: createEvent(),
|
||||
mediaVisible: true,
|
||||
setMediaVisible: jest.fn(),
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
|
||||
};
|
||||
|
||||
const renderBase = ({
|
||||
timelineRenderingType = TimelineRenderingType.Room,
|
||||
overrides = {},
|
||||
}: {
|
||||
timelineRenderingType?: TimelineRenderingType;
|
||||
overrides?: Partial<React.ComponentProps<typeof ImageBodyBaseInner>>;
|
||||
} = {}) => {
|
||||
const ref = createRef<ImageBodyBaseInner>();
|
||||
const result = render(
|
||||
<RoomContext.Provider value={{ timelineRenderingType } as any}>
|
||||
<ImageBodyBaseInner ref={ref} {...props} {...overrides} />
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
return { ...result, ref };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
mockedMediaFromContent.mockImplementation((content: Record<string, any>) => createMockMedia(content) as any);
|
||||
mockedUseMediaVisible.mockReturnValue([true, jest.fn()]);
|
||||
mockedBlobIsAnimated.mockResolvedValue(true);
|
||||
mockedCreateThumbnail.mockResolvedValue({ thumbnail: new Blob(["thumbnail"], { type: "image/jpeg" }) } as any);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(((setting, ...args) => {
|
||||
if (setting === "Images.size") return ImageSize.Normal;
|
||||
if (setting === "autoplayGifs") return false;
|
||||
return (originalGetValue as any)(setting, ...args);
|
||||
}) as typeof SettingsStore.getValue);
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("image-reply-watch");
|
||||
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a visible unencrypted image and file fallback outside room timelines", async () => {
|
||||
const { container } = renderBase({ timelineRenderingType: TimelineRenderingType.Notification });
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("img", { name: "demo image" })).toHaveLength(2));
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(container.querySelector("a[href='https://server/full.png']")).not.toBeNull();
|
||||
expect(container.querySelector("img.mx_MImageBody_thumbnail")).toHaveAttribute(
|
||||
"src",
|
||||
"https://server/thumb.png",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals hidden media through the supplied setter", () => {
|
||||
const setMediaVisible = jest.fn();
|
||||
renderBase({
|
||||
overrides: {
|
||||
mediaVisible: false,
|
||||
setMediaVisible,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show image" }));
|
||||
|
||||
expect(setMediaVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("opens the image viewer with thumbnail geometry", async () => {
|
||||
const { container } = renderBase();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
|
||||
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
|
||||
image.getBoundingClientRect = () => ({ width: 100, height: 80, x: 10, y: 20 }) as DOMRect;
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "demo image" }), { button: 0 });
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
src: "https://server/full.png",
|
||||
name: "demo image",
|
||||
width: 320,
|
||||
height: 240,
|
||||
fileSize: 48_000,
|
||||
thumbnailInfo: {
|
||||
width: 100,
|
||||
height: 80,
|
||||
positionX: 10,
|
||||
positionY: 20,
|
||||
},
|
||||
}),
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates load dimensions and toggles hover/focus banner state", async () => {
|
||||
const { container, ref } = renderBase();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
|
||||
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
|
||||
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageLoad"]();
|
||||
ref.current!.setState({ isAnimated: true, imgLoaded: true });
|
||||
});
|
||||
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
|
||||
|
||||
fireEvent.mouseEnter(image);
|
||||
expect(ref.current!.state.hover).toBe(true);
|
||||
expect(container.querySelector(".mx_MImageBody_banner")).not.toBeNull();
|
||||
expect(image).toHaveAttribute("src", "https://server/full.png");
|
||||
|
||||
fireEvent.mouseLeave(image);
|
||||
expect(ref.current!.state.hover).toBe(false);
|
||||
|
||||
const link = screen.getByRole("link", { name: /demo image/ });
|
||||
fireEvent.focus(link);
|
||||
expect(ref.current!.state.focus).toBe(true);
|
||||
fireEvent.blur(link);
|
||||
expect(ref.current!.state.focus).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the decrypted thumbnail in the image viewer when the source mime type is unsafe", async () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
body: "unsafe image",
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_info: { mimetype: "image/jpeg" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: "blob:unsafe-source",
|
||||
thumbnailUrl: "blob:safe-thumbnail",
|
||||
sourceBlob: new Blob(["html"], { type: "text/html" }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "unsafe image" })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "unsafe image" }), { button: 0 });
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
src: "blob:safe-thumbnail",
|
||||
name: "unsafe image",
|
||||
}),
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from thumbnail errors and clears image errors after reconnecting", async () => {
|
||||
const onSpy = jest.spyOn(cli, "on");
|
||||
const offSpy = jest.spyOn(cli, "off");
|
||||
const { ref } = renderBase();
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageError"]();
|
||||
});
|
||||
expect(ref.current!.state.thumbUrl).toBeNull();
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageError"]();
|
||||
});
|
||||
expect(ref.current!.state.imgError).toBe(true);
|
||||
expect(onSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
|
||||
|
||||
const listener = onSpy.mock.calls.at(-1)![1] as (...args: unknown[]) => void;
|
||||
act(() => {
|
||||
listener(SyncState.Syncing, SyncState.Error);
|
||||
});
|
||||
|
||||
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, listener);
|
||||
expect(ref.current!.state.imgError).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
|
||||
[new DownloadError(new Error("download failed")), "Error downloading image"],
|
||||
[new Error("display failed"), "Unable to show image due to error"],
|
||||
])("renders media processing errors for %s", async (error, label) => {
|
||||
const { container, ref } = renderBase();
|
||||
|
||||
act(() => {
|
||||
ref.current!.setState({ error });
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
|
||||
[new DownloadError(new Error("download failed")), "Error downloading image"],
|
||||
[new Error("download failed"), "Unable to show image due to error"],
|
||||
])("renders encrypted download failures for %s", async (error, label) => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: Promise.reject(error),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(label)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders export images directly from the event MXC URL", () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
forExport: true,
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
url: undefined,
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute(
|
||||
"href",
|
||||
"mxc://server/encrypted-image",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute("target", "_blank");
|
||||
expect(screen.queryByRole("link", { name: /Download/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("switches blurhash placeholders on after the delay", () => {
|
||||
jest.useFakeTimers();
|
||||
const { container } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
info: {
|
||||
[BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_Blurhash")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_Blurhash")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("downloads media when visibility changes after mount", async () => {
|
||||
const ref = createRef<ImageBodyBaseInner>();
|
||||
const mxEvent = createEvent();
|
||||
const { rerender } = render(
|
||||
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
|
||||
<ImageBodyBaseInner
|
||||
ref={ref}
|
||||
{...props}
|
||||
mxEvent={mxEvent}
|
||||
mediaVisible={false}
|
||||
setMediaVisible={jest.fn()}
|
||||
/>
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
expect(ref.current!.state.contentUrl).toBeNull();
|
||||
|
||||
rerender(
|
||||
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
|
||||
<ImageBodyBaseInner
|
||||
ref={ref}
|
||||
{...props}
|
||||
mxEvent={mxEvent}
|
||||
mediaVisible={true}
|
||||
setMediaVisible={jest.fn()}
|
||||
/>
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.contentUrl).toBe("https://server/full.png"));
|
||||
});
|
||||
|
||||
it("renders missing-size media after loading natural dimensions", async () => {
|
||||
const { container, ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({ content: { info: null } }),
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(container.querySelector("img[style*='display: none']")).not.toBeNull());
|
||||
const image = container.querySelector("img[style*='display: none']") as HTMLImageElement;
|
||||
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageLoad"]();
|
||||
});
|
||||
|
||||
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
|
||||
expect(container.querySelector(".mx_MImageBody_thumbnail_container")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("generates a static thumbnail for animated images without a safe thumbnail", async () => {
|
||||
let createdImage: any;
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
|
||||
if (tagName !== "img") {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
createdImage = originalCreateElement(tagName) as HTMLImageElement;
|
||||
Object.defineProperty(createdImage, "width", { configurable: true, value: 320 });
|
||||
Object.defineProperty(createdImage, "height", { configurable: true, value: 240 });
|
||||
return createdImage;
|
||||
}) as typeof document.createElement);
|
||||
const { ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
info: {
|
||||
"mimetype": "image/gif",
|
||||
"thumbnail_info": { mimetype: "image/gif" },
|
||||
"org.matrix.msc4230.is_animated": true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: "blob:animated-source",
|
||||
thumbnailUrl: null,
|
||||
sourceBlob: new Blob(["gif"], { type: "image/gif" }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createdImage).toBeDefined());
|
||||
await act(async () => {
|
||||
createdImage.onload();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("blob"));
|
||||
expect(mockedBlobIsAnimated).toHaveBeenCalled();
|
||||
expect(mockedCreateThumbnail).toHaveBeenCalledWith(expect.any(HTMLImageElement), 320, 240, "image/gif", false);
|
||||
expect(ref.current!.state.isAnimated).toBe(true);
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses SVG thumbnails when available", async () => {
|
||||
const { ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
info: {
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_url: "mxc://server/thumb",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
|
||||
|
||||
expect(
|
||||
mockedMediaFromContent.mock.results.some((result: any) =>
|
||||
result.value.getThumbnailHttp.mock.calls.some(
|
||||
(call: unknown[]) => call[0] === 800 && call[1] === 600 && call[2] === "scale",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the full source as thumbnail for small high-dpi images", async () => {
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value: 2,
|
||||
});
|
||||
|
||||
const { ref } = renderBase();
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/full.png"));
|
||||
});
|
||||
|
||||
it("renders the file body instead of unsafe encrypted images without thumbnails", () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: {
|
||||
media: { isEncrypted: true },
|
||||
sourceUrl: { value: Promise.resolve("blob:source") },
|
||||
thumbnailUrl: { value: Promise.resolve(null) },
|
||||
sourceBlob: {
|
||||
value: Promise.resolve(new Blob(["html"], { type: "text/html" })),
|
||||
cachedValue: new Blob(["html"], { type: "text/html" }),
|
||||
},
|
||||
} as unknown as MediaEventHelper,
|
||||
mediaVisible: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /demo image/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "demo image" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the compact reply body through the hook wrapper", async () => {
|
||||
const setMediaVisible = jest.fn();
|
||||
mockedUseMediaVisible.mockReturnValue([true, setMediaVisible]);
|
||||
|
||||
const { container } = render(<MImageReplyBody {...props} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".mx_MImageReplyBody")).not.toBeNull());
|
||||
expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cleans up settings watchers, listeners and generated animated thumbnails on unmount", async () => {
|
||||
const offSpy = jest.spyOn(cli, "off");
|
||||
const { ref, unmount } = renderBase();
|
||||
await waitFor(() => expect(ref.current).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
ref.current!.setState({
|
||||
isAnimated: true,
|
||||
thumbUrl: "blob:animated-thumbnail",
|
||||
});
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("image-reply-watch");
|
||||
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:animated-thumbnail");
|
||||
});
|
||||
});
|
||||
@ -20,15 +20,11 @@ import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permal
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import { Mjolnir } from "../../../../../src/mjolnir/Mjolnir";
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="image-body" />,
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
|
||||
__esModule: true,
|
||||
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
||||
FileBodyFactory: () => <div data-testid="file-body" />,
|
||||
ImageBodyFactory: () => <div data-testid="image-body" />,
|
||||
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
||||
VideoBodyFactory: () => <video data-testid="video-body" />,
|
||||
renderMBody: () => <div data-testid="file-body" />,
|
||||
|
||||
@ -1,379 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<MImageBody/> should generate a thumbnail if one isn't included for animated media 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_MImageBody"
|
||||
>
|
||||
<a
|
||||
href="https://server/_matrix/media/v3/download/server/image"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_thumbnail_container"
|
||||
style="max-height: 50px; max-width: 40px; aspect-ratio: 40/50;"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_placeholder"
|
||||
>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="max-height: 50px; max-width: 40px;"
|
||||
>
|
||||
<img
|
||||
alt="alt for a test image"
|
||||
class="mx_MImageBody_thumbnail"
|
||||
src="blob:generated-thumb"
|
||||
/>
|
||||
<p
|
||||
class="mx_MImageBody_gifLabel"
|
||||
>
|
||||
GIF
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should open ImageView using thumbnail for encrypted svg 1`] = `
|
||||
<div
|
||||
aria-label="Image view"
|
||||
class="mx_ImageView"
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_panel"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_info_wrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Profile picture"
|
||||
aria-live="off"
|
||||
class="_avatar_va14e_8 mx_BaseAvatar mx_Dialog_nonDialogButton _avatar-imageless_va14e_55"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="button"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
o
|
||||
</button>
|
||||
<div
|
||||
class="mx_ImageView_info"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_info_sender"
|
||||
>
|
||||
@other_use:server
|
||||
</div>
|
||||
<a
|
||||
aria-live="off"
|
||||
class="mx_MessageTimestamp _content_1r034_8"
|
||||
href="https://matrix.to/#/!room:server/undefined"
|
||||
>
|
||||
Thu, Jan 15, 1970, 06:56
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_title"
|
||||
>
|
||||
Image
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Zoom out"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.5 9.5q.425 0 .713.287.288.288.287.713a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287h-6a.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713q0-.425.287-.713A.97.97 0 0 1 7.5 9.5z"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10.5 3a7.5 7.5 0 0 1 5.963 12.049l3.244 3.244a1 1 0 1 1-1.414 1.414l-3.244-3.244A7.5 7.5 0 1 1 10.5 3m0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Zoom in"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#cpd_ZoomInIcon_a)"
|
||||
>
|
||||
<path
|
||||
d="M10.5 6.5q.425 0 .713.287.288.288.287.713v2h2q.425 0 .713.287.288.288.287.713a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287h-2v2a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713v-2h-2a.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713q0-.425.287-.713A.97.97 0 0 1 7.5 9.5h2v-2q0-.425.287-.713A.97.97 0 0 1 10.5 6.5"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10.5 3a7.5 7.5 0 0 1 5.963 12.049l3.244 3.244a1 1 0 1 1-1.414 1.414l-3.244-3.244A7.5 7.5 0 1 1 10.5 3m0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
|
||||
/>
|
||||
<path
|
||||
d="M7.875 11.375h1.75v1.75q0 .372.252.623A.85.85 0 0 0 10.5 14a.85.85 0 0 0 .623-.252.85.85 0 0 0 .252-.623v-1.75h1.75a.85.85 0 0 0 .623-.252A.85.85 0 0 0 14 10.5a.85.85 0 0 0-.252-.623.85.85 0 0 0-.623-.252h-1.75v-1.75a.85.85 0 0 0-.252-.623A.85.85 0 0 0 10.5 7a.85.85 0 0 0-.623.252.85.85 0 0 0-.252.623v1.75h-1.75a.85.85 0 0 0-.623.252A.85.85 0 0 0 7 10.5q0 .372.252.623a.85.85 0 0 0 .623.252"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="cpd_ZoomInIcon_a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Rotate Left"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.56 7.98C6.1 7.52 5.31 7.6 5 8.17c-.28.51-.5 1.03-.67 1.58-.19.63.31 1.25.96 1.25h.01c.43 0 .82-.28.94-.7q.18-.6.48-1.17c.22-.37.15-.84-.16-1.15M5.31 13h-.02c-.65 0-1.15.62-.96 1.25.16.54.38 1.07.66 1.58.31.57 1.11.66 1.57.2.3-.31.38-.77.17-1.15-.2-.37-.36-.76-.48-1.16a.97.97 0 0 0-.94-.72m2.85 6.02q.765.42 1.59.66c.62.18 1.24-.32 1.24-.96v-.03c0-.43-.28-.82-.7-.94-.4-.12-.78-.28-1.15-.48a.97.97 0 0 0-1.16.17l-.03.03c-.45.45-.36 1.24.21 1.55M13 4.07v-.66c0-.89-1.08-1.34-1.71-.71L9.17 4.83c-.4.4-.4 1.04 0 1.43l2.13 2.08c.63.62 1.7.17 1.7-.72V6.09c2.84.48 5 2.94 5 5.91 0 2.73-1.82 5.02-4.32 5.75a.97.97 0 0 0-.68.94v.02c0 .65.61 1.14 1.23.96A7.976 7.976 0 0 0 20 12c0-4.08-3.05-7.44-7-7.93"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Rotate Right"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#cpd_RotateRightIcon_a)"
|
||||
>
|
||||
<path
|
||||
d="M14.83 4.83 12.7 2.7c-.62-.62-1.7-.18-1.7.71v.66C7.06 4.56 4 7.92 4 12c0 3.64 2.43 6.71 5.77 7.68.62.18 1.23-.32 1.23-.96v-.03a.97.97 0 0 0-.68-.94A5.98 5.98 0 0 1 6 12c0-2.97 2.16-5.43 5-5.91v1.53c0 .89 1.07 1.33 1.7.71l2.13-2.08a.99.99 0 0 0 0-1.42m4.84 4.93q-.24-.825-.66-1.59c-.31-.57-1.1-.66-1.56-.2l-.01.01c-.31.31-.38.78-.17 1.16.2.37.36.76.48 1.16.12.42.51.7.94.7h.02c.65 0 1.15-.62.96-1.24M13 18.68v.02c0 .65.62 1.14 1.24.96q.825-.24 1.59-.66c.57-.31.66-1.1.2-1.56l-.02-.02a.97.97 0 0 0-1.16-.17c-.37.21-.76.37-1.16.49-.41.12-.69.51-.69.94m4.44-2.65c.46.46 1.25.37 1.56-.2.28-.51.5-1.04.67-1.59.18-.62-.31-1.24-.96-1.24h-.02c-.44 0-.82.28-.94.7q-.18.6-.48 1.17c-.21.38-.13.86.17 1.16"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="cpd_RotateRightIcon_a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Download"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_more"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Close"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_close"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_image_wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Attachment"
|
||||
class="mx_ImageView_image "
|
||||
draggable="true"
|
||||
src="https://server/_matrix/media/v3/download/server/svg-thumbnail"
|
||||
style="transform: translateX(-512px)
|
||||
translateY(NaNpx)
|
||||
scale(0)
|
||||
rotate(0deg); cursor: zoom-out;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should render MFileBody for svg with no thumbnail 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
class="_content_1t2mx_8 mx_MFileBody"
|
||||
>
|
||||
<div
|
||||
class="mx_MediaBody _mediaBody_rgndh_8"
|
||||
data-type="info"
|
||||
>
|
||||
<button
|
||||
aria-label="Attachment"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60"
|
||||
data-kind="secondary"
|
||||
data-size="md"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Attachment
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_MImageBody"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_thumbnail_container"
|
||||
style="max-height: 50px; max-width: 40px; aspect-ratio: 40/50;"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_placeholder"
|
||||
>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="max-height: 50px; max-width: 40px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
1012
apps/web/test/viewmodels/message-body/ImageBodyViewModel-test.tsx
Normal file
1012
apps/web/test/viewmodels/message-body/ImageBodyViewModel-test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -112,6 +112,38 @@ describe("ImageBodyView", () => {
|
||||
expect(onLinkClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("merges supplied class names with module classes", () => {
|
||||
const vm = new TestImageBodyViewModel({
|
||||
state: ImageBodyViewState.READY,
|
||||
alt: "Custom class image",
|
||||
src: "https://example.org/full.png",
|
||||
thumbnailSrc: "https://example.org/thumb.png",
|
||||
maxWidth: 320,
|
||||
maxHeight: 240,
|
||||
aspectRatio: "4 / 3",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ImageBodyView
|
||||
vm={vm}
|
||||
className="customRoot"
|
||||
containerClassName="customContainer"
|
||||
imageClassName="customImage"
|
||||
/>,
|
||||
);
|
||||
|
||||
const rootClassName = container.querySelector(".customRoot")?.className;
|
||||
const containerClassName = container.querySelector(".customContainer")?.className;
|
||||
const imageClassName = screen.getByRole("img", { name: "Custom class image" }).className;
|
||||
|
||||
expect(rootClassName).toContain("customRoot");
|
||||
expect(rootClassName).not.toBe("customRoot");
|
||||
expect(containerClassName).toContain("customContainer");
|
||||
expect(containerClassName).not.toBe("customContainer");
|
||||
expect(imageClassName).toContain("customImage");
|
||||
expect(imageClassName).not.toBe("customImage");
|
||||
});
|
||||
|
||||
it("swaps to the full source on hover for animated previews", async () => {
|
||||
const user = userEvent.setup();
|
||||
const vm = new TestImageBodyViewModel({
|
||||
|
||||
@ -12,6 +12,7 @@ import React, {
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactEventHandler,
|
||||
type Ref,
|
||||
useState,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
@ -148,6 +149,18 @@ interface ImageBodyViewProps {
|
||||
* Optional host CSS class.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional CSS class applied to the media frame container.
|
||||
*/
|
||||
containerClassName?: string;
|
||||
/**
|
||||
* Optional CSS class applied to the rendered image element.
|
||||
*/
|
||||
imageClassName?: string;
|
||||
/**
|
||||
* Optional ref to the rendered image element.
|
||||
*/
|
||||
imageRef?: Ref<HTMLImageElement>;
|
||||
/**
|
||||
* Optional supplemental content rendered after the media frame.
|
||||
*/
|
||||
@ -202,7 +215,14 @@ function renderPlaceholder({
|
||||
* </ImageBodyView>
|
||||
* ```
|
||||
*/
|
||||
export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyViewProps>): JSX.Element {
|
||||
export function ImageBodyView({
|
||||
vm,
|
||||
className,
|
||||
containerClassName,
|
||||
imageClassName,
|
||||
imageRef,
|
||||
children,
|
||||
}: Readonly<ImageBodyViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const {
|
||||
state,
|
||||
@ -230,6 +250,8 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
||||
const hoverOrFocus = hover || focus;
|
||||
|
||||
const rootClassName = classNames(className, styles.root);
|
||||
const resolvedContainerClassName = classNames(containerClassName, styles.thumbnailContainer);
|
||||
const resolvedImageClassName = classNames(imageClassName, styles.image);
|
||||
|
||||
if (state === ImageBodyViewState.ERROR) {
|
||||
return (
|
||||
@ -281,9 +303,10 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
||||
</div>
|
||||
) : resolvedImageSrc ? (
|
||||
<img
|
||||
className={styles.image}
|
||||
className={resolvedImageClassName}
|
||||
src={resolvedImageSrc}
|
||||
alt={alt}
|
||||
ref={imageRef}
|
||||
onError={vm.onImageError}
|
||||
onLoad={vm.onImageLoad}
|
||||
onMouseEnter={(): void => setHover(true)}
|
||||
@ -302,7 +325,7 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
||||
) : null;
|
||||
|
||||
let frame = (
|
||||
<div className={styles.thumbnailContainer} style={containerStyle}>
|
||||
<div className={resolvedContainerClassName} style={containerStyle}>
|
||||
{showPlaceholder && (
|
||||
<div
|
||||
className={classNames(styles.placeholder, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user