Global configuration flag for media previews (#29582)

* Modify useMediaVisible to take a room.

* Add initial support for a account data level key.

* Update controls.

* Update settings

* Lint and fixes

* make some tests go happy

* lint

* i18n

* update preferences

* prettier

* Update settings tab.

* update screenshot

* Update docs

* Rewrite controller

* Rewrite tons of tests

* Rewrite RoomAvatar to be a functional component

This is so we can use hooks to determine the setting state.

* lint

* lint

* Tidy up comments

* Apply media visible hook to inline images.

* Move conditionals.

* copyright all the things

* Review changes

* Update html utils to properly discard media.

* Types fix

* Fixing tests that break settings getValue expectations

* Fix logic around media preview calculation

* Fix room header tests

* Fixup tests for timelinePanel

* Clear settings in matrixchat

* Update tests to use SettingsStore where possible.

* fix bug

* revert changes to client.ts

* copyright years

* Add header

* Add a test for MediaPreviewAccountSettingsTab

* Mark initMatrixClient as optional

* Improve on types

* Ensure we do not set the account data twice.

* lint

* Review changes

* Ensure we include the client on rendered messages.

* Fix test

* update labels

* clean designs

* update settings tab

* update snapshot

* copyright

* prevent mutation
This commit is contained in:
Will Hunt
2025-04-22 10:37:47 +01:00
committed by GitHub
parent da6ac36f11
commit 75d9898dff
44 changed files with 1427 additions and 422 deletions

View File

@@ -20,6 +20,7 @@ import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
import { useRoomState } from "../../../hooks/useRoomState";
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
import { MediaPreviewValue } from "../../../@types/media_preview";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
// Room may be left unset here, but if it is,
@@ -40,7 +41,8 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
const roomIdName = useRoomIdName(room, oobData);
const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
const showAvatarsOnInvites =
useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On;
const onRoomAvatarClick = useCallback(() => {
const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
@@ -63,7 +65,6 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
// parseInt ignores suffixes.
const sizeInt = parseInt(size, 10);
let oobAvatar: string | null = null;
if (oobData?.avatarUrl) {
oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
}

View File

@@ -28,6 +28,7 @@ import {
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
import { useSettingValue } from "../../../hooks/useSettings.ts";
import { filterBoolean } from "../../../utils/arrays.ts";
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
/**
* Returns a RegExp pattern for the keyword in the push rule of the given Matrix event, if any
@@ -150,6 +151,7 @@ const EventContentBody = memo(
forwardRef<HTMLElement, Props>(
({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ...options }, ref) => {
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId());
const replacer = useReplacer(content, mxEvent, options);
const linkifyOptions = useMemo(
@@ -167,8 +169,9 @@ const EventContentBody = memo(
disableBigEmoji: isEmote || !enableBigEmoji,
// Part of Replies fallback support
stripReplyFallback: stripReply,
mediaIsVisible,
}),
[content, enableBigEmoji, highlights, isEmote, stripReply],
[content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply],
);
if (as === "div") includeDir = true; // force dir="auto" on divs

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 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
@@ -25,7 +25,7 @@ interface IProps {
* Quick action button for marking a media event as hidden.
*/
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId()!);
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
if (!mediaIsVisible) {
return;

View File

@@ -686,7 +686,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
// Wrap MImageBody component so we can use a hook here.
const MImageBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -38,7 +38,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
}
}
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -79,7 +79,7 @@ class MStickerBodyInner extends MImageBodyInner {
}
const MStickerBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
return <MStickerBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -342,7 +342,7 @@ class MVideoBodyInner extends React.PureComponent<IProps, IState> {
// Wrap MVideoBody component so we can use a hook here.
const MVideoBody: React.FC<IBodyProps> = (props) => {
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
return <MVideoBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
};

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 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
@@ -17,6 +17,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
const INITIAL_NUM_PREVIEWS = 2;
@@ -29,6 +30,7 @@ interface IProps {
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) => {
const cli = useContext(MatrixClientContext);
const [expanded, toggleExpanded] = useStateToggle();
const [mediaVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(
@@ -55,7 +57,13 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) =
return (
<div className="mx_LinkPreviewGroup">
{showPreviews.map(([link, preview], i) => (
<LinkPreviewWidget key={link} link={link} preview={preview} mxEvent={mxEvent}>
<LinkPreviewWidget
mediaVisible={mediaVisible}
key={link}
link={link}
preview={preview}
mxEvent={mxEvent}
>
{i === 0 ? (
<AccessibleButton
className="mx_LinkPreviewGroup_hide"

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -11,7 +11,6 @@ import { decode } from "html-entities";
import { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
import { Linkify } from "../../../HtmlUtils";
import SettingsStore from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils";
import { mediaFromMxc } from "../../../customisations/Media";
@@ -24,6 +23,7 @@ interface IProps {
preview: IPreviewUrlResponse;
mxEvent: MatrixEvent; // the Event associated with the preview
children?: ReactNode;
mediaVisible: boolean;
}
export default class LinkPreviewWidget extends React.Component<IProps> {
@@ -69,7 +69,7 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
// FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing?
let image: string | null = p["og:image"] ?? null;
if (!SettingsStore.getValue("showImages")) {
if (!this.props.mediaVisible) {
image = null; // Don't render a button to show the image, just hide it outright
}
const imageMaxWidth = 100;

View File

@@ -1,5 +1,5 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019-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.
@@ -22,6 +22,7 @@ import { SettingsSubsection } from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings";
import { MediaPreviewAccountSettings } from "../user/MediaPreviewAccountSettings";
interface IProps {
room: Room;
@@ -92,6 +93,9 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
<SettingsSection heading={_t("room_settings|general|other_section")}>
{urlPreviewSettings}
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings roomId={room.roomId} />
</SettingsSubsection>
{leaveSection}
</SettingsSection>
</SettingsTab>

View File

@@ -0,0 +1,150 @@
/*
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 ChangeEventHandler, useCallback } from "react";
import { Field, HelpMessage, InlineField, Label, RadioInput, Root } from "@vector-im/compound-web";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { type MediaPreviewConfig, MediaPreviewValue } from "../../../../../@types/media_preview";
import { _t } from "../../../../../languageHandler";
import { useSettingValue } from "../../../../../hooks/useSettings";
import SettingsStore from "../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../settings/SettingLevel";
export const MediaPreviewAccountSettings: React.FC<{ roomId?: string }> = ({ roomId }) => {
const currentMediaPreview = useSettingValue("mediaPreviewConfig", roomId);
const changeSetting = useCallback(
(newValue: MediaPreviewConfig) => {
SettingsStore.setValue(
"mediaPreviewConfig",
roomId ?? null,
roomId ? SettingLevel.ROOM_ACCOUNT : SettingLevel.ACCOUNT,
newValue,
);
},
[roomId],
);
const avatarOnChange = useCallback(
(c: boolean) => {
changeSetting({
...currentMediaPreview,
// Switch is inverted. "Hide avatars..."
invite_avatars: c ? MediaPreviewValue.Off : MediaPreviewValue.On,
});
},
[changeSetting, currentMediaPreview],
);
const mediaPreviewOnChangeOff = useCallback<ChangeEventHandler<HTMLInputElement>>(
(event) => {
if (!event.target.checked) {
return;
}
changeSetting({
...currentMediaPreview,
media_previews: MediaPreviewValue.Off,
});
},
[changeSetting, currentMediaPreview],
);
const mediaPreviewOnChangePrivate = useCallback<ChangeEventHandler<HTMLInputElement>>(
(event) => {
if (!event.target.checked) {
return;
}
changeSetting({
...currentMediaPreview,
media_previews: MediaPreviewValue.Private,
});
},
[changeSetting, currentMediaPreview],
);
const mediaPreviewOnChangeOn = useCallback<ChangeEventHandler<HTMLInputElement>>(
(event) => {
if (!event.target.checked) {
return;
}
changeSetting({
...currentMediaPreview,
media_previews: MediaPreviewValue.On,
});
},
[changeSetting, currentMediaPreview],
);
return (
<Root className="mx_MediaPreviewAccountSetting_Form">
{!roomId && (
<LabelledToggleSwitch
className="mx_MediaPreviewAccountSetting_ToggleSwitch"
label={_t("settings|media_preview|hide_avatars")}
value={currentMediaPreview.invite_avatars === MediaPreviewValue.Off}
onChange={avatarOnChange}
/>
)}
{/* Explict label here because htmlFor is not supported for linking to radiogroups */}
<Field
id="mx_media_previews"
role="radiogroup"
name="media_previews"
aria-label={_t("settings|media_preview|media_preview_label")}
>
<Label>{_t("settings|media_preview|media_preview_label")}</Label>
<HelpMessage className="mx_MediaPreviewAccountSetting_RadioHelp">
{_t("settings|media_preview|media_preview_description")}
</HelpMessage>
<InlineField
name="media_preview_off"
className="mx_MediaPreviewAccountSetting_Radio"
control={
<RadioInput
id="mx_media_previews_off"
checked={currentMediaPreview.media_previews === MediaPreviewValue.Off}
onChange={mediaPreviewOnChangeOff}
/>
}
>
<Label htmlFor="mx_media_previews_off">{_t("settings|media_preview|hide_media")}</Label>
</InlineField>
{!roomId && (
<InlineField
name="mx_media_previews_private"
className="mx_MediaPreviewAccountSetting_Radio"
control={
<RadioInput
id="mx_media_previews_private"
checked={currentMediaPreview.media_previews === MediaPreviewValue.Private}
onChange={mediaPreviewOnChangePrivate}
/>
}
>
<Label htmlFor="mx_media_previews_private">
{_t("settings|media_preview|show_in_private")}
</Label>
</InlineField>
)}
<InlineField
name="media_preview_on"
className="mx_MediaPreviewAccountSetting_Radio"
control={
<RadioInput
id="mx_media_previews_on"
checked={currentMediaPreview.media_previews === MediaPreviewValue.On}
onChange={mediaPreviewOnChangeOn}
/>
}
>
<Label htmlFor="mx_media_previews_on">{_t("settings|media_preview|show_media")}</Label>
</InlineField>
</Field>
</Root>
);
};

View File

@@ -32,6 +32,7 @@ import SpellCheckSettings from "../../SpellCheckSettings";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import * as TimezoneHandler from "../../../../../TimezoneHandler";
import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx";
import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx";
interface IProps {
closeSettingsFn(success: boolean): void;
@@ -116,7 +117,7 @@ const SpellCheckSection: React.FC = () => {
};
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
private static ROOM_LIST_SETTINGS: BooleanSettingKey[] = ["breadcrumbs", "showAvatarsOnInvites"];
private static ROOM_LIST_SETTINGS: BooleanSettingKey[] = ["breadcrumbs"];
private static SPACES_SETTINGS: BooleanSettingKey[] = ["Spaces.allRoomsInHome"];
@@ -146,7 +147,6 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
"urlPreviewsEnabled",
"autoplayGifs",
"autoplayVideo",
"showImages",
];
private static TIMELINE_SETTINGS: BooleanSettingKey[] = [
@@ -335,6 +335,10 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings />
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|room_directory_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.ROOM_DIRECTORY_SETTINGS)}
</SettingsSubsection>