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

View File

@@ -33,6 +33,7 @@ import { presentableTextForFile } from "../../../utils/FileUtils";
import { createReconnectedListener } from "../../../utils/connection"; import { createReconnectedListener } from "../../../utils/connection";
import MediaProcessingError from "./shared/MediaProcessingError"; import MediaProcessingError from "./shared/MediaProcessingError";
import { DecryptError, DownloadError } from "../../../utils/DecryptFile"; import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
enum Placeholder { enum Placeholder {
NoImage, NoImage,
@@ -52,11 +53,25 @@ interface IState {
}; };
hover: boolean; hover: boolean;
focus: boolean; focus: boolean;
showImage: boolean;
placeholder: Placeholder; 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; public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>; declare public context: React.ContextType<typeof RoomContext>;
@@ -73,21 +88,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
imgLoaded: false, imgLoaded: false,
hover: false, hover: false,
focus: false, focus: false,
showImage: SettingsStore.getValue("showImages"),
placeholder: Placeholder.NoImage, 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 => { protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) { if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault(); ev.preventDefault();
if (!this.state.showImage) { if (!this.props.mediaVisible) {
this.showImage(); this.props.setMediaVisible?.(true);
return; return;
} }
@@ -125,7 +133,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private get shouldAutoplay(): boolean { private get shouldAutoplay(): boolean {
return !( return !(
!this.state.contentUrl || !this.state.contentUrl ||
!this.state.showImage || !this.props.mediaVisible ||
!this.state.isAnimated || !this.state.isAnimated ||
SettingsStore.getValue("autoplayGifs") SettingsStore.getValue("autoplayGifs")
); );
@@ -346,14 +354,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
public componentDidMount(): void { public componentDidMount(): void {
this.unmounted = false; this.unmounted = false;
const showImage = if (this.props.mediaVisible) {
this.state.showImage || localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
this.downloadImage(); 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. // Add a 150ms timer for blurhash to first appear.
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) { 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 { public componentWillUnmount(): void {
this.unmounted = true; this.unmounted = true;
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); 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. // by the same width and height logic below.
if (!this.state.loadedImageDimensions) { if (!this.state.loadedImageDimensions) {
let imageElement: JSX.Element; let imageElement: JSX.Element;
if (!this.state.showImage) { if (!this.props.mediaVisible) {
imageElement = <HiddenImagePlaceholder />; imageElement = <HiddenImagePlaceholder />;
} else { } else {
imageElement = ( 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} />; img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. 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; let banner: ReactNode | undefined;
if (this.state.showImage && hoverOrFocus) { if (this.props.mediaVisible && hoverOrFocus) {
banner = this.getBanner(content); banner = this.getBanner(content);
} }
@@ -585,7 +596,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
{children} {children}
</a> </a>
); );
} else if (!this.state.showImage) { } else if (!this.props.mediaVisible) {
return ( return (
<div role="button" onClick={this.onClick}> <div role="button" onClick={this.onClick}>
{children} {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 React from "react";
import { type ImageContent } from "matrix-js-sdk/src/types"; 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; const FORCED_IMAGE_HEIGHT = 44;
export default class MImageReplyBody extends MImageBody { class MImageReplyBodyInner extends MImageBodyInner {
public onClick = (ev: React.MouseEvent): void => { public onClick = (ev: React.MouseEvent): void => {
ev.preventDefault(); ev.preventDefault();
}; };
@@ -35,3 +37,9 @@ export default class MImageReplyBody extends MImageBody {
return <div className="mx_MImageReplyBody">{thumbnail}</div>; 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 Tooltip } from "@vector-im/compound-web";
import { type MediaEventContent } from "matrix-js-sdk/src/types"; import { type MediaEventContent } from "matrix-js-sdk/src/types";
import MImageBody from "./MImageBody"; import { MImageBodyInner } from "./MImageBody";
import { BLURHASH_FIELD } from "../../../utils/image-media"; import { BLURHASH_FIELD } from "../../../utils/image-media";
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg"; import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
import { type IBodyProps } from "./IBodyProps";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
export default class MStickerBody extends MImageBody { class MStickerBodyInner extends MImageBodyInner {
// Mostly empty to prevent default behaviour of MImageBody // Mostly empty to prevent default behaviour of MImageBody
protected onClick = (ev: React.MouseEvent): void => { protected onClick = (ev: React.MouseEvent): void => {
ev.preventDefault(); ev.preventDefault();
if (!this.state.showImage) { if (!this.props.mediaVisible) {
this.showImage(); this.props.setMediaVisible?.(true);
} }
}; };
@@ -26,7 +28,7 @@ export default class MStickerBody extends MImageBody {
// which is added by mx_MStickerBody_wrapper // which is added by mx_MStickerBody_wrapper
protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element { protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element {
let onClick: React.MouseEventHandler | undefined; let onClick: React.MouseEventHandler | undefined;
if (!this.state.showImage) { if (!this.props.mediaVisible) {
onClick = this.onClick; onClick = this.onClick;
} }
return ( return (
@@ -75,3 +77,10 @@ export default class MStickerBody extends MImageBody {
return null; // we don't need a banner, we have a tooltip 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 { type ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils"; import PinningUtils from "../../../utils/PinningUtils";
import PosthogTrackers from "../../../PosthogTrackers.ts"; import PosthogTrackers from "../../../PosthogTrackers.ts";
import { HideActionButton } from "./HideActionButton.tsx";
interface IOptionsButtonProps { interface IOptionsButtonProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@@ -535,6 +536,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()} mediaEventHelperGet={() => this.props.getTile()?.getMediaHelper?.()}
key="download" key="download"
/>, />,
<HideActionButton mxEvent={this.props.mxEvent} key="hide" />,
); );
} }
} else if ( } else if (

View File

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

View File

@@ -26,6 +26,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import { renderReplyTile } from "../../../events/EventTileFactory"; import { renderReplyTile } from "../../../events/EventTileFactory";
import { type GetRelationsForEvent } from "../rooms/EventTile"; import { type GetRelationsForEvent } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { type IBodyProps } from "../messages/IBodyProps";
interface IProps { interface IProps {
mxEvent: MatrixEvent; 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, [MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS // Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody, [MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody,
[MsgType.Video]: 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 // Use MImageReplyBody so that the sticker isn't taking up a lot of space
[EventType.Sticker]: MImageReplyBody, [EventType.Sticker]: MImageReplyBody,
}; };

View File

@@ -42,6 +42,7 @@ import HiddenBody from "../components/views/messages/HiddenBody";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { ElementCall } from "../models/Call"; import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
// Subset of EventTile's IProps plus some mixins // Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps 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 ref?: React.RefObject<any>; // `any` because it's effectively impossible to convince TS of a reasonable type
timestamp?: JSX.Element; timestamp?: JSX.Element;
maxImageHeight?: number; // pixels maxImageHeight?: number; // pixels
overrideBodyTypes?: Record<string, typeof React.Component>; overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, typeof React.Component>; overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
} }
type FactoryProps = Omit<EventTileTypeProps, "ref">; 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": "Go",
"go_back": "Go back", "go_back": "Go back",
"got_it": "Got it", "got_it": "Got it",
"hide": "Hide",
"hide_advanced": "Hide advanced", "hide_advanced": "Hide advanced",
"hold": "Hold", "hold": "Hold",
"ignore": "Ignore", "ignore": "Ignore",

View File

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

View File

@@ -697,6 +697,24 @@ export default class SettingsStore {
client.on(ClientEvent.Sync, onSync); 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. * Runs or queues any setting migrations needed.
*/ */
@@ -708,6 +726,12 @@ export default class SettingsStore {
// be disabled in E2EE rooms. // be disabled in E2EE rooms.
SettingsStore.migrateURLPreviewsE2EE(isFreshLogin); 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 // 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. // add a comment to note when it can be removed.
return; 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. 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 { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest"; import fetchMock from "fetch-mock-jest";
@@ -27,6 +27,7 @@ import {
} from "../../../../test-utils"; } from "../../../../test-utils";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
jest.mock("matrix-encrypt-attachment", () => ({ jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(), decryptAttachment: jest.fn(),
@@ -57,6 +58,7 @@ describe("<MImageBody/>", () => {
}, },
); );
const encryptedMediaEvent = new MatrixEvent({ const encryptedMediaEvent = new MatrixEvent({
event_id: "$foo:bar",
room_id: "!room:server", room_id: "!room:server",
sender: userId, sender: userId,
type: EventType.RoomMessage, type: EventType.RoomMessage,
@@ -131,7 +133,26 @@ describe("<MImageBody/>", () => {
describe("with image previews/thumbnails disabled", () => { describe("with image previews/thumbnails disabled", () => {
beforeEach(() => { 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 () => { it("should not download image", async () => {
@@ -163,7 +184,6 @@ describe("<MImageBody/>", () => {
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole("button"));
// image fetched after clicking show image
expect(fetchMock).toHaveFetched(url); expect(fetchMock).toHaveFetched(url);
// spinner while downloading image // 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);
});
});
});