Compare commits

...

25 Commits

Author SHA1 Message Date
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
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
17 changed files with 376 additions and 50 deletions

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -905,6 +905,19 @@ test.describe("Timeline", () => {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should be able to hide an image", { tag: "@screenshot" }, async ({ page, app, room, context }) => {
await app.viewRoomById(room.roomId);
await sendImage(app.client, room.roomId, NEW_AVATAR);
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
// Check that the image is now hidden.
await expect(page.getByRole("link", { name: "Show image" })).toBeVisible();
});
});
test.describe("message sending", { tag: ["@no-firefox", "@no-webkit"] }, () => {

View File

@@ -0,0 +1,44 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import React from "react";
import { VisibilityOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
interface IProps {
/**
* Matrix event that this action applies to.
*/
mxEvent: MatrixEvent;
}
/**
* Quick action button for marking a media event as hidden.
*/
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId()!);
if (!mediaIsVisible) {
return;
}
return (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton "
title={_t("action|hide")}
onClick={() => setVisible(false)}
placement="left"
>
<VisibilityOffIcon />
</RovingAccessibleButton>
);
};

View File

@@ -93,7 +93,7 @@ export function computedStyle(element: HTMLElement | null): string {
interface IProps extends IBodyProps {
/* whether or not to show the default placeholder for the file. Defaults to true. */
showGenericPlaceholder: boolean;
showGenericPlaceholder?: boolean;
}
interface IState {
@@ -105,11 +105,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
declare public context: React.ContextType<typeof RoomContext>;
public state: IState = {};
public static defaultProps = {
showGenericPlaceholder: true,
};
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false;
@@ -191,15 +186,17 @@ export default class MFileBody extends React.Component<IProps, IState> {
const contentUrl = this.getContentUrl();
const contentFileSize = this.content.info ? this.content.info.size : null;
const fileType = this.content.info?.mimetype ?? "application/octet-stream";
// defaultProps breaks types on IBodyProps, so instead define the default here.
const showGenericPlaceholder = this.props.showGenericPlaceholder ?? true;
let showDownloadLink =
!this.props.showGenericPlaceholder ||
!showGenericPlaceholder ||
(this.context.timelineRenderingType !== TimelineRenderingType.Room &&
this.context.timelineRenderingType !== TimelineRenderingType.Search &&
this.context.timelineRenderingType !== TimelineRenderingType.Pinned);
let placeholder: React.ReactNode = null;
if (this.props.showGenericPlaceholder) {
if (showGenericPlaceholder) {
placeholder = (
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
<span className="mx_MFileBody_info_icon" />

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 { useMediaVisible } from "../../../hooks/useMediaVisible";
enum Placeholder {
NoImage,
@@ -52,11 +53,25 @@ interface IState {
};
hover: boolean;
focus: boolean;
showImage: boolean;
placeholder: Placeholder;
}
export default class MImageBody extends React.Component<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;
}
/**
* @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>;
@@ -73,21 +88,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
imgLoaded: false,
hover: false,
focus: false,
showImage: SettingsStore.getValue("showImages"),
placeholder: Placeholder.NoImage,
};
protected showImage(): void {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({ showImage: true });
this.downloadImage();
}
protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault();
if (!this.state.showImage) {
this.showImage();
if (!this.props.mediaVisible) {
this.props.setMediaVisible?.(true);
return;
}
@@ -125,7 +133,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private get shouldAutoplay(): boolean {
return !(
!this.state.contentUrl ||
!this.state.showImage ||
!this.props.mediaVisible ||
!this.state.isAnimated ||
SettingsStore.getValue("autoplayGifs")
);
@@ -346,14 +354,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
public componentDidMount(): void {
this.unmounted = false;
const showImage =
this.state.showImage || localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
if (this.props.mediaVisible) {
// noinspection JSIgnoredPromiseFromCall
this.downloadImage();
this.setState({ showImage: true });
} // else don't download anything because we don't want to display anything.
}
// Add a 150ms timer for blurhash to first appear.
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
@@ -372,6 +376,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
});
}
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);
@@ -425,7 +436,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// by the same width and height logic below.
if (!this.state.loadedImageDimensions) {
let imageElement: JSX.Element;
if (!this.state.showImage) {
if (!this.props.mediaVisible) {
imageElement = <HiddenImagePlaceholder />;
} else {
imageElement = (
@@ -495,7 +506,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
);
}
if (!this.state.showImage) {
if (!this.props.mediaVisible) {
img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
}
@@ -506,7 +517,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}
let banner: ReactNode | undefined;
if (this.state.showImage && hoverOrFocus) {
if (this.props.mediaVisible && hoverOrFocus) {
banner = this.getBanner(content);
}
@@ -585,7 +596,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
{children}
</a>
);
} else if (!this.state.showImage) {
} else if (!this.props.mediaVisible) {
return (
<div role="button" onClick={this.onClick}>
{children}
@@ -686,3 +697,10 @@ export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProp
);
}
}
const MImageBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MImageBody;

View File

@@ -9,11 +9,13 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { type ImageContent } from "matrix-js-sdk/src/types";
import MImageBody from "./MImageBody";
import { MImageBodyInner } from "./MImageBody";
import { type IBodyProps } from "./IBodyProps";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
const FORCED_IMAGE_HEIGHT = 44;
export default class MImageReplyBody extends MImageBody {
class MImageReplyBodyInner extends MImageBodyInner {
public onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
};
@@ -35,3 +37,9 @@ export default class MImageReplyBody extends MImageBody {
return <div className="mx_MImageReplyBody">{thumbnail}</div>;
}
}
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MImageReplyBody;

View File

@@ -9,16 +9,18 @@ import React, { type ComponentProps, type ReactNode } from "react";
import { type Tooltip } from "@vector-im/compound-web";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import MImageBody from "./MImageBody";
import { MImageBodyInner } from "./MImageBody";
import { BLURHASH_FIELD } from "../../../utils/image-media";
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
import { type IBodyProps } from "./IBodyProps";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
export default class MStickerBody extends MImageBody {
class MStickerBodyInner extends MImageBodyInner {
// Mostly empty to prevent default behaviour of MImageBody
protected onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
if (!this.state.showImage) {
this.showImage();
if (!this.props.mediaVisible) {
this.props.setMediaVisible?.(true);
}
};
@@ -26,7 +28,7 @@ export default class MStickerBody extends MImageBody {
// which is added by mx_MStickerBody_wrapper
protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element {
let onClick: React.MouseEventHandler | undefined;
if (!this.state.showImage) {
if (!this.props.mediaVisible) {
onClick = this.onClick;
}
return (
@@ -75,3 +77,10 @@ export default class MStickerBody extends MImageBody {
return null; // we don't need a banner, we have a tooltip
}
}
const MStickerBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
return <MStickerBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};
export default MStickerBody;

View File

@@ -61,6 +61,7 @@ import { type GetRelationsForEvent, type IEventTileType } from "../rooms/EventTi
import { type ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils";
import PosthogTrackers from "../../../PosthogTrackers.ts";
import { HideActionButton } from "./HideActionButton.tsx";
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
@@ -535,6 +536,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()}
key="download"
/>,
<HideActionButton mxEvent={this.props.mxEvent} key="hide" />,
);
}
} else if (

View File

@@ -45,8 +45,8 @@ import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTil
// onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes?: Record<string, typeof React.Component>;
overrideEventTypes?: Record<string, typeof React.Component>;
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
// helper function to access relations for this event
getRelationsForEvent?: GetRelationsForEvent;
@@ -58,7 +58,7 @@ export interface IOperableEventTile {
getEventTileOps(): IEventTileOps | null;
}
const baseBodyTypes = new Map<string, typeof React.Component>([
const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
[MsgType.Text, TextualBody],
[MsgType.Notice, TextualBody],
[MsgType.Emote, TextualBody],
@@ -80,7 +80,7 @@ const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper?: MediaEventHelper;
private bodyTypes = new Map<string, typeof React.Component>(baseBodyTypes.entries());
private bodyTypes = new Map<string, React.ComponentType<IBodyProps>>(baseBodyTypes.entries());
private evTypes = new Map<string, React.ComponentType<IBodyProps>>(baseEvTypes.entries());
public static contextType = MatrixClientContext;
@@ -115,7 +115,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
}
private updateComponentMaps(): void {
this.bodyTypes = new Map<string, typeof React.Component>(baseBodyTypes.entries());
this.bodyTypes = new Map<string, React.ComponentType<IBodyProps>>(baseBodyTypes.entries());
for (const [bodyType, bodyComponent] of Object.entries(this.props.overrideBodyTypes ?? {})) {
this.bodyTypes.set(bodyType, bodyComponent);
}

View File

@@ -26,6 +26,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import { renderReplyTile } from "../../../events/EventTileFactory";
import { type GetRelationsForEvent } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { type IBodyProps } from "../messages/IBodyProps";
interface IProps {
mxEvent: MatrixEvent;
@@ -139,13 +140,13 @@ export default class ReplyTile extends React.PureComponent<IProps> {
);
}
const msgtypeOverrides: Record<string, typeof React.Component> = {
const msgtypeOverrides: Record<string, React.ComponentType<IBodyProps>> = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides: Record<string, typeof React.Component> = {
const evOverrides: Record<string, React.ComponentType<IBodyProps>> = {
// Use MImageReplyBody so that the sticker isn't taking up a lot of space
[EventType.Sticker]: MImageReplyBody,
};

View File

@@ -42,6 +42,7 @@ import HiddenBody from "../components/views/messages/HiddenBody";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps
@@ -64,8 +65,8 @@ export interface EventTileTypeProps
ref?: React.RefObject<any>; // `any` because it's effectively impossible to convince TS of a reasonable type
timestamp?: JSX.Element;
maxImageHeight?: number; // pixels
overrideBodyTypes?: Record<string, typeof React.Component>;
overrideEventTypes?: Record<string, typeof React.Component>;
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
}
type FactoryProps = Omit<EventTileTypeProps, "ref">;

View File

@@ -0,0 +1,35 @@
/*
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 { useCallback } from "react";
import { SettingLevel } from "../settings/SettingLevel";
import { useSettingValue } from "./useSettings";
import SettingsStore from "../settings/SettingsStore";
/**
* Should the media event be visible in the client, or hidden.
* @param eventId The eventId of the media event.
* @returns A boolean describing the hidden status, and a function to set the visiblity.
*/
export function useMediaVisible(eventId: string): [boolean, (visible: boolean) => void] {
const defaultShowImages = useSettingValue("showImages", SettingLevel.DEVICE);
const eventVisibility = useSettingValue("showMediaEventIds", SettingLevel.DEVICE);
const setMediaVisible = useCallback(
(visible: boolean) => {
SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
...eventVisibility,
[eventId]: visible,
});
},
[eventId, eventVisibility],
);
// Always prefer the explicit per-event user preference here.
const imgIsVisible = eventVisibility[eventId] ?? defaultShowImages;
return [imgIsVisible, setMediaVisible];
}

View File

@@ -64,6 +64,7 @@
"go": "Go",
"go_back": "Go back",
"got_it": "Got it",
"hide": "Hide",
"hide_advanced": "Hide advanced",
"hold": "Hold",
"ignore": "Ignore",

View File

@@ -273,6 +273,7 @@ export interface Settings {
"language": IBaseSetting<string>;
"breadcrumb_rooms": IBaseSetting<string[]>;
"recent_emoji": IBaseSetting<RecentEmojiData>;
"showMediaEventIds": IBaseSetting<{ [eventId: string]: boolean }>;
"SpotlightSearch.recentSearches": IBaseSetting<string[]>;
"SpotlightSearch.showNsfwPublicRooms": IBaseSetting<boolean>;
"room_directory_servers": IBaseSetting<string[]>;
@@ -969,6 +970,11 @@ export const SETTINGS: Settings = {
supportedLevels: [SettingLevel.ACCOUNT],
default: [], // list of room IDs, most recent first
},
"showMediaEventIds": {
// not really a setting
supportedLevels: [SettingLevel.DEVICE],
default: {}, // List of events => is visible
},
"SpotlightSearch.showNsfwPublicRooms": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|show_nsfw_content"),

View File

@@ -697,6 +697,24 @@ export default class SettingsStore {
client.on(ClientEvent.Sync, onSync);
}
/**
* Migrate the setting for visible images to a setting.
*/
private static migrateShowImagesToSettings(): void {
const MIGRATION_DONE_FLAG = "mx_show_images_migration_done";
if (localStorage.getItem(MIGRATION_DONE_FLAG)) return;
logger.info("Performing one-time settings migration of shown images to settings store");
const newValue = Object.fromEntries(
Object.keys(localStorage)
.filter((k) => k.startsWith("mx_ShowImage_"))
.map((k) => [k.slice("mx_ShowImage_".length), true]),
);
this.setValue("showMediaEventIds", null, SettingLevel.DEVICE, newValue);
localStorage.setItem(MIGRATION_DONE_FLAG, "true");
}
/**
* Runs or queues any setting migrations needed.
*/
@@ -708,6 +726,12 @@ export default class SettingsStore {
// be disabled in E2EE rooms.
SettingsStore.migrateURLPreviewsE2EE(isFreshLogin);
// This can be removed once enough users have run a version of Element with
// this migration.
// The consequences of missing the migration are that previously shown images
// will now be hidden again, so this fails safely.
SettingsStore.migrateShowImagesToSettings();
// Dev notes: to add your migration, just add a new `migrateMyFeature` function, call it, and
// add a comment to note when it can be removed.
return;

View File

@@ -0,0 +1,76 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2024 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 } from "jest-matrix-react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { HideActionButton } from "../../../../../src/components/views/messages/HideActionButton";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import type { Settings } from "../../../../../src/settings/Settings";
function mockSetting(
showImages: Settings["showImages"]["default"],
showMediaEventIds: Settings["showMediaEventIds"]["default"],
) {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "showImages") {
return showImages;
} else if (settingName === "showMediaEventIds") {
return showMediaEventIds;
}
throw Error(`Unexpected setting ${settingName}`);
});
}
const event = new MatrixEvent({
event_id: "$foo:bar",
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
describe("HideActionButton", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("should show button when event is visible by showMediaEventIds setting", async () => {
mockSetting(false, { "$foo:bar": true });
render(<HideActionButton mxEvent={event} />);
expect(screen.getByRole("button")).toBeVisible();
});
it("should show button when event is visible by showImages setting", async () => {
mockSetting(true, {});
render(<HideActionButton mxEvent={event} />);
expect(screen.getByRole("button")).toBeVisible();
});
it("should hide button when event is hidden by showMediaEventIds setting", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({ "$foo:bar": false });
render(<HideActionButton mxEvent={event} />);
expect(screen.queryByRole("button")).toBeNull();
});
it("should hide button when event is hidden by showImages setting", async () => {
mockSetting(false, {});
render(<HideActionButton mxEvent={event} />);
expect(screen.queryByRole("button")).toBeNull();
});
it("should store event as hidden when clicked", async () => {
const spy = jest.spyOn(SettingsStore, "setValue");
render(<HideActionButton mxEvent={event} />);
fireEvent.click(screen.getByRole("button"));
expect(spy).toHaveBeenCalledWith("showMediaEventIds", null, SettingLevel.DEVICE, { "$foo:bar": false });
// Button should be hidden after the setting is set.
expect(screen.queryByRole("button")).toBeNull();
});
});

View File

@@ -6,7 +6,7 @@ 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 { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest";
@@ -27,6 +27,7 @@ import {
} from "../../../../test-utils";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(),
@@ -57,6 +58,7 @@ describe("<MImageBody/>", () => {
},
);
const encryptedMediaEvent = new MatrixEvent({
event_id: "$foo:bar",
room_id: "!room:server",
sender: userId,
type: EventType.RoomMessage,
@@ -131,7 +133,26 @@ describe("<MImageBody/>", () => {
describe("with image previews/thumbnails disabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
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 image", async () => {
@@ -163,7 +184,6 @@ describe("<MImageBody/>", () => {
fireEvent.click(screen.getByRole("button"));
// image fetched after clicking show image
expect(fetchMock).toHaveFetched(url);
// spinner while downloading image

View File

@@ -0,0 +1,71 @@
/*
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 { act, renderHook, waitFor } from "jest-matrix-react";
import { useMediaVisible } from "../../../src/hooks/useMediaVisible";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
const EVENT_ID = "$fibble:example.org";
function render() {
return renderHook(() => useMediaVisible(EVENT_ID));
}
describe("useMediaVisible", () => {
afterEach(() => {
// Using act here as otherwise React warns about state updates not being wrapped.
act(() => {
SettingsStore.setValue(
"showMediaEventIds",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showMediaEventIds"),
);
SettingsStore.setValue(
"showImages",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showImages"),
);
});
});
it("should display images by default", async () => {
const { result } = render();
expect(result.current[0]).toEqual(true);
});
it("should hide images when the default is changed", async () => {
SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false);
const { result } = render();
expect(result.current[0]).toEqual(false);
});
it("should hide images after function is called", async () => {
const { result } = render();
expect(result.current[0]).toEqual(true);
act(() => {
result.current[1](false);
});
await waitFor(() => {
expect(result.current[0]).toEqual(false);
});
});
it("should show images after function is called", async () => {
SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false);
const { result } = render();
expect(result.current[0]).toEqual(false);
act(() => {
result.current[1](true);
});
await waitFor(() => {
expect(result.current[0]).toEqual(true);
});
});
});