mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-05 22:25:36 +00: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:
@@ -208,7 +208,7 @@ test.describe("Composer", () => {
|
|||||||
});
|
});
|
||||||
await app.viewRoomByName("Bob");
|
await app.viewRoomByName("Bob");
|
||||||
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
|
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 }) => {
|
test("can paste a file", async ({ page, bot, app }) => {
|
||||||
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
|
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 }) => {
|
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 tile.getByRole("button", { name: "Reply in thread" }).click();
|
||||||
|
|
||||||
await app.composerDragAndPasteFile("thread", getSampleFilePath("riot.png"), "image/png");
|
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", () => {
|
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 }) => {
|
test("should allow upload via drag and drop", { tag: "@screenshot" }, async ({ page, app }) => {
|
||||||
await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png");
|
await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png");
|
||||||
await app.timeline.scrollToBottom();
|
await app.timeline.scrollToBottom();
|
||||||
const imgTile = page.locator(".mx_MImageBody").first();
|
const imgTile = page.locator(".mx_ImageBody").first();
|
||||||
await expect(imgTile).toBeVisible();
|
await expect(imgTile).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,12 +105,16 @@ test.describe("Custom Component API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.timeline.scrollToBottom();
|
await app.timeline.scrollToBottom();
|
||||||
const imgTile = page.locator(".mx_MImageBody").first();
|
const imgTile = page.locator(".mx_ImageBody").first();
|
||||||
await expect(imgTile).toBeVisible();
|
await expect(imgTile).toBeVisible();
|
||||||
|
const image = imgTile.getByRole("img", { name: "bad.png" });
|
||||||
|
await expect(image).toBeVisible();
|
||||||
await imgTile.hover();
|
await imgTile.hover();
|
||||||
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
|
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
|
||||||
await imgTile.click();
|
await image.click();
|
||||||
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
|
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 ({
|
test("should allow downloading media when the allowDownloading hint is set to true", async ({
|
||||||
page,
|
page,
|
||||||
@@ -127,12 +131,16 @@ test.describe("Custom Component API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.timeline.scrollToBottom();
|
await app.timeline.scrollToBottom();
|
||||||
const imgTile = page.locator(".mx_MImageBody").first();
|
const imgTile = page.locator(".mx_ImageBody").first();
|
||||||
await expect(imgTile).toBeVisible();
|
await expect(imgTile).toBeVisible();
|
||||||
|
const image = imgTile.getByRole("img", { name: "good.png" });
|
||||||
|
await expect(image).toBeVisible();
|
||||||
await imgTile.hover();
|
await imgTile.hover();
|
||||||
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
||||||
await imgTile.click();
|
await image.click();
|
||||||
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
|
const imageView = page.getByLabel("Image view");
|
||||||
|
await expect(imageView).toBeVisible();
|
||||||
|
await expect(imageView.getByLabel("Download")).toBeVisible();
|
||||||
});
|
});
|
||||||
test(
|
test(
|
||||||
"should render the next registered component if the filter function throws",
|
"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);
|
await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3);
|
||||||
|
|
||||||
// Detect the image file
|
// 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
|
// 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();
|
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
|
||||||
|
|
||||||
// Detect the audio file
|
// Detect the audio file
|
||||||
@@ -113,7 +113,7 @@ test.describe("FilePanel", () => {
|
|||||||
"flex-end",
|
"flex-end",
|
||||||
);
|
);
|
||||||
// Assert that all of the file tiles are visible before taking a snapshot
|
// 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
|
await expect(filePanelMessageList.locator(".mx_MAudioBody")).toBeVisible(); // middle
|
||||||
const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails");
|
const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails");
|
||||||
await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible();
|
await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible();
|
||||||
@@ -184,7 +184,7 @@ test.describe("FilePanel", () => {
|
|||||||
|
|
||||||
// Detect the image file on the panel
|
// Detect the image file on the panel
|
||||||
const imageBody = page.locator(
|
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");
|
const link = imageBody.locator(".mx_MFileBody a");
|
||||||
|
|||||||
@@ -907,7 +907,7 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
await sendImage(bot, room.roomId, NEW_AVATAR);
|
await sendImage(bot, room.roomId, NEW_AVATAR);
|
||||||
await app.timeline.scrollToBottom();
|
await app.timeline.scrollToBottom();
|
||||||
const imgTile = page.locator(".mx_MImageBody").first();
|
const imgTile = page.locator(".mx_ImageBody").first();
|
||||||
await expect(imgTile).toBeVisible();
|
await expect(imgTile).toBeVisible();
|
||||||
await imgTile.hover();
|
await imgTile.hover();
|
||||||
await page.getByRole("button", { name: "Hide" }).click();
|
await page.getByRole("button", { name: "Hide" }).click();
|
||||||
@@ -1314,7 +1314,7 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
await sendImage(app.client, room.roomId, NEW_AVATAR);
|
await sendImage(app.client, room.roomId, NEW_AVATAR);
|
||||||
await app.timeline.scrollToBottom();
|
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
|
// Exclude timestamp and read marker from snapshot
|
||||||
const screenshotOptions = {
|
const screenshotOptions = {
|
||||||
|
|||||||
@@ -224,7 +224,6 @@
|
|||||||
@import "./views/messages/_DisambiguatedProfile.pcss";
|
@import "./views/messages/_DisambiguatedProfile.pcss";
|
||||||
@import "./views/messages/_LegacyCallEvent.pcss";
|
@import "./views/messages/_LegacyCallEvent.pcss";
|
||||||
@import "./views/messages/_MFileBody.pcss";
|
@import "./views/messages/_MFileBody.pcss";
|
||||||
@import "./views/messages/_MImageBody.pcss";
|
|
||||||
@import "./views/messages/_MImageReplyBody.pcss";
|
@import "./views/messages/_MImageReplyBody.pcss";
|
||||||
@import "./views/messages/_MLocationBody.pcss";
|
@import "./views/messages/_MLocationBody.pcss";
|
||||||
@import "./views/messages/_MPollBody.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);
|
margin-top: var(--cpd-space-4x);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_ImageBody {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* anchor link as wrapper */
|
/* anchor link as wrapper */
|
||||||
.mx_EventTile_senderDetailsLink {
|
.mx_EventTile_senderDetailsLink {
|
||||||
text-decoration: none;
|
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.
|
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 {
|
.mx_MImageReplyBody {
|
||||||
display: flex;
|
display: flex;
|
||||||
column-gap: $spacing-4;
|
column-gap: $spacing-4;
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
padding-right: 48px !important;
|
padding-right: 48px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MImageBody {
|
.mx_ImageBody {
|
||||||
.mx_MImageBody_thumbnail_container {
|
.mx_ImageBody_container {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
|
min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
|
||||||
min-width: 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 {
|
.mx_EventTile_line {
|
||||||
border-bottom-right-radius: var(--cornerRadius);
|
border-bottom-right-radius: var(--cornerRadius);
|
||||||
|
|
||||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
.mx_ImageBody .mx_ImageBody_container,
|
||||||
.mx_MImageBody::before,
|
.mx_ImageBody::before,
|
||||||
.mx_MVideoBody .mx_MVideoBody_container,
|
.mx_MVideoBody .mx_MVideoBody_container,
|
||||||
.mx_MediaBody,
|
.mx_MediaBody,
|
||||||
.mx_MLocationBody_map,
|
.mx_MLocationBody_map,
|
||||||
@@ -220,8 +220,8 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
border-bottom-left-radius: var(--cornerRadius);
|
border-bottom-left-radius: var(--cornerRadius);
|
||||||
|
|
||||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
.mx_ImageBody .mx_ImageBody_container,
|
||||||
.mx_MImageBody::before,
|
.mx_ImageBody::before,
|
||||||
.mx_MVideoBody .mx_MVideoBody_container,
|
.mx_MVideoBody .mx_MVideoBody_container,
|
||||||
.mx_MediaBody,
|
.mx_MediaBody,
|
||||||
.mx_MLocationBody_map,
|
.mx_MLocationBody_map,
|
||||||
@@ -334,16 +334,12 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MImageBody {
|
.mx_ImageBody {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.mx_MImageBody_thumbnail.mx_MImageBody_thumbnail--blurhash {
|
|
||||||
position: unset;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* noinspection CssReplaceWithShorthandSafely */
|
/* noinspection CssReplaceWithShorthandSafely */
|
||||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
.mx_ImageBody .mx_ImageBody_container,
|
||||||
.mx_MVideoBody .mx_MVideoBody_container,
|
.mx_MVideoBody .mx_MVideoBody_container,
|
||||||
.mx_MediaBody {
|
.mx_MediaBody {
|
||||||
border-radius: unset;
|
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 {
|
&.mx_EventTile_continuation[data-self="false"] .mx_EventTile_line {
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
|
|
||||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
.mx_ImageBody .mx_ImageBody_container,
|
||||||
.mx_MVideoBody .mx_MVideoBody_container,
|
.mx_MVideoBody .mx_MVideoBody_container,
|
||||||
.mx_MImageBody::before,
|
.mx_ImageBody::before,
|
||||||
.mx_MediaBody,
|
.mx_MediaBody,
|
||||||
.mx_MLocationBody_map,
|
.mx_MLocationBody_map,
|
||||||
.mx_MBeaconBody {
|
.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 {
|
&.mx_EventTile_lastInSection[data-self="false"] .mx_EventTile_line {
|
||||||
border-bottom-left-radius: var(--cornerRadius);
|
border-bottom-left-radius: var(--cornerRadius);
|
||||||
|
|
||||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
.mx_ImageBody .mx_ImageBody_container,
|
||||||
.mx_MVideoBody .mx_MVideoBody_container,
|
.mx_MVideoBody .mx_MVideoBody_container,
|
||||||
.mx_MImageBody::before,
|
.mx_ImageBody::before,
|
||||||
.mx_MediaBody,
|
.mx_MediaBody,
|
||||||
.mx_MLocationBody_map,
|
.mx_MLocationBody_map,
|
||||||
.mx_MBeaconBody {
|
.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 {
|
&.mx_EventTile_continuation[data-self="true"] .mx_EventTile_line {
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
|
|
||||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
.mx_ImageBody .mx_ImageBody_container,
|
||||||
.mx_MVideoBody .mx_MVideoBody_container,
|
.mx_MVideoBody .mx_MVideoBody_container,
|
||||||
.mx_MImageBody::before,
|
.mx_ImageBody::before,
|
||||||
.mx_MediaBody,
|
.mx_MediaBody,
|
||||||
.mx_MLocationBody_map,
|
.mx_MLocationBody_map,
|
||||||
.mx_MBeaconBody {
|
.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 {
|
&.mx_EventTile_lastInSection[data-self="true"] .mx_EventTile_line {
|
||||||
border-bottom-right-radius: var(--cornerRadius);
|
border-bottom-right-radius: var(--cornerRadius);
|
||||||
|
|
||||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
.mx_ImageBody .mx_ImageBody_container,
|
||||||
.mx_MVideoBody .mx_MVideoBody_container,
|
.mx_MVideoBody .mx_MVideoBody_container,
|
||||||
.mx_MImageBody::before,
|
.mx_ImageBody::before,
|
||||||
.mx_MediaBody,
|
.mx_MediaBody,
|
||||||
.mx_MLocationBody_map,
|
.mx_MLocationBody_map,
|
||||||
.mx_MBeaconBody {
|
.mx_MBeaconBody {
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ $left-gutter: 64px;
|
|||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MImageBody {
|
.mx_ImageBody {
|
||||||
.mx_MImageBody_thumbnail_container {
|
.mx_ImageBody_container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center; /* on every layout */
|
align-items: center; /* on every layout */
|
||||||
}
|
}
|
||||||
@@ -156,8 +156,8 @@ $left-gutter: 64px;
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MImageBody {
|
.mx_ImageBody {
|
||||||
.mx_MImageBody_thumbnail_container {
|
.mx_ImageBody_container {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-height: $font-44px;
|
min-height: $font-44px;
|
||||||
min-width: $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 React, { type JSX, type RefObject, useContext, useEffect, useRef } from "react";
|
||||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||||
import {
|
import {
|
||||||
DecryptionFailureBodyView,
|
DecryptionFailureBodyView,
|
||||||
FileBodyView,
|
FileBodyView,
|
||||||
|
ImageBodyView,
|
||||||
RedactedBodyView,
|
RedactedBodyView,
|
||||||
VideoBodyView,
|
VideoBodyView,
|
||||||
useCreateAutoDisposedViewModel,
|
useCreateAutoDisposedViewModel,
|
||||||
@@ -21,8 +23,10 @@ import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDevi
|
|||||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||||
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
|
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
|
||||||
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
|
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
|
||||||
|
import { ImageBodyViewModel } from "../../../viewmodels/message-body/ImageBodyViewModel";
|
||||||
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
|
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
|
||||||
import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel";
|
import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel";
|
||||||
|
import { isMimeTypeAllowed } from "../../../utils/blobs";
|
||||||
|
|
||||||
type MBodyComponent = React.ComponentType<IBodyProps>;
|
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 {
|
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
|
||||||
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
|
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.
|
// Message body factory registry for bodies that already route through view-model-backed wrappers.
|
||||||
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([
|
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([
|
||||||
|
[MsgType.Image, ImageBodyFactory],
|
||||||
[MsgType.File, FileBodyFactory],
|
[MsgType.File, FileBodyFactory],
|
||||||
[MsgType.Video, VideoBodyFactory],
|
[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.
|
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 { 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 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 { 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;
|
const FORCED_IMAGE_HEIGHT = 44;
|
||||||
|
|
||||||
class MImageReplyBodyInner extends MImageBodyInner {
|
class MImageReplyBodyInner extends ImageBodyBaseInner {
|
||||||
public onClick = (ev: React.MouseEvent): void => {
|
public onClick = (ev: React.MouseEvent): void => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
};
|
};
|
||||||
@@ -37,6 +642,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
|
|||||||
return <div className="mx_MImageReplyBody">{thumbnail}</div>;
|
return <div className="mx_MImageReplyBody">{thumbnail}</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
|
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
|
||||||
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
|
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
|
||||||
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
|
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 Tooltip } from "@vector-im/compound-web";
|
||||||
import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
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 { BLURHASH_FIELD } from "../../../utils/image-media";
|
||||||
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
|
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
|
||||||
import { type IBodyProps } from "./IBodyProps";
|
import { type IBodyProps } from "./IBodyProps";
|
||||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||||
|
|
||||||
class MStickerBodyInner extends MImageBodyInner {
|
class MStickerBodyInner extends ImageBodyBaseInner {
|
||||||
// Mostly empty to prevent default behaviour of MImageBody
|
// Mostly empty to prevent default behaviour of MImageBody
|
||||||
protected onClick = (ev: React.MouseEvent): void => {
|
protected onClick = (ev: React.MouseEvent): void => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir";
|
|||||||
import { type IMediaBody } from "./IMediaBody";
|
import { type IMediaBody } from "./IMediaBody";
|
||||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||||
import { type IBodyProps } from "./IBodyProps";
|
import { type IBodyProps } from "./IBodyProps";
|
||||||
import MImageBody from "./MImageBody";
|
|
||||||
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
||||||
import MStickerBody from "./MStickerBody";
|
import MStickerBody from "./MStickerBody";
|
||||||
import MPollBody from "./MPollBody";
|
import MPollBody from "./MPollBody";
|
||||||
@@ -36,6 +35,7 @@ import { MjolnirBodyViewModel } from "../../../viewmodels/room/timeline/event-ti
|
|||||||
import {
|
import {
|
||||||
DecryptionFailureBodyFactory,
|
DecryptionFailureBodyFactory,
|
||||||
FileBodyFactory,
|
FileBodyFactory,
|
||||||
|
ImageBodyFactory,
|
||||||
RedactedBodyFactory,
|
RedactedBodyFactory,
|
||||||
VideoBodyFactory,
|
VideoBodyFactory,
|
||||||
renderMBody,
|
renderMBody,
|
||||||
@@ -67,7 +67,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
|||||||
[MsgType.Text, TextualBodyFactory],
|
[MsgType.Text, TextualBodyFactory],
|
||||||
[MsgType.Notice, TextualBodyFactory],
|
[MsgType.Notice, TextualBodyFactory],
|
||||||
[MsgType.Emote, TextualBodyFactory],
|
[MsgType.Emote, TextualBodyFactory],
|
||||||
[MsgType.Image, MImageBody],
|
[MsgType.Image, ImageBodyFactory],
|
||||||
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
||||||
[MsgType.Audio, MVoiceOrAudioBody],
|
[MsgType.Audio, MVoiceOrAudioBody],
|
||||||
[MsgType.Video, VideoBodyFactory],
|
[MsgType.Video, VideoBodyFactory],
|
||||||
@@ -283,7 +283,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
((BodyType === MImageBody || BodyType === VideoBodyFactory) &&
|
((BodyType === ImageBodyFactory || BodyType === VideoBodyFactory) &&
|
||||||
!this.validateImageOrVideoMimetype(content)) ||
|
!this.validateImageOrVideoMimetype(content)) ||
|
||||||
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||||
import {
|
import {
|
||||||
|
DecryptionFailureBodyFactory,
|
||||||
FileBodyFactory,
|
FileBodyFactory,
|
||||||
|
ImageBodyFactory,
|
||||||
|
RedactedBodyFactory,
|
||||||
VideoBodyFactory,
|
VideoBodyFactory,
|
||||||
renderMBody,
|
renderMBody,
|
||||||
} from "../../../../../src/components/views/messages/MBodyFactory";
|
} from "../../../../../src/components/views/messages/MBodyFactory";
|
||||||
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
|
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
|
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
|
||||||
|
|
||||||
jest.mock("matrix-encrypt-attachment", () => ({
|
jest.mock("matrix-encrypt-attachment", () => ({
|
||||||
decryptAttachment: jest.fn(),
|
decryptAttachment: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
useMediaVisible: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("MBodyFactory", () => {
|
describe("MBodyFactory", () => {
|
||||||
const userId = "@user:server";
|
const userId = "@user:server";
|
||||||
const deviceId = "DEADB33F";
|
const deviceId = "DEADB33F";
|
||||||
@@ -59,7 +68,7 @@ describe("MBodyFactory", () => {
|
|||||||
onMessageAllowed: jest.fn(),
|
onMessageAllowed: jest.fn(),
|
||||||
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
|
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({
|
new MatrixEvent({
|
||||||
room_id: "!room:server",
|
room_id: "!room:server",
|
||||||
sender: userId,
|
sender: userId,
|
||||||
@@ -68,13 +77,26 @@ describe("MBodyFactory", () => {
|
|||||||
body: "alt",
|
body: "alt",
|
||||||
...(msgtype ? { msgtype } : {}),
|
...(msgtype ? { msgtype } : {}),
|
||||||
url: "mxc://server/file",
|
url: "mxc://server/file",
|
||||||
|
...content,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
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", () => {
|
describe("renderMBody", () => {
|
||||||
it("renders download button for m.file in file rendering type", () => {
|
it("renders download button for m.file in file rendering type", () => {
|
||||||
const mediaEvent = mkEvent("m.file");
|
const mediaEvent = mkEvent("m.file");
|
||||||
@@ -102,6 +124,10 @@ describe("MBodyFactory", () => {
|
|||||||
expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory);
|
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", () => {
|
it("returns null when msgtype is missing", () => {
|
||||||
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
|
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -156,4 +182,160 @@ describe("MBodyFactory", () => {
|
|||||||
expect(container).toMatchSnapshot();
|
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 MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||||
import { Mjolnir } from "../../../../../src/mjolnir/Mjolnir";
|
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", () => ({
|
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
||||||
FileBodyFactory: () => <div data-testid="file-body" />,
|
FileBodyFactory: () => <div data-testid="file-body" />,
|
||||||
|
ImageBodyFactory: () => <div data-testid="image-body" />,
|
||||||
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
||||||
VideoBodyFactory: () => <video data-testid="video-body" />,
|
VideoBodyFactory: () => <video data-testid="video-body" />,
|
||||||
renderMBody: () => <div data-testid="file-body" />,
|
renderMBody: () => <div data-testid="file-body" />,
|
||||||
|
|||||||
-379
@@ -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>
|
|
||||||
`;
|
|
||||||
File diff suppressed because it is too large
Load Diff
+32
@@ -112,6 +112,38 @@ describe("ImageBodyView", () => {
|
|||||||
expect(onLinkClick).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("swaps to the full source on hover for animated previews", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const vm = new TestImageBodyViewModel({
|
const vm = new TestImageBodyViewModel({
|
||||||
|
|||||||
+26
-3
@@ -12,6 +12,7 @@ import React, {
|
|||||||
type MouseEventHandler,
|
type MouseEventHandler,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactEventHandler,
|
type ReactEventHandler,
|
||||||
|
type Ref,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -148,6 +149,18 @@ interface ImageBodyViewProps {
|
|||||||
* Optional host CSS class.
|
* Optional host CSS class.
|
||||||
*/
|
*/
|
||||||
className?: string;
|
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.
|
* Optional supplemental content rendered after the media frame.
|
||||||
*/
|
*/
|
||||||
@@ -202,7 +215,14 @@ function renderPlaceholder({
|
|||||||
* </ImageBodyView>
|
* </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 { translate: _t } = useI18n();
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -230,6 +250,8 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
|||||||
const hoverOrFocus = hover || focus;
|
const hoverOrFocus = hover || focus;
|
||||||
|
|
||||||
const rootClassName = classNames(className, styles.root);
|
const rootClassName = classNames(className, styles.root);
|
||||||
|
const resolvedContainerClassName = classNames(containerClassName, styles.thumbnailContainer);
|
||||||
|
const resolvedImageClassName = classNames(imageClassName, styles.image);
|
||||||
|
|
||||||
if (state === ImageBodyViewState.ERROR) {
|
if (state === ImageBodyViewState.ERROR) {
|
||||||
return (
|
return (
|
||||||
@@ -281,9 +303,10 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
|||||||
</div>
|
</div>
|
||||||
) : resolvedImageSrc ? (
|
) : resolvedImageSrc ? (
|
||||||
<img
|
<img
|
||||||
className={styles.image}
|
className={resolvedImageClassName}
|
||||||
src={resolvedImageSrc}
|
src={resolvedImageSrc}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
|
ref={imageRef}
|
||||||
onError={vm.onImageError}
|
onError={vm.onImageError}
|
||||||
onLoad={vm.onImageLoad}
|
onLoad={vm.onImageLoad}
|
||||||
onMouseEnter={(): void => setHover(true)}
|
onMouseEnter={(): void => setHover(true)}
|
||||||
@@ -302,7 +325,7 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
let frame = (
|
let frame = (
|
||||||
<div className={styles.thumbnailContainer} style={containerStyle}>
|
<div className={resolvedContainerClassName} style={containerStyle}>
|
||||||
{showPlaceholder && (
|
{showPlaceholder && (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.placeholder, {
|
className={classNames(styles.placeholder, {
|
||||||
|
|||||||
Reference in New Issue
Block a user