Compare commits

...

41 Commits

Author SHA1 Message Date
Will Hunt
7a423a0fd0 Merge branch 'develop' into hs/add-hide-image-button+video-preview 2025-03-20 11:13:05 +00:00
Half-Shot
0512becf8e Improve comments 2025-03-20 11:12:45 +00:00
Half-Shot
3b27416f92 make tests happy 2025-03-20 10:34:14 +00:00
Half-Shot
89353db2ba Fixup tests. 2025-03-20 10:19:02 +00:00
Half-Shot
9a0857fd1f Fix jest test 2025-03-19 23:23:09 +00:00
Half-Shot
265278dc17 Merge remote-tracking branch 'origin/develop' into hs/add-hide-image-button+video-preview 2025-03-19 23:12:37 +00:00
Half-Shot
c6359e6710 lint 2025-03-17 10:50:51 +00:00
Half-Shot
c1f0724256 remove .only 2025-03-17 10:45:33 +00:00
Half-Shot
e9397096fd allow for a delay for the image to render 2025-03-17 10:42:37 +00:00
Half-Shot
1b213431a8 fixup 2025-03-17 10:35:13 +00:00
Half-Shot
bc264ff3b7 lint 2025-03-17 10:19:40 +00:00
Half-Shot
6f92b536be Use new wrapping logic 2025-03-17 10:13:48 +00:00
Half-Shot
e057a2ed7b Merge branch 'hs/add-hide-image-button' into hs/add-hide-image-button+video-preview 2025-03-17 10:10:52 +00:00
Half-Shot
d7a185baea Move props into IProps 2025-03-17 10:07:36 +00:00
Half-Shot
93009d4613 Add a comment 2025-03-17 10:01:12 +00:00
Half-Shot
71257d97e7 Merge remote-tracking branch 'origin/develop' into hs/add-hide-image-button 2025-03-17 09:59:37 +00:00
Half-Shot
60eeb8a7de Support functional components for message body rendering. 2025-03-17 09:59:25 +00:00
Half-Shot
571a2e373d Fixup tests 2025-03-17 09:06:58 +00:00
Half-Shot
d0b8564660 Fixup MImageBody test 2025-03-17 08:47:43 +00:00
Will Hunt
28ea91566a Merge branch 'develop' into hs/add-hide-image-button 2025-03-17 08:27:55 +00:00
Half-Shot
ef32747473 Drop setting hook usage. 2025-03-17 08:27:15 +00:00
Half-Shot
7696516e8b Use a hook for media visibility. 2025-03-17 08:22:19 +00:00
Half-Shot
99274065c9 First pass at supporting hiding video files. 2025-03-14 09:48:44 +00:00
Half-Shot
debb6ad626 Add a test for video files. 2025-03-14 09:48:27 +00:00
Half-Shot
939ff4e37a First pass at support for previewing/hiding images. 2025-03-14 09:22:21 +00:00
Will Hunt
46b1234a1d Merge branch 'develop' into hs/add-hide-image-button 2025-03-13 16:49:27 +00:00
Half-Shot
b9c0d63e3e lint 2025-03-11 15:57:09 +00:00
Half-Shot
cf7e52c6fc lint 2025-03-11 15:55:30 +00:00
Half-Shot
e87eb127ee Add tests for HideActionButton 2025-03-11 15:51:05 +00:00
Half-Shot
83e421daf2 appese prettier 2025-03-11 15:20:56 +00:00
Half-Shot
d6fb24dea7 i18n 2025-03-11 15:14:46 +00:00
Half-Shot
a518c8d662 add type 2025-03-11 15:14:25 +00:00
Half-Shot
c759e516bd docs fixes 2025-03-11 15:13:42 +00:00
Half-Shot
c8b55c3dfe add description for migration 2025-03-11 15:11:14 +00:00
Half-Shot
7197093744 Fixup and add tests 2025-03-11 15:08:48 +00:00
Half-Shot
4e34adb854 Tweaks to MImageBody to support new setting. 2025-03-11 11:32:49 +00:00
Half-Shot
72c2a3eb07 Add an action button to hide settings. 2025-03-11 11:32:26 +00:00
Half-Shot
4d290461c4 Add a migration path 2025-03-11 11:32:17 +00:00
Half-Shot
0cc06450d7 Add new setting showMediaEventIds 2025-03-11 11:32:10 +00:00
Half-Shot
9376d71831 Move useSettingsValueWithSetter to useSettings 2025-03-11 11:31:51 +00:00
Half-Shot
6d5442a87b start hide 2025-03-11 10:13:06 +00:00
14 changed files with 292 additions and 115 deletions

View File

@@ -28,6 +28,8 @@ const NEW_AVATAR = fs.readFileSync("playwright/sample-files/element.png");
const OLD_NAME = "Alan";
const NEW_NAME = "Alan (away)";
const VIDEO_FILE = fs.readFileSync("playwright/sample-files/5secvid.webm");
const getEventTilesWithBodies = (page: Page): Locator => {
return page.locator(".mx_EventTile").filter({ has: page.locator(".mx_EventTile_body") });
};
@@ -916,7 +918,26 @@ test.describe("Timeline", () => {
await page.getByRole("button", { name: "Hide" }).click();
// Check that the image is now hidden.
await expect(page.getByRole("link", { name: "Show image" })).toBeVisible();
await expect(page.getByRole("button", { name: "Show image" })).toBeVisible();
});
test("should be able to hide a video", { tag: "@screenshot" }, async ({ page, app, room, context }) => {
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(VIDEO_FILE, { name: "bbb.webm", type: "video/webm" });
await app.client.sendEvent(room.roomId, null, "m.room.message" as EventType, {
msgtype: "m.video" as MsgType,
body: "bbb.webm",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MVideoBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
// Check that the video is now hidden.
await expect(page.getByRole("button", { name: "Show video" })).toBeVisible();
});
});

Binary file not shown.

View File

@@ -228,6 +228,7 @@
@import "./views/messages/_DisambiguatedProfile.pcss";
@import "./views/messages/_EventTileBubble.pcss";
@import "./views/messages/_HiddenBody.pcss";
@import "./views/messages/_HiddenMediaPlaceholder.pcss";
@import "./views/messages/_JumpToDatePicker.pcss";
@import "./views/messages/_LegacyCallEvent.pcss";
@import "./views/messages/_MEmoteBody.pcss";

View File

@@ -0,0 +1,29 @@
.mx_HiddenMediaPlaceholder {
border: none;
width: 100%;
height: 100%;
inset: 0;
/* To center the text in the middle of the frame */
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
background-color: $header-panel-bg-color;
> div {
color: $accent;
/* Icon alignment */
display: flex;
> svg {
margin-top: auto;
margin-bottom: auto;
}
}
}
.mx_EventTile:hover .mx_HiddenMediaPlaceholder {
background-color: $background;
}

View File

@@ -79,39 +79,3 @@ Please see LICENSE files in the repository root for full details.
color: $imagebody-giflabel-color;
pointer-events: none;
}
.mx_HiddenImagePlaceholder {
position: absolute;
inset: 0;
/* To center the text in the middle of the frame */
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
background-color: $header-panel-bg-color;
.mx_HiddenImagePlaceholder_button {
color: $accent;
span.mx_HiddenImagePlaceholder_eye {
margin-right: 8px;
background-color: $accent;
mask-image: url("$(res)/img/element-icons/eye.svg");
display: inline-block;
width: 18px;
height: 14px;
}
span:not(.mx_HiddenImagePlaceholder_eye) {
vertical-align: text-bottom;
}
}
}
.mx_EventTile:hover .mx_HiddenImagePlaceholder {
background-color: $background;
}

View File

@@ -0,0 +1,26 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type PropsWithChildren, type MouseEventHandler } from "react";
import { VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
interface IProps {
onClick: MouseEventHandler<HTMLButtonElement>;
}
const HiddenMediaPlaceholder: React.FunctionComponent<PropsWithChildren<IProps>> = ({ onClick, children }) => {
return (
<button onClick={onClick} className="mx_HiddenMediaPlaceholder">
<div>
<VisibilityOnIcon />
<span>{children}</span>
</div>
</button>
);
};
export default HiddenMediaPlaceholder;

View File

@@ -33,6 +33,7 @@ import { presentableTextForFile } from "../../../utils/FileUtils";
import { createReconnectedListener } from "../../../utils/connection";
import MediaProcessingError from "./shared/MediaProcessingError";
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
import HiddenMediaPlaceholder from "./HiddenMediaPlaceholder";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
enum Placeholder {
@@ -95,7 +96,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault();
if (!this.props.mediaVisible) {
this.props.setMediaVisible?.(true);
this.props.setMediaVisible(true);
return;
}
@@ -437,7 +438,11 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
if (!this.state.loadedImageDimensions) {
let imageElement: JSX.Element;
if (!this.props.mediaVisible) {
imageElement = <HiddenImagePlaceholder />;
imageElement = (
<HiddenMediaPlaceholder onClick={this.onClick}>
{_t("timeline|m.image|show_image")}
</HiddenMediaPlaceholder>
);
} else {
imageElement = (
<img
@@ -507,7 +512,14 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
}
if (!this.props.mediaVisible) {
img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
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.
}
@@ -563,7 +575,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
</div>
{/* HACK: This div fills out space while the image loads, to prevent scroll jumps */}
{!this.props.forExport && !this.state.imgLoaded && (
{!this.props.forExport && !this.state.imgLoaded && !placeholder && (
<div style={{ height: maxHeight, width: maxWidth }} />
)}
</div>
@@ -596,12 +608,6 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
{children}
</a>
);
} else if (!this.props.mediaVisible) {
return (
<div role="button" onClick={this.onClick}>
{children}
</div>
);
}
return children;
}
@@ -680,24 +686,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
}
}
interface PlaceholderIProps {
maxWidth?: number;
}
export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProps> {
public render(): React.ReactNode {
const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null;
return (
<div className="mx_HiddenImagePlaceholder" style={{ maxWidth: `min(100%, ${maxWidth}px)` }}>
<div className="mx_HiddenImagePlaceholder_button">
<span className="mx_HiddenImagePlaceholder_eye" />
<span>{_t("timeline|m.image|show_image")}</span>
</div>
</div>
);
}
}
// Wrap MImageBody component so we can use a hook here.
const MImageBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;

View File

@@ -21,6 +21,8 @@ import MFileBody from "./MFileBody";
import { type ImageSize, suggestedSize as suggestedVideoSize } from "../../../settings/enums/ImageSize";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import MediaProcessingError from "./shared/MediaProcessingError";
import HiddenMediaPlaceholder from "./HiddenMediaPlaceholder";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
interface IState {
decryptedUrl: string | null;
@@ -32,7 +34,19 @@ interface IState {
blurhashUrl: string | null;
}
export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
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;
}
class MVideoBodyInner extends React.PureComponent<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
@@ -49,6 +63,10 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
blurhashUrl: null,
};
private onClick = (): void => {
this.props.setMediaVisible(true);
};
private getContentUrl(): string | undefined {
const content = this.props.mxEvent.getContent<MediaEventContent>();
// During export, the content url will point to the MSC, which will later point to a local url
@@ -120,11 +138,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
}
}
public async componentDidMount(): Promise<void> {
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
});
private async downloadVideo(): Promise<void> {
try {
this.loadBlurhash();
} catch (e) {
@@ -174,6 +188,23 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
}
}
public async componentDidMount(): Promise<void> {
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
});
// Do not attempt to load the media if we do not want to show previews here.
if (this.props.mediaVisible) {
await this.downloadVideo();
}
}
public async componentDidUpdate(prevProps: Readonly<IProps>): Promise<void> {
if (!prevProps.mediaVisible && this.props.mediaVisible) {
await this.downloadVideo();
}
}
public componentWillUnmount(): void {
SettingsStore.unwatchSetting(this.sizeWatcher);
}
@@ -244,6 +275,22 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
);
}
// Users may not even want to show a poster, so instead show a preview button.
if (!this.props.mediaVisible) {
return (
<span className="mx_MVideoBody">
<div
className="mx_MVideoBody_container"
style={{ width: maxWidth, height: maxHeight, aspectRatio }}
>
<HiddenMediaPlaceholder onClick={this.onClick}>
{_t("timeline|m.video|show_video")}
</HiddenMediaPlaceholder>
</div>
</span>
);
}
// Important: If we aren't autoplaying and we haven't decrypted it yet, show a video with a poster.
if (!this.props.forExport && content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
// Need to decrypt the attachment
@@ -294,3 +341,11 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
);
}
}
// Wrap MVideoBody component so we can use a hook here.
const MVideoBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MVideoBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MVideoBody;

View File

@@ -536,9 +536,11 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()}
key="download"
/>,
<HideActionButton mxEvent={this.props.mxEvent} key="hide" />,
);
}
if (MediaEventHelper.canHide(this.props.mxEvent)) {
toolbarOpts.splice(0, 0, <HideActionButton mxEvent={this.props.mxEvent} key="hide" />);
}
} else if (
// Show thread icon even for deleted messages, but only within main timeline
this.context.timelineRenderingType === TimelineRenderingType.Room &&

View File

@@ -3568,7 +3568,8 @@
},
"m.sticker": "%(senderDisplayName)s sent a sticker.",
"m.video": {
"error_decrypting": "Error decrypting video"
"error_decrypting": "Error decrypting video",
"show_video": "Show video"
},
"m.widget": {
"added": "%(widgetName)s widget added by %(senderName)s",

View File

@@ -113,4 +113,18 @@ export class MediaEventHelper implements IDestroyable {
// Finally, it's probably not media
return false;
}
/**
* Determine if the media event in question supports being hidden in the timeline.
* @param event Any matrix event.
* @returns `true` if the media can be hidden, otherwise false.
*/
public static canHide(event: MatrixEvent): boolean {
if (!event) return false;
if (event.isRedacted()) return false;
const content = event.getContent();
const hideTypes: string[] = [MsgType.Video, MsgType.Image];
if (hideTypes.includes(content.msgtype!)) return true;
return false;
}
}

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { act } from "react";
import { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } 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";
@@ -85,6 +85,10 @@ describe("<MImageBody/>", () => {
fetchMock.mockReset();
});
afterEach(() => {
mocked(encrypt.decryptAttachment).mockReset();
});
it("should show a thumbnail while image is being downloaded", async () => {
fetchMock.getOnce(url, { status: 200 });
@@ -166,6 +170,8 @@ describe("<MImageBody/>", () => {
/>,
);
expect(screen.getByText("Show image")).toBeInTheDocument();
expect(fetchMock).not.toHaveFetched(url);
});
@@ -186,8 +192,12 @@ describe("<MImageBody/>", () => {
expect(fetchMock).toHaveFetched(url);
// spinner while downloading image
expect(screen.getByRole("progressbar")).toBeInTheDocument();
// 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();
});
});
});

View File

@@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import React, { act } from "react";
import { EventType, getHttpUriForMxc, type IContent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { render, type RenderResult } from "jest-matrix-react";
import { fireEvent, render, screen, type RenderResult } from "jest-matrix-react";
import fetchMock from "fetch-mock-jest";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
@@ -22,19 +22,22 @@ import {
mockClientMethodsUser,
} from "../../../../test-utils";
import MVideoBody from "../../../../../src/components/views/messages/MVideoBody";
import type { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import SettingsStore from "../../../../../src/settings/SettingsStore";
// Needed so we don't throw an error about failing to decrypt.
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(),
}));
describe("MVideoBody", () => {
it("does not crash when given a portrait image", () => {
// Check for an unreliable crash caused by a fractional-sized
// image dimension being used for a CanvasImageData.
const { asFragment } = makeMVideoBody(720, 1280);
expect(asFragment()).toMatchSnapshot();
// If we get here, we did not crash.
});
const userId = "@user:server";
const deviceId = "DEADB33F";
it("should show poster for encrypted media before downloading it", async () => {
const userId = "@user:server";
const deviceId = "DEADB33F";
const thumbUrl = "https://server/_matrix/media/v3/download/server/encrypted-poster";
beforeEach(() => {
const cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
@@ -49,40 +52,108 @@ describe("MVideoBody", () => {
},
}),
});
const thumbUrl = "https://server/_matrix/media/v3/download/server/encrypted-poster";
fetchMock.getOnce(thumbUrl, { status: 200 });
// 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({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test video",
info: {
duration: 420,
w: 40,
h: 50,
thumbnail_file: {
url: "mxc://server/encrypted-poster",
},
},
file: {
url: "mxc://server/encrypted-image",
fetchMock.mockReset();
});
const encryptedMediaEvent = new MatrixEvent({
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
content: {
body: "alt for a test video",
info: {
duration: 420,
w: 40,
h: 50,
thumbnail_file: {
url: "mxc://server/encrypted-poster",
},
},
});
file: {
url: "mxc://server/encrypted-image",
},
},
});
it("does not crash when given a portrait image", () => {
// Check for an unreliable crash caused by a fractional-sized
// image dimension being used for a CanvasImageData.
const { asFragment } = makeMVideoBody(720, 1280);
expect(asFragment()).toMatchSnapshot();
// If we get here, we did not crash.
});
it("should show poster for encrypted media before downloading it", async () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
const { asFragment } = render(
<MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />,
);
expect(asFragment()).toMatchSnapshot();
});
describe("with video previews/thumbnails disabled", () => {
beforeEach(() => {
act(() => {
SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false);
});
});
afterEach(() => {
act(() => {
SettingsStore.setValue(
"showImages",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showImages"),
);
SettingsStore.setValue(
"showMediaEventIds",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showMediaEventIds"),
);
});
});
it("should not download video", async () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
render(
<MVideoBody
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
);
expect(screen.getByText("Show video")).toBeInTheDocument();
expect(fetchMock).not.toHaveFetched(thumbUrl);
});
it("should render video poster after user consent", async () => {
fetchMock.getOnce(thumbUrl, { status: 200 });
render(
<MVideoBody
mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>,
);
const placeholderButton = screen.getByRole("button", { name: "Show video" });
expect(placeholderButton).toBeInTheDocument();
fireEvent.click(placeholderButton);
expect(fetchMock).toHaveFetched(thumbUrl);
});
});
});
function makeMVideoBody(w: number, h: number): RenderResult {
@@ -109,7 +180,7 @@ function makeMVideoBody(w: number, h: number): RenderResult {
content,
});
const defaultProps: MVideoBody["props"] = {
const defaultProps: IBodyProps = {
mxEvent: event,
highlights: [],
highlightLink: "",

View File

@@ -41,9 +41,6 @@ exports[`<MImageBody/> should generate a thumbnail if one isn't included for ani
GIF
</p>
</div>
<div
style="height: 50px; width: 40px;"
/>
</div>
</a>
</div>
@@ -77,9 +74,6 @@ exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1
<div
style="max-height: 50px; max-width: 40px;"
/>
<div
style="height: 50px; width: 40px;"
/>
</div>
</div>
</div>