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:
24
src/components/views/messages/HiddenMediaPlaceholder.tsx
Normal file
24
src/components/views/messages/HiddenMediaPlaceholder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user