Add support for hiding videos (#29496)

* 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

* First pass at support for previewing/hiding images.

* Add a test for video files.

* First pass at supporting hiding video files.

* 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

* Use new wrapping logic

* lint

* fixup

* allow for a delay for the image to render

* remove .only

* lint

* Fix jest test

* Fixup tests.

* make tests happy

* Improve comments

* review fixes

* unbreak test
This commit is contained in:
Will Hunt
2025-03-24 14:38:34 +00:00
committed by GitHub
parent 74da64db63
commit 13c4ab2cf4
14 changed files with 290 additions and 115 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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