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:
@@ -20,17 +20,26 @@ import {
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import {
|
||||
DecryptionFailureBodyFactory,
|
||||
FileBodyFactory,
|
||||
ImageBodyFactory,
|
||||
RedactedBodyFactory,
|
||||
VideoBodyFactory,
|
||||
renderMBody,
|
||||
} from "../../../../../src/components/views/messages/MBodyFactory";
|
||||
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
|
||||
|
||||
jest.mock("matrix-encrypt-attachment", () => ({
|
||||
decryptAttachment: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
|
||||
__esModule: true,
|
||||
useMediaVisible: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MBodyFactory", () => {
|
||||
const userId = "@user:server";
|
||||
const deviceId = "DEADB33F";
|
||||
@@ -59,7 +68,7 @@ describe("MBodyFactory", () => {
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
|
||||
};
|
||||
const mkEvent = (msgtype?: string): MatrixEvent =>
|
||||
const mkEvent = (msgtype?: string, content: Record<string, unknown> = {}): MatrixEvent =>
|
||||
new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: userId,
|
||||
@@ -68,13 +77,26 @@ describe("MBodyFactory", () => {
|
||||
body: "alt",
|
||||
...(msgtype ? { msgtype } : {}),
|
||||
url: "mxc://server/file",
|
||||
...content,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
||||
jest.mocked(useMediaVisible).mockReturnValue([true, jest.fn()]);
|
||||
});
|
||||
|
||||
const encryptedImageHelper = (): MediaEventHelper =>
|
||||
({
|
||||
media: { isEncrypted: true },
|
||||
sourceUrl: { value: Promise.resolve("blob:source") },
|
||||
thumbnailUrl: { value: Promise.resolve("blob:thumbnail") },
|
||||
sourceBlob: {
|
||||
value: Promise.resolve(new Blob(["image"], { type: "image/jpeg" })),
|
||||
cachedValue: new Blob(["image"], { type: "image/jpeg" }),
|
||||
},
|
||||
}) as unknown as MediaEventHelper;
|
||||
|
||||
describe("renderMBody", () => {
|
||||
it("renders download button for m.file in file rendering type", () => {
|
||||
const mediaEvent = mkEvent("m.file");
|
||||
@@ -102,6 +124,10 @@ describe("MBodyFactory", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory);
|
||||
});
|
||||
|
||||
it("returns the image body factory for m.image", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent("m.image") })?.type).toBe(ImageBodyFactory);
|
||||
});
|
||||
|
||||
it("returns null when msgtype is missing", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
|
||||
});
|
||||
@@ -156,4 +182,160 @@ describe("MBodyFactory", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
describe("ImageBodyFactory", () => {
|
||||
const imageContent = {
|
||||
info: {
|
||||
mimetype: "image/jpeg",
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("renders the shared image view in room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.image", imageContent);
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the file fallback child in notification timelines", () => {
|
||||
const mediaEvent = mkEvent("m.image", imageContent);
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only a file body for encrypted unsafe images without thumbnails", () => {
|
||||
const mediaEvent = mkEvent("m.image", {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
},
|
||||
});
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={{ media: { isEncrypted: true } } as MediaEventHelper}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("button", { name: "alt" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the image body for encrypted unsafe images when a thumbnail is available", () => {
|
||||
const mediaEvent = mkEvent("m.image", {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
thumbnail_info: { mimetype: "image/jpeg" },
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory {...props} mxEvent={mediaEvent} mediaEventHelper={encryptedImageHelper()} />
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoBodyFactory", () => {
|
||||
const videoContent = {
|
||||
info: {
|
||||
mimetype: "video/mp4",
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("renders without a file fallback in room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.video", videoContent);
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<VideoBodyFactory
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
forExport={false}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the file fallback child outside room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.video", videoContent);
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
|
||||
<VideoBodyFactory
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
forExport={false}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the redacted body wrapper", () => {
|
||||
const mediaEvent = mkEvent("m.text");
|
||||
|
||||
const { container } = render(<RedactedBodyFactory mxEvent={mediaEvent} />);
|
||||
|
||||
expect(container.querySelector(".mx_RedactedBody")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the decryption failure body wrapper", () => {
|
||||
const mediaEvent = mkEvent("m.text");
|
||||
Object.defineProperty(mediaEvent, "decryptionFailureReason", {
|
||||
configurable: true,
|
||||
value: "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
});
|
||||
|
||||
const { container } = render(<DecryptionFailureBodyFactory mxEvent={mediaEvent} />);
|
||||
|
||||
expect(container.querySelector(".mx_DecryptionFailureBody")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within } from "jest-matrix-react";
|
||||
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
import encrypt from "matrix-encrypt-attachment";
|
||||
import { mocked } from "jest-mock";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import MImageBody from "../../../../../src/components/views/messages/MImageBody";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsCrypto,
|
||||
mockClientMethodsDevice,
|
||||
mockClientMethodsServer,
|
||||
mockClientMethodsUser,
|
||||
withClientContextRenderOptions,
|
||||
} from "../../../../test-utils";
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
|
||||
|
||||
jest.mock("matrix-encrypt-attachment", () => ({
|
||||
decryptAttachment: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<MImageBody/>", () => {
|
||||
const ourUserId = "@user:server";
|
||||
const senderUserId = "@other_use:server";
|
||||
const deviceId = "DEADB33F";
|
||||
const cli = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(ourUserId),
|
||||
...mockClientMethodsServer(),
|
||||
...mockClientMethodsDevice(deviceId),
|
||||
...mockClientMethodsCrypto(),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getRoom: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": true,
|
||||
"org.matrix.msc3886": true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const url = "https://server/_matrix/media/v3/download/server/encrypted-image";
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
cli.mxcUrlToHttp.mockImplementation(
|
||||
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
|
||||
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
|
||||
},
|
||||
);
|
||||
const encryptedMediaEvent = new MatrixEvent({
|
||||
event_id: "$foo:bar",
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/png",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-image",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = {
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room(encryptedMediaEvent.getRoomId()!, cli, cli.getUserId()!)),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
mocked(encrypt.decryptAttachment).mockReset();
|
||||
});
|
||||
|
||||
it("should show a thumbnail while image is being downloaded", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
// thumbnail with dimensions present
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show error when encrypted media cannot be downloaded", async () => {
|
||||
fetchMock.getOnce(url, { status: 500 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveFetched(url);
|
||||
|
||||
await screen.findByText("Error downloading image");
|
||||
});
|
||||
|
||||
it("should show error when encrypted media cannot be decrypted", async () => {
|
||||
fetchMock.getOnce(url, "thisistotallyanencryptedpng");
|
||||
mocked(encrypt.decryptAttachment).mockRejectedValue(new Error("Failed to decrypt"));
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
await screen.findByText("Error decrypting image");
|
||||
});
|
||||
|
||||
describe("with image previews/thumbnails disabled", () => {
|
||||
beforeEach(() => {
|
||||
const origFn = SettingsStore.getValue;
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
|
||||
if (setting === "mediaPreviewConfig") {
|
||||
return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off };
|
||||
}
|
||||
return origFn(setting, ...args);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not download image", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show image")).toBeInTheDocument();
|
||||
|
||||
expect(fetchMock).toHaveFetchedTimes(0, url);
|
||||
});
|
||||
|
||||
it("should render hidden image placeholder", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show image")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(fetchMock).toHaveFetched(url);
|
||||
|
||||
// Show image is asynchronous since it applies through a settings watcher hook, so
|
||||
// be sure to wait here.
|
||||
await waitFor(() => {
|
||||
// spinner while downloading image
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to /download/ if /thumbnail/ fails", async () => {
|
||||
const thumbUrl = "https://server/_matrix/media/v3/thumbnail/server/image?width=800&height=600&method=scale";
|
||||
const downloadUrl = "https://server/_matrix/media/v3/download/server/image";
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
|
||||
expect(img).toHaveProperty("src", thumbUrl);
|
||||
|
||||
fireEvent.error(img);
|
||||
expect(img).toHaveProperty("src", downloadUrl);
|
||||
});
|
||||
|
||||
it("should generate a thumbnail if one isn't included for animated media", async () => {
|
||||
Object.defineProperty(global.Image.prototype, "src", {
|
||||
set(src) {
|
||||
window.setTimeout(() => this.onload?.());
|
||||
},
|
||||
});
|
||||
Object.defineProperty(global.Image.prototype, "height", {
|
||||
get() {
|
||||
return 600;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(global.Image.prototype, "width", {
|
||||
get() {
|
||||
return 800;
|
||||
},
|
||||
});
|
||||
|
||||
mocked(global.URL.createObjectURL).mockReturnValue("blob:generated-thumb");
|
||||
|
||||
fetchMock.getOnce("https://server/_matrix/media/v3/download/server/image", {
|
||||
body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")),
|
||||
});
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/webp",
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
// Wait for spinners to go away
|
||||
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
|
||||
// thumbnail with dimensions present
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show banner on hover", async () => {
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
|
||||
await userEvent.hover(img);
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody_banner")).toHaveTextContent("...alt for a test image");
|
||||
});
|
||||
|
||||
it("should render MFileBody for svg with no thumbnail", async () => {
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, asFragment } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MFileBody")).toHaveTextContent("Attachment");
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should open ImageView using thumbnail for encrypted svg", async () => {
|
||||
const url = "https://server/_matrix/media/v3/download/server/encrypted-svg";
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
const thumbUrl = "https://server/_matrix/media/v3/download/server/svg-thumbnail";
|
||||
fetchMock.getOnce(thumbUrl, { status: 200 });
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
origin_server_ts: 1234567890,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_file: {
|
||||
url: "mxc://server/svg-thumbnail",
|
||||
},
|
||||
thumbnail_info: { mimetype: "image/png" },
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mediaEventHelper = new MediaEventHelper(event);
|
||||
mediaEventHelper.thumbnailUrl["prom"] = Promise.resolve(thumbUrl);
|
||||
mediaEventHelper.sourceUrl["prom"] = Promise.resolve(url);
|
||||
|
||||
const { findByRole } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={mediaEventHelper} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole("link"));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await expect(within(dialog).findByRole("img")).resolves.toHaveAttribute(
|
||||
"src",
|
||||
"https://server/_matrix/media/v3/download/server/svg-thumbnail",
|
||||
);
|
||||
expect(dialog).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,620 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import { ClientEvent, EventType, getHttpUriForMxc, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import Modal from "../../../../../src/Modal";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { ImageSize } from "../../../../../src/settings/enums/ImageSize";
|
||||
import { mediaFromContent } from "../../../../../src/customisations/Media";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../../../../src/utils/image-media";
|
||||
import { blobIsAnimated } from "../../../../../src/utils/Image";
|
||||
import { DecryptError, DownloadError } from "../../../../../src/utils/DecryptFile";
|
||||
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||
import MImageReplyBody, { ImageBodyBaseInner } from "../../../../../src/components/views/messages/MImageReplyBody";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsCrypto,
|
||||
mockClientMethodsDevice,
|
||||
mockClientMethodsServer,
|
||||
mockClientMethodsUser,
|
||||
} from "../../../../test-utils";
|
||||
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
|
||||
|
||||
jest.mock("../../../../../src/customisations/Media", () => ({
|
||||
mediaFromContent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/Image", () => ({
|
||||
...jest.requireActual("../../../../../src/utils/Image"),
|
||||
blobIsAnimated: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/image-media", () => ({
|
||||
...jest.requireActual("../../../../../src/utils/image-media"),
|
||||
createThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
|
||||
__esModule: true,
|
||||
useMediaVisible: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<MImageReplyBody />", () => {
|
||||
const userId = "@user:server";
|
||||
const deviceId = "DEADB33F";
|
||||
const cli = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
...mockClientMethodsServer(),
|
||||
...mockClientMethodsDevice(deviceId),
|
||||
...mockClientMethodsCrypto(),
|
||||
getRoom: jest.fn(),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": true,
|
||||
"org.matrix.msc3886": true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
cli.mxcUrlToHttp.mockImplementation(
|
||||
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
|
||||
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
|
||||
},
|
||||
);
|
||||
|
||||
const mockedMediaFromContent = jest.mocked(mediaFromContent);
|
||||
const mockedUseMediaVisible = jest.mocked(useMediaVisible);
|
||||
const mockedBlobIsAnimated = jest.mocked(blobIsAnimated);
|
||||
const mockedCreateThumbnail = jest.mocked(createThumbnail);
|
||||
const originalGetValue = SettingsStore.getValue.bind(SettingsStore);
|
||||
|
||||
const createEvent = ({
|
||||
body = "demo image",
|
||||
content = {},
|
||||
}: {
|
||||
body?: string;
|
||||
content?: Record<string, unknown>;
|
||||
} = {}): MatrixEvent => {
|
||||
const { info: infoOverride, ...restContent } = content;
|
||||
const info =
|
||||
infoOverride === null
|
||||
? undefined
|
||||
: {
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
mimetype: "image/jpeg",
|
||||
...(infoOverride as Record<string, unknown> | undefined),
|
||||
};
|
||||
|
||||
return new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
room_id: "!room:server",
|
||||
event_id: "$image:server",
|
||||
sender: userId,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body,
|
||||
url: "mxc://server/image",
|
||||
...restContent,
|
||||
...(info ? { info } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createMockMedia = (content: Record<string, any>) => ({
|
||||
isEncrypted: !!content.file,
|
||||
srcMxc: content.url ?? content.file?.url ?? "mxc://server/image",
|
||||
srcHttp: "https://server/full.png",
|
||||
thumbnailMxc: content.info?.thumbnail_url ?? "mxc://server/thumb",
|
||||
thumbnailHttp: "https://server/thumb.png",
|
||||
hasThumbnail: content.info?.thumbnail_url !== null,
|
||||
getThumbnailHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
|
||||
getThumbnailOfSourceHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
|
||||
getSquareThumbnailHttp: jest.fn(),
|
||||
downloadSource: jest.fn(),
|
||||
});
|
||||
|
||||
const createMediaEventHelper = ({
|
||||
encrypted = true,
|
||||
thumbnailUrl = "blob:thumbnail",
|
||||
sourceUrl = "blob:source",
|
||||
sourceBlob = new Blob(["image"], { type: "image/jpeg" }),
|
||||
}: {
|
||||
encrypted?: boolean;
|
||||
thumbnailUrl?: string | null | Promise<string | null>;
|
||||
sourceUrl?: string | null | Promise<string | null>;
|
||||
sourceBlob?: Blob | Promise<Blob>;
|
||||
} = {}): MediaEventHelper =>
|
||||
({
|
||||
media: { isEncrypted: encrypted },
|
||||
thumbnailUrl: { value: Promise.resolve(thumbnailUrl) },
|
||||
sourceUrl: { value: Promise.resolve(sourceUrl) },
|
||||
sourceBlob: { value: Promise.resolve(sourceBlob), cachedValue: sourceBlob },
|
||||
}) as unknown as MediaEventHelper;
|
||||
|
||||
const props = {
|
||||
mxEvent: createEvent(),
|
||||
mediaVisible: true,
|
||||
setMediaVisible: jest.fn(),
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
|
||||
};
|
||||
|
||||
const renderBase = ({
|
||||
timelineRenderingType = TimelineRenderingType.Room,
|
||||
overrides = {},
|
||||
}: {
|
||||
timelineRenderingType?: TimelineRenderingType;
|
||||
overrides?: Partial<React.ComponentProps<typeof ImageBodyBaseInner>>;
|
||||
} = {}) => {
|
||||
const ref = createRef<ImageBodyBaseInner>();
|
||||
const result = render(
|
||||
<RoomContext.Provider value={{ timelineRenderingType } as any}>
|
||||
<ImageBodyBaseInner ref={ref} {...props} {...overrides} />
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
return { ...result, ref };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
mockedMediaFromContent.mockImplementation((content: Record<string, any>) => createMockMedia(content) as any);
|
||||
mockedUseMediaVisible.mockReturnValue([true, jest.fn()]);
|
||||
mockedBlobIsAnimated.mockResolvedValue(true);
|
||||
mockedCreateThumbnail.mockResolvedValue({ thumbnail: new Blob(["thumbnail"], { type: "image/jpeg" }) } as any);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(((setting, ...args) => {
|
||||
if (setting === "Images.size") return ImageSize.Normal;
|
||||
if (setting === "autoplayGifs") return false;
|
||||
return (originalGetValue as any)(setting, ...args);
|
||||
}) as typeof SettingsStore.getValue);
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("image-reply-watch");
|
||||
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a visible unencrypted image and file fallback outside room timelines", async () => {
|
||||
const { container } = renderBase({ timelineRenderingType: TimelineRenderingType.Notification });
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("img", { name: "demo image" })).toHaveLength(2));
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(container.querySelector("a[href='https://server/full.png']")).not.toBeNull();
|
||||
expect(container.querySelector("img.mx_MImageBody_thumbnail")).toHaveAttribute(
|
||||
"src",
|
||||
"https://server/thumb.png",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals hidden media through the supplied setter", () => {
|
||||
const setMediaVisible = jest.fn();
|
||||
renderBase({
|
||||
overrides: {
|
||||
mediaVisible: false,
|
||||
setMediaVisible,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show image" }));
|
||||
|
||||
expect(setMediaVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("opens the image viewer with thumbnail geometry", async () => {
|
||||
const { container } = renderBase();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
|
||||
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
|
||||
image.getBoundingClientRect = () => ({ width: 100, height: 80, x: 10, y: 20 }) as DOMRect;
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "demo image" }), { button: 0 });
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
src: "https://server/full.png",
|
||||
name: "demo image",
|
||||
width: 320,
|
||||
height: 240,
|
||||
fileSize: 48_000,
|
||||
thumbnailInfo: {
|
||||
width: 100,
|
||||
height: 80,
|
||||
positionX: 10,
|
||||
positionY: 20,
|
||||
},
|
||||
}),
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates load dimensions and toggles hover/focus banner state", async () => {
|
||||
const { container, ref } = renderBase();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
|
||||
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
|
||||
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageLoad"]();
|
||||
ref.current!.setState({ isAnimated: true, imgLoaded: true });
|
||||
});
|
||||
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
|
||||
|
||||
fireEvent.mouseEnter(image);
|
||||
expect(ref.current!.state.hover).toBe(true);
|
||||
expect(container.querySelector(".mx_MImageBody_banner")).not.toBeNull();
|
||||
expect(image).toHaveAttribute("src", "https://server/full.png");
|
||||
|
||||
fireEvent.mouseLeave(image);
|
||||
expect(ref.current!.state.hover).toBe(false);
|
||||
|
||||
const link = screen.getByRole("link", { name: /demo image/ });
|
||||
fireEvent.focus(link);
|
||||
expect(ref.current!.state.focus).toBe(true);
|
||||
fireEvent.blur(link);
|
||||
expect(ref.current!.state.focus).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the decrypted thumbnail in the image viewer when the source mime type is unsafe", async () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
body: "unsafe image",
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_info: { mimetype: "image/jpeg" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: "blob:unsafe-source",
|
||||
thumbnailUrl: "blob:safe-thumbnail",
|
||||
sourceBlob: new Blob(["html"], { type: "text/html" }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "unsafe image" })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "unsafe image" }), { button: 0 });
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
src: "blob:safe-thumbnail",
|
||||
name: "unsafe image",
|
||||
}),
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from thumbnail errors and clears image errors after reconnecting", async () => {
|
||||
const onSpy = jest.spyOn(cli, "on");
|
||||
const offSpy = jest.spyOn(cli, "off");
|
||||
const { ref } = renderBase();
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageError"]();
|
||||
});
|
||||
expect(ref.current!.state.thumbUrl).toBeNull();
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageError"]();
|
||||
});
|
||||
expect(ref.current!.state.imgError).toBe(true);
|
||||
expect(onSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
|
||||
|
||||
const listener = onSpy.mock.calls.at(-1)![1] as (...args: unknown[]) => void;
|
||||
act(() => {
|
||||
listener(SyncState.Syncing, SyncState.Error);
|
||||
});
|
||||
|
||||
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, listener);
|
||||
expect(ref.current!.state.imgError).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
|
||||
[new DownloadError(new Error("download failed")), "Error downloading image"],
|
||||
[new Error("display failed"), "Unable to show image due to error"],
|
||||
])("renders media processing errors for %s", async (error, label) => {
|
||||
const { container, ref } = renderBase();
|
||||
|
||||
act(() => {
|
||||
ref.current!.setState({ error });
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
|
||||
[new DownloadError(new Error("download failed")), "Error downloading image"],
|
||||
[new Error("download failed"), "Unable to show image due to error"],
|
||||
])("renders encrypted download failures for %s", async (error, label) => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: Promise.reject(error),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(label)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders export images directly from the event MXC URL", () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
forExport: true,
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
url: undefined,
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute(
|
||||
"href",
|
||||
"mxc://server/encrypted-image",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute("target", "_blank");
|
||||
expect(screen.queryByRole("link", { name: /Download/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("switches blurhash placeholders on after the delay", () => {
|
||||
jest.useFakeTimers();
|
||||
const { container } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
info: {
|
||||
[BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_Blurhash")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_Blurhash")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("downloads media when visibility changes after mount", async () => {
|
||||
const ref = createRef<ImageBodyBaseInner>();
|
||||
const mxEvent = createEvent();
|
||||
const { rerender } = render(
|
||||
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
|
||||
<ImageBodyBaseInner
|
||||
ref={ref}
|
||||
{...props}
|
||||
mxEvent={mxEvent}
|
||||
mediaVisible={false}
|
||||
setMediaVisible={jest.fn()}
|
||||
/>
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
expect(ref.current!.state.contentUrl).toBeNull();
|
||||
|
||||
rerender(
|
||||
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
|
||||
<ImageBodyBaseInner
|
||||
ref={ref}
|
||||
{...props}
|
||||
mxEvent={mxEvent}
|
||||
mediaVisible={true}
|
||||
setMediaVisible={jest.fn()}
|
||||
/>
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.contentUrl).toBe("https://server/full.png"));
|
||||
});
|
||||
|
||||
it("renders missing-size media after loading natural dimensions", async () => {
|
||||
const { container, ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({ content: { info: null } }),
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(container.querySelector("img[style*='display: none']")).not.toBeNull());
|
||||
const image = container.querySelector("img[style*='display: none']") as HTMLImageElement;
|
||||
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageLoad"]();
|
||||
});
|
||||
|
||||
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
|
||||
expect(container.querySelector(".mx_MImageBody_thumbnail_container")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("generates a static thumbnail for animated images without a safe thumbnail", async () => {
|
||||
let createdImage: any;
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
|
||||
if (tagName !== "img") {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
createdImage = originalCreateElement(tagName) as HTMLImageElement;
|
||||
Object.defineProperty(createdImage, "width", { configurable: true, value: 320 });
|
||||
Object.defineProperty(createdImage, "height", { configurable: true, value: 240 });
|
||||
return createdImage;
|
||||
}) as typeof document.createElement);
|
||||
const { ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
info: {
|
||||
"mimetype": "image/gif",
|
||||
"thumbnail_info": { mimetype: "image/gif" },
|
||||
"org.matrix.msc4230.is_animated": true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: "blob:animated-source",
|
||||
thumbnailUrl: null,
|
||||
sourceBlob: new Blob(["gif"], { type: "image/gif" }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createdImage).toBeDefined());
|
||||
await act(async () => {
|
||||
createdImage.onload();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("blob"));
|
||||
expect(mockedBlobIsAnimated).toHaveBeenCalled();
|
||||
expect(mockedCreateThumbnail).toHaveBeenCalledWith(expect.any(HTMLImageElement), 320, 240, "image/gif", false);
|
||||
expect(ref.current!.state.isAnimated).toBe(true);
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses SVG thumbnails when available", async () => {
|
||||
const { ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
info: {
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_url: "mxc://server/thumb",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
|
||||
|
||||
expect(
|
||||
mockedMediaFromContent.mock.results.some((result: any) =>
|
||||
result.value.getThumbnailHttp.mock.calls.some(
|
||||
(call: unknown[]) => call[0] === 800 && call[1] === 600 && call[2] === "scale",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the full source as thumbnail for small high-dpi images", async () => {
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value: 2,
|
||||
});
|
||||
|
||||
const { ref } = renderBase();
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/full.png"));
|
||||
});
|
||||
|
||||
it("renders the file body instead of unsafe encrypted images without thumbnails", () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: {
|
||||
media: { isEncrypted: true },
|
||||
sourceUrl: { value: Promise.resolve("blob:source") },
|
||||
thumbnailUrl: { value: Promise.resolve(null) },
|
||||
sourceBlob: {
|
||||
value: Promise.resolve(new Blob(["html"], { type: "text/html" })),
|
||||
cachedValue: new Blob(["html"], { type: "text/html" }),
|
||||
},
|
||||
} as unknown as MediaEventHelper,
|
||||
mediaVisible: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /demo image/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "demo image" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the compact reply body through the hook wrapper", async () => {
|
||||
const setMediaVisible = jest.fn();
|
||||
mockedUseMediaVisible.mockReturnValue([true, setMediaVisible]);
|
||||
|
||||
const { container } = render(<MImageReplyBody {...props} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".mx_MImageReplyBody")).not.toBeNull());
|
||||
expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cleans up settings watchers, listeners and generated animated thumbnails on unmount", async () => {
|
||||
const offSpy = jest.spyOn(cli, "off");
|
||||
const { ref, unmount } = renderBase();
|
||||
await waitFor(() => expect(ref.current).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
ref.current!.setState({
|
||||
isAnimated: true,
|
||||
thumbUrl: "blob:animated-thumbnail",
|
||||
});
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("image-reply-watch");
|
||||
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:animated-thumbnail");
|
||||
});
|
||||
});
|
||||
@@ -20,15 +20,11 @@ import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permal
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import { Mjolnir } from "../../../../../src/mjolnir/Mjolnir";
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="image-body" />,
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
|
||||
__esModule: true,
|
||||
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
||||
FileBodyFactory: () => <div data-testid="file-body" />,
|
||||
ImageBodyFactory: () => <div data-testid="image-body" />,
|
||||
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
||||
VideoBodyFactory: () => <video data-testid="video-body" />,
|
||||
renderMBody: () => <div data-testid="file-body" />,
|
||||
|
||||
-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>
|
||||
`;
|
||||
Reference in New Issue
Block a user