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

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
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
@@ -14,6 +14,7 @@ import type { EncryptedFile } from "matrix-js-sdk/src/types";
import type { EmptyObject } from "matrix-js-sdk/src/matrix";
import type { DeviceClientInformation } from "../utils/device/types.ts";
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
import { type MediaPreviewConfig } from "./media_preview.ts";
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
declare module "matrix-js-sdk/src/types" {
@@ -87,6 +88,8 @@ declare module "matrix-js-sdk/src/types" {
"m.accepted_terms": {
accepted: string[];
};
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
}
export interface AudioContent {

View File

@@ -0,0 +1,33 @@
/*
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.
*/
export enum MediaPreviewValue {
/**
* Media previews should be enabled.
*/
On = "on",
/**
* Media previews should only be enabled for rooms with non-public join rules.
*/
Private = "private",
/**
* Media previews should be disabled.
*/
Off = "off",
}
export const MEDIA_PREVIEW_ACCOUNT_DATA_TYPE = "io.element.msc4278.media_preview_config";
export interface MediaPreviewConfig extends Record<string, unknown> {
/**
* Media preview setting for thumbnails of media in rooms.
*/
media_previews: MediaPreviewValue;
/**
* Media preview settings for avatars of rooms we have been invited to.
*/
invite_avatars: MediaPreviewValue.On | MediaPreviewValue.Off;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
@@ -294,6 +294,10 @@ export interface EventRenderOpts {
disableBigEmoji?: boolean;
stripReplyFallback?: boolean;
forComposerQuote?: boolean;
/**
* Should inline media be rendered?
*/
mediaIsVisible?: boolean;
}
function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
@@ -302,6 +306,20 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
sanitizeParams = composerSanitizeHtmlParams;
}
if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) {
// Prevent mutating the source of sanitizeParams.
sanitizeParams = {
...sanitizeParams,
transformTags: {
...sanitizeParams.transformTags,
img: (tagName) => {
// Remove element
return { tagName, attribs: {} };
},
},
};
}
try {
const isFormattedBody =
content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string";

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
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
@@ -12,7 +12,6 @@ import { merge } from "lodash";
import _Linkify from "linkify-react";
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
import SettingsStore from "./settings/SettingsStore";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { mediaFromMxc } from "./customisations/Media";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
@@ -47,10 +46,7 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
// We also drop inline images (as if they were not present at all) when the "show
// images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have.
if (!src || !SettingsStore.getValue("showImages")) {
if (!src) {
return { tagName, attribs: {} };
}
@@ -78,7 +74,6 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
if (requestedHeight) {
attribs.style += "height: 100%;";
}
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!;
return { tagName, attribs };
},

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>

View File

@@ -6,30 +6,52 @@ Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import { JoinRule } from "matrix-js-sdk/src/matrix";
import { SettingLevel } from "../settings/SettingLevel";
import { useSettingValue } from "./useSettings";
import SettingsStore from "../settings/SettingsStore";
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { MediaPreviewValue } from "../@types/media_preview";
import { useRoomState } from "./useRoomState";
const PRIVATE_JOIN_RULES: JoinRule[] = [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted];
/**
* 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);
export function useMediaVisible(eventId?: string, roomId?: string): [boolean, (visible: boolean) => void] {
const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId);
const client = useMatrixClientContext();
const eventVisibility = useSettingValue("showMediaEventIds");
const joinRule = useRoomState(client.getRoom(roomId) ?? undefined, (state) => state.getJoinRule());
const setMediaVisible = useCallback(
(visible: boolean) => {
SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
...eventVisibility,
[eventId]: visible,
[eventId!]: visible,
});
},
[eventId, eventVisibility],
);
const roomIsPrivate = joinRule ? PRIVATE_JOIN_RULES.includes(joinRule) : false;
const explicitEventVisiblity = eventId ? eventVisibility[eventId] : undefined;
// Always prefer the explicit per-event user preference here.
const imgIsVisible = eventVisibility[eventId] ?? defaultShowImages;
return [imgIsVisible, setMediaVisible];
if (explicitEventVisiblity !== undefined) {
return [explicitEventVisiblity, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Off) {
return [false, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.On) {
return [true, setMediaVisible];
} else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Private) {
return [roomIsPrivate, setMediaVisible];
} else {
// Invalid setting.
console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews);
return [false, setMediaVisible];
}
}

View File

@@ -525,6 +525,7 @@
"message_timestamp_invalid": "Invalid timestamp",
"microphone": "Microphone",
"model": "Model",
"moderation_and_safety": "Moderation and safety",
"modern": "Modern",
"mute": "Mute",
"n_members": {
@@ -2668,12 +2669,10 @@
"unable_to_load_msisdns": "Unable to load phone numbers",
"username": "Username"
},
"image_thumbnails": "Show previews/thumbnails for images",
"inline_url_previews_default": "Enable inline URL previews by default",
"inline_url_previews_room": "Enable URL previews by default for participants in this room",
"inline_url_previews_room_account": "Enable URL previews for this room (only affects you)",
"insert_trailing_colon_mentions": "Insert a trailing colon after user mentions at the start of a message",
"invite_avatars": "Show avatars of rooms you have been invited to",
"jump_to_bottom_on_send": "Jump to the bottom of the timeline when you send a message",
"key_backup": {
"backup_in_progress": "Your keys are being backed up (the first backup could take a few minutes).",
@@ -2732,6 +2731,14 @@
"labs_mjolnir": {
"dialog_title": "<strong>Settings:</strong> Ignored Users"
},
"media_preview": {
"hide_avatars": "Hide avatars of room and inviter",
"hide_media": "Always hide",
"media_preview_description": "A hidden media can always be shown by tapping on it",
"media_preview_label": "Show media in timeline",
"show_in_private": "In private rooms",
"show_media": "Always show"
},
"notifications": {
"default_setting_description": "This setting will be applied by default to all your rooms.",
"default_setting_section": "I want to be notified for (Default Setting)",

View File

@@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type ReactNode } from "react";
import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix";
import { type MediaPreviewConfig } from "../@types/media_preview.ts";
import { _t, _td, type TranslationKey } from "../languageHandler";
import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts";
import {
@@ -45,6 +46,7 @@ import { type Json, type JsonValue } from "../@types/json.ts";
import { type RecentEmojiData } from "../emojipicker/recent.ts";
import { type Assignable } from "../@types/common.ts";
import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts";
import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts";
export const defaultWatchManager = new WatchManager();
@@ -312,8 +314,6 @@ export interface Settings {
"showHiddenEventsInTimeline": IBaseSetting<boolean>;
"lowBandwidth": IBaseSetting<boolean>;
"fallbackICEServerAllowed": IBaseSetting<boolean | null>;
"showImages": IBaseSetting<boolean>;
"showAvatarsOnInvites": IBaseSetting<boolean>;
"RoomList.preferredSorting": IBaseSetting<SortingAlgorithm>;
"RoomList.showMessagePreview": IBaseSetting<boolean>;
"RightPanel.phasesGlobal": IBaseSetting<IRightPanelForRoomStored | null>;
@@ -349,6 +349,7 @@ export interface Settings {
"Electron.alwaysShowMenuBar": IBaseSetting<boolean>;
"Electron.showTrayIcon": IBaseSetting<boolean>;
"Electron.enableHardwareAcceleration": IBaseSetting<boolean>;
"mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>;
"Developer.elementCallUrl": IBaseSetting<string>;
}
@@ -427,6 +428,11 @@ export const SETTINGS: Settings = {
supportedLevelsAreOrdered: true,
default: false,
},
"mediaPreviewConfig": {
controller: new MediaPreviewConfigController(),
supportedLevels: LEVELS_ROOM_SETTINGS,
default: MediaPreviewConfigController.default,
},
"feature_report_to_moderators": {
isFeature: true,
labsGroup: LabGroup.Moderation,
@@ -1123,16 +1129,6 @@ export const SETTINGS: Settings = {
default: null,
controller: new FallbackIceServerController(),
},
"showImages": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|image_thumbnails"),
default: true,
},
"showAvatarsOnInvites": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|invite_avatars"),
default: true,
},
"RoomList.preferredSorting": {
supportedLevels: [SettingLevel.DEVICE],
default: SortingAlgorithm.Recency,
@@ -1386,7 +1382,6 @@ export const SETTINGS: Settings = {
displayName: _td("settings|preferences|enable_hardware_acceleration"),
default: true,
},
"Developer.elementCallUrl": {
supportedLevels: [SettingLevel.DEVICE],
displayName: _td("devtools|settings|elementCallUrl"),

View File

@@ -38,6 +38,7 @@ import { Action } from "../dispatcher/actions";
import PlatformSettingsHandler from "./handlers/PlatformSettingsHandler";
import ReloadOnChangeController from "./controllers/ReloadOnChangeController";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { MediaPreviewValue } from "../@types/media_preview";
// Convert the settings to easier to manage objects for the handlers
const defaultSettings: Record<string, any> = {};
@@ -715,6 +716,29 @@ export default class SettingsStore {
localStorage.setItem(MIGRATION_DONE_FLAG, "true");
}
/**
* Migrate the setting for visible images to a setting.
*/
private static migrateMediaControlsToSetting(): void {
const MIGRATION_DONE_FLAG = "mx_migrate_media_controls";
if (localStorage.getItem(MIGRATION_DONE_FLAG)) return;
logger.info("Performing one-time settings migration of show images and invite avatars to account data");
const handler = LEVEL_HANDLERS[SettingLevel.ACCOUNT];
const showImages = handler.getValue("showImages", null);
const showAvatarsOnInvites = handler.getValue("showAvatarsOnInvites", null);
const AccountHandler = LEVEL_HANDLERS[SettingLevel.ACCOUNT];
if (showImages !== null || showAvatarsOnInvites !== null) {
AccountHandler.setValue("mediaPreviewConfig", null, {
invite_avatars: showAvatarsOnInvites === false ? MediaPreviewValue.Off : MediaPreviewValue.On,
media_previews: showImages === false ? MediaPreviewValue.Off : MediaPreviewValue.On,
});
} // else, we don't set anything and use the server value
localStorage.setItem(MIGRATION_DONE_FLAG, "true");
}
/**
* Runs or queues any setting migrations needed.
*/
@@ -732,6 +756,12 @@ export default class SettingsStore {
// will now be hidden again, so this fails safely.
SettingsStore.migrateShowImagesToSettings();
// This can be removed once enough users have run a version of Element with
// this migration.
// The consequences of missing the migration are that the previously set
// media controls for this user will be missing
SettingsStore.migrateMediaControlsToSetting();
// 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;

View File

@@ -26,7 +26,7 @@ export default abstract class MatrixClientBackedController extends SettingContro
MatrixClientBackedController._matrixClient = client;
for (const instance of MatrixClientBackedController.instances) {
instance.initMatrixClient(client, oldClient);
instance.initMatrixClient?.(client, oldClient);
}
}
@@ -40,5 +40,5 @@ export default abstract class MatrixClientBackedController extends SettingContro
return MatrixClientBackedController._matrixClient;
}
protected abstract initMatrixClient(newClient: MatrixClient, oldClient?: MatrixClient): void;
protected initMatrixClient?(newClient: MatrixClient, oldClient?: MatrixClient): void;
}

View File

@@ -0,0 +1,100 @@
/*
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 { type IContent } from "matrix-js-sdk/src/matrix";
import { type AccountDataEvents } from "matrix-js-sdk/src/types";
import {
MEDIA_PREVIEW_ACCOUNT_DATA_TYPE,
type MediaPreviewConfig,
MediaPreviewValue,
} from "../../@types/media_preview.ts";
import { type SettingLevel } from "../SettingLevel.ts";
import MatrixClientBackedController from "./MatrixClientBackedController.ts";
/**
* Handles media preview settings provided by MSC4278.
* This uses both account-level and room-level account data.
*/
export default class MediaPreviewConfigController extends MatrixClientBackedController {
public static readonly default: AccountDataEvents[typeof MEDIA_PREVIEW_ACCOUNT_DATA_TYPE] = {
media_previews: MediaPreviewValue.On,
invite_avatars: MediaPreviewValue.On,
};
private static getValidSettingData(content: IContent): Partial<MediaPreviewConfig> {
const mediaPreviews: MediaPreviewConfig["media_previews"] = content.media_previews;
const inviteAvatars: MediaPreviewConfig["invite_avatars"] = content.invite_avatars;
const validMediaPreviews = Object.values(MediaPreviewValue);
const validInviteAvatars = [MediaPreviewValue.Off, MediaPreviewValue.On];
return {
invite_avatars: validInviteAvatars.includes(inviteAvatars) ? inviteAvatars : undefined,
media_previews: validMediaPreviews.includes(mediaPreviews) ? mediaPreviews : undefined,
};
}
public constructor() {
super();
}
private getValue = (roomId?: string): MediaPreviewConfig => {
const source = roomId ? this.client?.getRoom(roomId) : this.client;
const accountData =
source?.getAccountData(MEDIA_PREVIEW_ACCOUNT_DATA_TYPE)?.getContent<MediaPreviewConfig>() ?? {};
const calculatedConfig = MediaPreviewConfigController.getValidSettingData(accountData);
// Save an account data fetch if we have all the values.
if (calculatedConfig.invite_avatars && calculatedConfig.media_previews) {
return calculatedConfig as MediaPreviewConfig;
}
// We're missing some keys.
if (roomId) {
const globalConfig = this.getValue();
return {
invite_avatars:
calculatedConfig.invite_avatars ??
globalConfig.invite_avatars ??
MediaPreviewConfigController.default.invite_avatars,
media_previews:
calculatedConfig.media_previews ??
globalConfig.media_previews ??
MediaPreviewConfigController.default.media_previews,
};
}
return {
invite_avatars: calculatedConfig.invite_avatars ?? MediaPreviewConfigController.default.invite_avatars,
media_previews: calculatedConfig.media_previews ?? MediaPreviewConfigController.default.media_previews,
};
};
public getValueOverride(_level: SettingLevel, roomId: string | null): MediaPreviewConfig {
return this.getValue(roomId ?? undefined);
}
public get settingDisabled(): false {
// No homeserver support is required for this MSC.
return false;
}
public async beforeChange(
_level: SettingLevel,
roomId: string | null,
newValue: MediaPreviewConfig,
): Promise<boolean> {
if (!this.client) {
return false;
}
if (roomId) {
await this.client.setRoomAccountData(roomId, MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, newValue);
return true;
}
await this.client.setAccountData(MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, newValue);
return true;
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Travis Ralston
@@ -15,6 +15,7 @@ import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandl
import { objectClone, objectKeyChanges } from "../../utils/objects";
import { SettingLevel } from "../SettingLevel";
import { type WatchManager } from "../WatchManager";
import { MEDIA_PREVIEW_ACCOUNT_DATA_TYPE } from "../../@types/media_preview";
const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms";
const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs";
@@ -68,6 +69,8 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
} else if (event.getType() === RECENT_EMOJI_EVENT_TYPE) {
const val = event.getContent()["enabled"];
this.watchers.notifyUpdate("recent_emoji", null, SettingLevel.ACCOUNT, val);
} else if (event.getType() === MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) {
this.watchers.notifyUpdate("mediaPreviewConfig", null, SettingLevel.ROOM_ACCOUNT, event.getContent());
}
};
@@ -173,7 +176,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
await deferred.promise;
}
public setValue(settingName: string, roomId: string, newValue: any): Promise<void> {
public async setValue(settingName: string, roomId: string, newValue: any): Promise<void> {
switch (settingName) {
// Special case URL previews
case "urlPreviewsEnabled":
@@ -199,7 +202,9 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
// Special case analytics
case "pseudonymousAnalyticsOptIn":
return this.setAccountData(ANALYTICS_EVENT_TYPE, "pseudonymousAnalyticsOptIn", newValue);
case "mediaPreviewConfig":
// Handled in MediaPreviewConfigController.
return;
default:
return this.setAccountData(DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Travis Ralston
@@ -14,6 +14,7 @@ import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandl
import { objectClone, objectKeyChanges } from "../../utils/objects";
import { SettingLevel } from "../SettingLevel";
import { type WatchManager } from "../WatchManager";
import { MEDIA_PREVIEW_ACCOUNT_DATA_TYPE } from "../../@types/media_preview";
const ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets";
const DEFAULT_SETTINGS_EVENT_TYPE = "im.vector.web.settings";
@@ -56,6 +57,8 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
}
} else if (event.getType() === ALLOWED_WIDGETS_EVENT_TYPE) {
this.watchers.notifyUpdate("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent());
} else if (event.getType() === MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) {
this.watchers.notifyUpdate("mediaPreviewConfig", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent());
}
};
@@ -108,7 +111,7 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
await deferred.promise;
}
public setValue(settingName: string, roomId: string, newValue: any): Promise<void> {
public async setValue(settingName: string, roomId: string, newValue: any): Promise<void> {
switch (settingName) {
// Special case URL previews
case "urlPreviewsEnabled":
@@ -117,7 +120,9 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
// Special case allowed widgets
case "allowedWidgets":
return this.setRoomAccountData(roomId, ALLOWED_WIDGETS_EVENT_TYPE, null, newValue);
case "mediaPreviewConfig":
// Handled in MediaPreviewConfigController.
return;
default:
return this.setRoomAccountData(roomId, DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue);
}