From e662c1959b45c2fcc2c55e6fba71d5a87fdc6383 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Tue, 18 Mar 2025 14:23:24 +0000 Subject: [PATCH] Add ability to hide images after clicking "show image" (#29467) * start hide * Move useSettingsValueWithSetter to useSettings * Add new setting showMediaEventIds * Add a migration path * Add an action button to hide settings. * Tweaks to MImageBody to support new setting. * Fixup and add tests * add description for migration * docs fixes * add type * i18n * appese prettier * Add tests for HideActionButton * lint * lint * Use a hook for media visibility. * Drop setting hook usage. * Fixup MImageBody test * Fixup tests * Support functional components for message body rendering. * Add a comment * Move props into IProps --- playwright/e2e/timeline/timeline.spec.ts | 15 +++- .../views/messages/HideActionButton.tsx | 44 +++++++++++ src/components/views/messages/MFileBody.tsx | 13 ++-- src/components/views/messages/MImageBody.tsx | 62 +++++++++------ .../views/messages/MImageReplyBody.tsx | 12 ++- .../views/messages/MStickerBody.tsx | 19 +++-- .../views/messages/MessageActionBar.tsx | 2 + .../views/messages/MessageEvent.tsx | 10 +-- src/components/views/rooms/ReplyTile.tsx | 5 +- src/events/EventTileFactory.tsx | 5 +- src/hooks/useMediaVisible.ts | 35 +++++++++ src/i18n/strings/en_EN.json | 1 + src/settings/Settings.tsx | 6 ++ src/settings/SettingsStore.ts | 24 ++++++ .../views/messages/HideActionButton-test.tsx | 76 +++++++++++++++++++ .../views/messages/MImageBody-test.tsx | 26 ++++++- .../unit-tests/hooks/useMediaVisible-test.tsx | 71 +++++++++++++++++ 17 files changed, 376 insertions(+), 50 deletions(-) create mode 100644 src/components/views/messages/HideActionButton.tsx create mode 100644 src/hooks/useMediaVisible.ts create mode 100644 test/unit-tests/components/views/messages/HideActionButton-test.tsx create mode 100644 test/unit-tests/hooks/useMediaVisible-test.tsx diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 7b13d1ccb1..1f353e3e84 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -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"] }, () => { diff --git a/src/components/views/messages/HideActionButton.tsx b/src/components/views/messages/HideActionButton.tsx new file mode 100644 index 0000000000..8d2baf0220 --- /dev/null +++ b/src/components/views/messages/HideActionButton.tsx @@ -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 = ({ mxEvent }) => { + const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId()!); + + if (!mediaIsVisible) { + return; + } + + return ( + setVisible(false)} + placement="left" + > + + + ); +}; diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index 674e0f8583..3bb9fb1069 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -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 { declare public context: React.ContextType; public state: IState = {}; - - public static defaultProps = { - showGenericPlaceholder: true, - }; - private iframe: React.RefObject = createRef(); private dummyLink: React.RefObject = createRef(); private userDidClick = false; @@ -191,15 +186,17 @@ export default class MFileBody extends React.Component { 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 = ( diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 59d8b988a8..3fa4f01bfd 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -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 { +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 { public static contextType = RoomContext; declare public context: React.ContextType; @@ -73,21 +88,14 @@ export default class MImageBody extends React.Component { 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 { 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 { 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 { }); } + public componentDidUpdate(prevProps: Readonly): 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 { // 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 = ; } else { imageElement = ( @@ -495,7 +506,7 @@ export default class MImageBody extends React.Component { ); } - if (!this.state.showImage) { + if (!this.props.mediaVisible) { img = ; 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 { } 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 { {children} ); - } else if (!this.state.showImage) { + } else if (!this.props.mediaVisible) { return (
{children} @@ -686,3 +697,10 @@ export class HiddenImagePlaceholder extends React.PureComponent = (props) => { + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!); + return ; +}; + +export default MImageBody; diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index 32fd27c62a..5bbb00ef44 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -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
{thumbnail}
; } } +const MImageReplyBody: React.FC = (props) => { + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!); + return ; +}; + +export default MImageReplyBody; diff --git a/src/components/views/messages/MStickerBody.tsx b/src/components/views/messages/MStickerBody.tsx index ccb9bb437b..29082512d0 100644 --- a/src/components/views/messages/MStickerBody.tsx +++ b/src/components/views/messages/MStickerBody.tsx @@ -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 = (props) => { + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!); + return ; +}; + +export default MStickerBody; diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index e6838c635f..0120947b31 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -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 this.props.getTile()?.getMediaHelper?.()} key="download" />, + , ); } } else if ( diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index be63457902..ff97edd1db 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -45,8 +45,8 @@ import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTil // onMessageAllowed is handled internally interface IProps extends Omit { /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */ - overrideBodyTypes?: Record; - overrideEventTypes?: Record; + overrideBodyTypes?: Record>; + overrideEventTypes?: Record>; // helper function to access relations for this event getRelationsForEvent?: GetRelationsForEvent; @@ -58,7 +58,7 @@ export interface IOperableEventTile { getEventTileOps(): IEventTileOps | null; } -const baseBodyTypes = new Map([ +const baseBodyTypes = new Map>([ [MsgType.Text, TextualBody], [MsgType.Notice, TextualBody], [MsgType.Emote, TextualBody], @@ -80,7 +80,7 @@ const baseEvTypes = new Map>([ export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { private body: React.RefObject = createRef(); private mediaHelper?: MediaEventHelper; - private bodyTypes = new Map(baseBodyTypes.entries()); + private bodyTypes = new Map>(baseBodyTypes.entries()); private evTypes = new Map>(baseEvTypes.entries()); public static contextType = MatrixClientContext; @@ -115,7 +115,7 @@ export default class MessageEvent extends React.Component implements IMe } private updateComponentMaps(): void { - this.bodyTypes = new Map(baseBodyTypes.entries()); + this.bodyTypes = new Map>(baseBodyTypes.entries()); for (const [bodyType, bodyComponent] of Object.entries(this.props.overrideBodyTypes ?? {})) { this.bodyTypes.set(bodyType, bodyComponent); } diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx index 8084b59ba3..0f2b580d7e 100644 --- a/src/components/views/rooms/ReplyTile.tsx +++ b/src/components/views/rooms/ReplyTile.tsx @@ -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 { ); } - const msgtypeOverrides: Record = { + const msgtypeOverrides: Record> = { [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 = { + const evOverrides: Record> = { // Use MImageReplyBody so that the sticker isn't taking up a lot of space [EventType.Sticker]: MImageReplyBody, }; diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index e0d2a91afb..633583485a 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -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` because it's effectively impossible to convince TS of a reasonable type timestamp?: JSX.Element; maxImageHeight?: number; // pixels - overrideBodyTypes?: Record; - overrideEventTypes?: Record; + overrideBodyTypes?: Record>; + overrideEventTypes?: Record>; } type FactoryProps = Omit; diff --git a/src/hooks/useMediaVisible.ts b/src/hooks/useMediaVisible.ts new file mode 100644 index 0000000000..e244d500cf --- /dev/null +++ b/src/hooks/useMediaVisible.ts @@ -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]; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index aa71cac8a7..322fcf4c93 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -64,6 +64,7 @@ "go": "Go", "go_back": "Go back", "got_it": "Got it", + "hide": "Hide", "hide_advanced": "Hide advanced", "hold": "Hold", "ignore": "Ignore", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index d887c9bb49..8784e12b6c 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -273,6 +273,7 @@ export interface Settings { "language": IBaseSetting; "breadcrumb_rooms": IBaseSetting; "recent_emoji": IBaseSetting; + "showMediaEventIds": IBaseSetting<{ [eventId: string]: boolean }>; "SpotlightSearch.recentSearches": IBaseSetting; "SpotlightSearch.showNsfwPublicRooms": IBaseSetting; "room_directory_servers": IBaseSetting; @@ -970,6 +971,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"), diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 671a2ac676..7dac777399 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -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; diff --git a/test/unit-tests/components/views/messages/HideActionButton-test.tsx b/test/unit-tests/components/views/messages/HideActionButton-test.tsx new file mode 100644 index 0000000000..57e92b02b8 --- /dev/null +++ b/test/unit-tests/components/views/messages/HideActionButton-test.tsx @@ -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(); + expect(screen.getByRole("button")).toBeVisible(); + }); + it("should show button when event is visible by showImages setting", async () => { + mockSetting(true, {}); + render(); + 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(); + expect(screen.queryByRole("button")).toBeNull(); + }); + it("should hide button when event is hidden by showImages setting", async () => { + mockSetting(false, {}); + render(); + expect(screen.queryByRole("button")).toBeNull(); + }); + it("should store event as hidden when clicked", async () => { + const spy = jest.spyOn(SettingsStore, "setValue"); + render(); + 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(); + }); +}); diff --git a/test/unit-tests/components/views/messages/MImageBody-test.tsx b/test/unit-tests/components/views/messages/MImageBody-test.tsx index f9ba25d7a9..9d324f13e8 100644 --- a/test/unit-tests/components/views/messages/MImageBody-test.tsx +++ b/test/unit-tests/components/views/messages/MImageBody-test.tsx @@ -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("", () => { }, ); const encryptedMediaEvent = new MatrixEvent({ + event_id: "$foo:bar", room_id: "!room:server", sender: userId, type: EventType.RoomMessage, @@ -131,7 +133,26 @@ describe("", () => { 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("", () => { fireEvent.click(screen.getByRole("button")); - // image fetched after clicking show image expect(fetchMock).toHaveFetched(url); // spinner while downloading image diff --git a/test/unit-tests/hooks/useMediaVisible-test.tsx b/test/unit-tests/hooks/useMediaVisible-test.tsx new file mode 100644 index 0000000000..cafc32f1bf --- /dev/null +++ b/test/unit-tests/hooks/useMediaVisible-test.tsx @@ -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); + }); + }); +});