mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-05 01:10:40 +00:00
Compare commits
25 Commits
renovate/t
...
hs/add-hid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a185baea | ||
|
|
93009d4613 | ||
|
|
71257d97e7 | ||
|
|
60eeb8a7de | ||
|
|
571a2e373d | ||
|
|
d0b8564660 | ||
|
|
28ea91566a | ||
|
|
ef32747473 | ||
|
|
7696516e8b | ||
|
|
46b1234a1d | ||
|
|
b9c0d63e3e | ||
|
|
cf7e52c6fc | ||
|
|
e87eb127ee | ||
|
|
83e421daf2 | ||
|
|
d6fb24dea7 | ||
|
|
a518c8d662 | ||
|
|
c759e516bd | ||
|
|
c8b55c3dfe | ||
|
|
7197093744 | ||
|
|
4e34adb854 | ||
|
|
72c2a3eb07 | ||
|
|
4d290461c4 | ||
|
|
0cc06450d7 | ||
|
|
9376d71831 | ||
|
|
6d5442a87b |
@@ -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"] }, () => {
|
||||
|
||||
44
src/components/views/messages/HideActionButton.tsx
Normal file
44
src/components/views/messages/HideActionButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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">;
|
||||
|
||||
35
src/hooks/useMediaVisible.ts
Normal file
35
src/hooks/useMediaVisible.ts
Normal 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];
|
||||
}
|
||||
@@ -64,6 +64,7 @@
|
||||
"go": "Go",
|
||||
"go_back": "Go back",
|
||||
"got_it": "Got it",
|
||||
"hide": "Hide",
|
||||
"hide_advanced": "Hide advanced",
|
||||
"hold": "Hold",
|
||||
"ignore": "Ignore",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
71
test/unit-tests/hooks/useMediaVisible-test.tsx
Normal file
71
test/unit-tests/hooks/useMediaVisible-test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user