1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-09 14:42:51 +03:00

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

@@ -0,0 +1,122 @@
/*
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 * as fs from "node:fs";
import { type EventType, type MsgType, type RoomJoinRulesEventContent } from "matrix-js-sdk/src/types";
import { test, expect } from "../../element-web-test";
const MEDIA_FILE = fs.readFileSync("playwright/sample-files/riot.png");
test.describe("Media preview settings", () => {
test.use({
displayName: "Alan",
room: async ({ app, page, homeserver, bot, user }, use) => {
const mxc = (await bot.uploadContent(MEDIA_FILE, { name: "image.png", type: "image/png" })).content_uri;
const roomId = await bot.createRoom({
name: "Test room",
invite: [user.userId],
initial_state: [{ type: "m.room.avatar", content: { url: mxc }, state_key: "" }],
});
await bot.sendEvent(roomId, null, "m.room.message" as EventType, {
msgtype: "m.image" as MsgType,
body: "image.png",
url: mxc,
});
await use({ roomId });
},
});
test("should be able to hide avatars of inviters", { tag: "@screenshot" }, async ({ page, app, room, user }) => {
let settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Hide avatars of room and inviter").click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await expect(
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
).toMatchScreenshot("invite-no-avatar.png");
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
).toMatchScreenshot("invite-room-tree-no-avatar.png");
// And then go back to being visible
settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Hide avatars of room and inviter").click();
await app.closeDialog();
await page.goto("#/home");
await app.viewRoomById(room.roomId);
await expect(
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
).toMatchScreenshot("invite-with-avatar.png");
await expect(
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
).toMatchScreenshot("invite-room-tree-with-avatar.png");
});
test("should be able to hide media in rooms globally", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).toBeVisible();
});
test("should be able to hide media in non-private rooms globally", async ({ page, app, room, user, bot }) => {
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
join_rule: "public",
});
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByLabel("In private rooms").click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).toBeVisible();
for (const joinRule of ["invite", "knock", "restricted"] as RoomJoinRulesEventContent["join_rule"][]) {
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
join_rule: joinRule,
} satisfies RoomJoinRulesEventContent);
await expect(page.getByText("Show image")).not.toBeVisible();
}
});
test("should be able to show media in rooms globally", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Show image")).not.toBeVisible();
});
test("should be able to hide media in an individual room", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
const roomSettings = await app.settings.openRoomSettings("General");
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await expect(page.getByText("Show image")).toBeVisible();
});
test("should be able to show media in an individual room", async ({ page, app, room, user }) => {
const settings = await app.settings.openUserSettings("Preferences");
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
await app.closeDialog();
await app.viewRoomById(room.roomId);
await page.getByRole("button", { name: "Accept" }).click();
const roomSettings = await app.settings.openRoomSettings("General");
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
await app.closeDialog();
await expect(page.getByText("Show image")).not.toBeVisible();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -378,6 +378,7 @@
@import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_HelpUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_KeyboardUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_KeyboardUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_MediaPreviewAccountSettings.pcss";
@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_PreferencesUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.pcss";
@import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss";

View File

@@ -0,0 +1,28 @@
/*
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.
*/
.mx_MediaPreviewAccountSetting_Radio {
margin: var(--cpd-space-1x) 0;
}
.mx_MediaPreviewAccountSetting {
margin-top: var(--cpd-space-1x);
}
.mx_MediaPreviewAccountSetting_RadioHelp {
margin-top: 0;
margin-bottom: var(--cpd-space-1x);
}
.mx_MediaPreviewAccountSetting_Form {
width: 100%;
}
.mx_MediaPreviewAccountSetting_ToggleSwitch {
font: var(--cpd-font-body-md-medium);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
}

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. 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 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 { EmptyObject } from "matrix-js-sdk/src/matrix";
import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { DeviceClientInformation } from "../utils/device/types.ts";
import type { UserWidget } from "../utils/WidgetUtils-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 // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
declare module "matrix-js-sdk/src/types" { declare module "matrix-js-sdk/src/types" {
@@ -87,6 +88,8 @@ declare module "matrix-js-sdk/src/types" {
"m.accepted_terms": { "m.accepted_terms": {
accepted: string[]; accepted: string[];
}; };
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
} }
export interface AudioContent { 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 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
@@ -294,6 +294,10 @@ export interface EventRenderOpts {
disableBigEmoji?: boolean; disableBigEmoji?: boolean;
stripReplyFallback?: boolean; stripReplyFallback?: boolean;
forComposerQuote?: boolean; forComposerQuote?: boolean;
/**
* Should inline media be rendered?
*/
mediaIsVisible?: boolean;
} }
function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis { 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; 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 { try {
const isFormattedBody = const isFormattedBody =
content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string"; 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. 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 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 _Linkify from "linkify-react";
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
import SettingsStore from "./settings/SettingsStore";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; 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 // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // 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 if (!src) {
// images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have.
if (!src || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {} }; return { tagName, attribs: {} };
} }
@@ -78,7 +74,6 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
if (requestedHeight) { if (requestedHeight) {
attribs.style += "height: 100%;"; attribs.style += "height: 100%;";
} }
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!; attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!;
return { tagName, attribs }; return { tagName, attribs };
}, },

View File

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

View File

@@ -28,6 +28,7 @@ import {
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx"; import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
import { useSettingValue } from "../../../hooks/useSettings.ts"; import { useSettingValue } from "../../../hooks/useSettings.ts";
import { filterBoolean } from "../../../utils/arrays.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 * 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>( forwardRef<HTMLElement, Props>(
({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ...options }, ref) => { ({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ...options }, ref) => {
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji"); const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId());
const replacer = useReplacer(content, mxEvent, options); const replacer = useReplacer(content, mxEvent, options);
const linkifyOptions = useMemo( const linkifyOptions = useMemo(
@@ -167,8 +169,9 @@ const EventContentBody = memo(
disableBigEmoji: isEmote || !enableBigEmoji, disableBigEmoji: isEmote || !enableBigEmoji,
// Part of Replies fallback support // Part of Replies fallback support
stripReplyFallback: stripReply, stripReplyFallback: stripReply,
mediaIsVisible,
}), }),
[content, enableBigEmoji, highlights, isEmote, stripReply], [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply],
); );
if (as === "div") includeDir = true; // force dir="auto" on divs 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. 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 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. * Quick action button for marking a media event as hidden.
*/ */
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => { export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId()!); const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
if (!mediaIsVisible) { if (!mediaIsVisible) {
return; 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. // Wrap MImageBody component so we can use a hook here.
const MImageBody: React.FC<IBodyProps> = (props) => { 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} />; return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
}; };

View File

@@ -38,7 +38,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
} }
} }
const MImageReplyBody: React.FC<IBodyProps> = (props) => { 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} />; return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
}; };

View File

@@ -79,7 +79,7 @@ class MStickerBodyInner extends MImageBodyInner {
} }
const MStickerBody: React.FC<IBodyProps> = (props) => { 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} />; 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. // Wrap MVideoBody component so we can use a hook here.
const MVideoBody: React.FC<IBodyProps> = (props) => { 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} />; 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. 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 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 { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useMediaVisible } from "../../../hooks/useMediaVisible";
const INITIAL_NUM_PREVIEWS = 2; const INITIAL_NUM_PREVIEWS = 2;
@@ -29,6 +30,7 @@ interface IProps {
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) => { const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const [expanded, toggleExpanded] = useStateToggle(); const [expanded, toggleExpanded] = useStateToggle();
const [mediaVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
const ts = mxEvent.getTs(); const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>( const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(
@@ -55,7 +57,13 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) =
return ( return (
<div className="mx_LinkPreviewGroup"> <div className="mx_LinkPreviewGroup">
{showPreviews.map(([link, preview], i) => ( {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 ? ( {i === 0 ? (
<AccessibleButton <AccessibleButton
className="mx_LinkPreviewGroup_hide" 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. 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 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 { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
import { Linkify } from "../../../HtmlUtils"; import { Linkify } from "../../../HtmlUtils";
import SettingsStore from "../../../settings/SettingsStore";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils"; import * as ImageUtils from "../../../ImageUtils";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
@@ -24,6 +23,7 @@ interface IProps {
preview: IPreviewUrlResponse; preview: IPreviewUrlResponse;
mxEvent: MatrixEvent; // the Event associated with the preview mxEvent: MatrixEvent; // the Event associated with the preview
children?: ReactNode; children?: ReactNode;
mediaVisible: boolean;
} }
export default class LinkPreviewWidget extends React.Component<IProps> { 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? // 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; 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 image = null; // Don't render a button to show the image, just hide it outright
} }
const imageMaxWidth = 100; 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 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. 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 SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSection } from "../../shared/SettingsSection";
import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings"; import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings";
import { MediaPreviewAccountSettings } from "../user/MediaPreviewAccountSettings";
interface IProps { interface IProps {
room: Room; room: Room;
@@ -92,6 +93,9 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
<SettingsSection heading={_t("room_settings|general|other_section")}> <SettingsSection heading={_t("room_settings|general|other_section")}>
{urlPreviewSettings} {urlPreviewSettings}
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings roomId={room.roomId} />
</SettingsSubsection>
{leaveSection} {leaveSection}
</SettingsSection> </SettingsSection>
</SettingsTab> </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 LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import * as TimezoneHandler from "../../../../../TimezoneHandler"; import * as TimezoneHandler from "../../../../../TimezoneHandler";
import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx"; import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx";
import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx";
interface IProps { interface IProps {
closeSettingsFn(success: boolean): void; closeSettingsFn(success: boolean): void;
@@ -116,7 +117,7 @@ const SpellCheckSection: React.FC = () => {
}; };
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> { 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"]; private static SPACES_SETTINGS: BooleanSettingKey[] = ["Spaces.allRoomsInHome"];
@@ -146,7 +147,6 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
"urlPreviewsEnabled", "urlPreviewsEnabled",
"autoplayGifs", "autoplayGifs",
"autoplayVideo", "autoplayVideo",
"showImages",
]; ];
private static TIMELINE_SETTINGS: BooleanSettingKey[] = [ private static TIMELINE_SETTINGS: BooleanSettingKey[] = [
@@ -335,6 +335,10 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
{this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings />
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|room_directory_heading")}> <SettingsSubsection heading={_t("settings|preferences|room_directory_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.ROOM_DIRECTORY_SETTINGS)} {this.renderGroup(PreferencesUserSettingsTab.ROOM_DIRECTORY_SETTINGS)}
</SettingsSubsection> </SettingsSubsection>

View File

@@ -6,30 +6,52 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { useCallback } from "react"; import { useCallback } from "react";
import { JoinRule } from "matrix-js-sdk/src/matrix";
import { SettingLevel } from "../settings/SettingLevel"; import { SettingLevel } from "../settings/SettingLevel";
import { useSettingValue } from "./useSettings"; import { useSettingValue } from "./useSettings";
import SettingsStore from "../settings/SettingsStore"; 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. * Should the media event be visible in the client, or hidden.
* @param eventId The eventId of the media event. * @param eventId The eventId of the media event.
* @returns A boolean describing the hidden status, and a function to set the visiblity. * @returns A boolean describing the hidden status, and a function to set the visiblity.
*/ */
export function useMediaVisible(eventId: string): [boolean, (visible: boolean) => void] { export function useMediaVisible(eventId?: string, roomId?: string): [boolean, (visible: boolean) => void] {
const defaultShowImages = useSettingValue("showImages", SettingLevel.DEVICE); const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId);
const eventVisibility = useSettingValue("showMediaEventIds", SettingLevel.DEVICE); const client = useMatrixClientContext();
const eventVisibility = useSettingValue("showMediaEventIds");
const joinRule = useRoomState(client.getRoom(roomId) ?? undefined, (state) => state.getJoinRule());
const setMediaVisible = useCallback( const setMediaVisible = useCallback(
(visible: boolean) => { (visible: boolean) => {
SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, { SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, {
...eventVisibility, ...eventVisibility,
[eventId]: visible, [eventId!]: visible,
}); });
}, },
[eventId, eventVisibility], [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. // Always prefer the explicit per-event user preference here.
const imgIsVisible = eventVisibility[eventId] ?? defaultShowImages; if (explicitEventVisiblity !== undefined) {
return [imgIsVisible, setMediaVisible]; 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", "message_timestamp_invalid": "Invalid timestamp",
"microphone": "Microphone", "microphone": "Microphone",
"model": "Model", "model": "Model",
"moderation_and_safety": "Moderation and safety",
"modern": "Modern", "modern": "Modern",
"mute": "Mute", "mute": "Mute",
"n_members": { "n_members": {
@@ -2668,12 +2669,10 @@
"unable_to_load_msisdns": "Unable to load phone numbers", "unable_to_load_msisdns": "Unable to load phone numbers",
"username": "Username" "username": "Username"
}, },
"image_thumbnails": "Show previews/thumbnails for images",
"inline_url_previews_default": "Enable inline URL previews by default", "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": "Enable URL previews by default for participants in this room",
"inline_url_previews_room_account": "Enable URL previews for this room (only affects you)", "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", "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", "jump_to_bottom_on_send": "Jump to the bottom of the timeline when you send a message",
"key_backup": { "key_backup": {
"backup_in_progress": "Your keys are being backed up (the first backup could take a few minutes).", "backup_in_progress": "Your keys are being backed up (the first backup could take a few minutes).",
@@ -2732,6 +2731,14 @@
"labs_mjolnir": { "labs_mjolnir": {
"dialog_title": "<strong>Settings:</strong> Ignored Users" "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": { "notifications": {
"default_setting_description": "This setting will be applied by default to all your rooms.", "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)", "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 React, { type ReactNode } from "react";
import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; 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 { _t, _td, type TranslationKey } from "../languageHandler";
import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts"; import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts";
import { import {
@@ -45,6 +46,7 @@ import { type Json, type JsonValue } from "../@types/json.ts";
import { type RecentEmojiData } from "../emojipicker/recent.ts"; import { type RecentEmojiData } from "../emojipicker/recent.ts";
import { type Assignable } from "../@types/common.ts"; import { type Assignable } from "../@types/common.ts";
import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts"; import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts";
import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts";
export const defaultWatchManager = new WatchManager(); export const defaultWatchManager = new WatchManager();
@@ -312,8 +314,6 @@ export interface Settings {
"showHiddenEventsInTimeline": IBaseSetting<boolean>; "showHiddenEventsInTimeline": IBaseSetting<boolean>;
"lowBandwidth": IBaseSetting<boolean>; "lowBandwidth": IBaseSetting<boolean>;
"fallbackICEServerAllowed": IBaseSetting<boolean | null>; "fallbackICEServerAllowed": IBaseSetting<boolean | null>;
"showImages": IBaseSetting<boolean>;
"showAvatarsOnInvites": IBaseSetting<boolean>;
"RoomList.preferredSorting": IBaseSetting<SortingAlgorithm>; "RoomList.preferredSorting": IBaseSetting<SortingAlgorithm>;
"RoomList.showMessagePreview": IBaseSetting<boolean>; "RoomList.showMessagePreview": IBaseSetting<boolean>;
"RightPanel.phasesGlobal": IBaseSetting<IRightPanelForRoomStored | null>; "RightPanel.phasesGlobal": IBaseSetting<IRightPanelForRoomStored | null>;
@@ -349,6 +349,7 @@ export interface Settings {
"Electron.alwaysShowMenuBar": IBaseSetting<boolean>; "Electron.alwaysShowMenuBar": IBaseSetting<boolean>;
"Electron.showTrayIcon": IBaseSetting<boolean>; "Electron.showTrayIcon": IBaseSetting<boolean>;
"Electron.enableHardwareAcceleration": IBaseSetting<boolean>; "Electron.enableHardwareAcceleration": IBaseSetting<boolean>;
"mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>;
"Developer.elementCallUrl": IBaseSetting<string>; "Developer.elementCallUrl": IBaseSetting<string>;
} }
@@ -427,6 +428,11 @@ export const SETTINGS: Settings = {
supportedLevelsAreOrdered: true, supportedLevelsAreOrdered: true,
default: false, default: false,
}, },
"mediaPreviewConfig": {
controller: new MediaPreviewConfigController(),
supportedLevels: LEVELS_ROOM_SETTINGS,
default: MediaPreviewConfigController.default,
},
"feature_report_to_moderators": { "feature_report_to_moderators": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Moderation, labsGroup: LabGroup.Moderation,
@@ -1123,16 +1129,6 @@ export const SETTINGS: Settings = {
default: null, default: null,
controller: new FallbackIceServerController(), 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": { "RoomList.preferredSorting": {
supportedLevels: [SettingLevel.DEVICE], supportedLevels: [SettingLevel.DEVICE],
default: SortingAlgorithm.Recency, default: SortingAlgorithm.Recency,
@@ -1386,7 +1382,6 @@ export const SETTINGS: Settings = {
displayName: _td("settings|preferences|enable_hardware_acceleration"), displayName: _td("settings|preferences|enable_hardware_acceleration"),
default: true, default: true,
}, },
"Developer.elementCallUrl": { "Developer.elementCallUrl": {
supportedLevels: [SettingLevel.DEVICE], supportedLevels: [SettingLevel.DEVICE],
displayName: _td("devtools|settings|elementCallUrl"), displayName: _td("devtools|settings|elementCallUrl"),

View File

@@ -38,6 +38,7 @@ import { Action } from "../dispatcher/actions";
import PlatformSettingsHandler from "./handlers/PlatformSettingsHandler"; import PlatformSettingsHandler from "./handlers/PlatformSettingsHandler";
import ReloadOnChangeController from "./controllers/ReloadOnChangeController"; import ReloadOnChangeController from "./controllers/ReloadOnChangeController";
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
import { MediaPreviewValue } from "../@types/media_preview";
// Convert the settings to easier to manage objects for the handlers // Convert the settings to easier to manage objects for the handlers
const defaultSettings: Record<string, any> = {}; const defaultSettings: Record<string, any> = {};
@@ -715,6 +716,29 @@ export default class SettingsStore {
localStorage.setItem(MIGRATION_DONE_FLAG, "true"); 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. * Runs or queues any setting migrations needed.
*/ */
@@ -732,6 +756,12 @@ export default class SettingsStore {
// will now be hidden again, so this fails safely. // will now be hidden again, so this fails safely.
SettingsStore.migrateShowImagesToSettings(); 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 // 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. // add a comment to note when it can be removed.
return; return;

View File

@@ -26,7 +26,7 @@ export default abstract class MatrixClientBackedController extends SettingContro
MatrixClientBackedController._matrixClient = client; MatrixClientBackedController._matrixClient = client;
for (const instance of MatrixClientBackedController.instances) { 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; 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 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Travis Ralston Copyright 2017 Travis Ralston
@@ -15,6 +15,7 @@ import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandl
import { objectClone, objectKeyChanges } from "../../utils/objects"; import { objectClone, objectKeyChanges } from "../../utils/objects";
import { SettingLevel } from "../SettingLevel"; import { SettingLevel } from "../SettingLevel";
import { type WatchManager } from "../WatchManager"; 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_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms";
const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs"; 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) { } else if (event.getType() === RECENT_EMOJI_EVENT_TYPE) {
const val = event.getContent()["enabled"]; const val = event.getContent()["enabled"];
this.watchers.notifyUpdate("recent_emoji", null, SettingLevel.ACCOUNT, val); 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; 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) { switch (settingName) {
// Special case URL previews // Special case URL previews
case "urlPreviewsEnabled": case "urlPreviewsEnabled":
@@ -199,7 +202,9 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
// Special case analytics // Special case analytics
case "pseudonymousAnalyticsOptIn": case "pseudonymousAnalyticsOptIn":
return this.setAccountData(ANALYTICS_EVENT_TYPE, "pseudonymousAnalyticsOptIn", newValue); return this.setAccountData(ANALYTICS_EVENT_TYPE, "pseudonymousAnalyticsOptIn", newValue);
case "mediaPreviewConfig":
// Handled in MediaPreviewConfigController.
return;
default: default:
return this.setAccountData(DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); 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 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Travis Ralston Copyright 2017 Travis Ralston
@@ -14,6 +14,7 @@ import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandl
import { objectClone, objectKeyChanges } from "../../utils/objects"; import { objectClone, objectKeyChanges } from "../../utils/objects";
import { SettingLevel } from "../SettingLevel"; import { SettingLevel } from "../SettingLevel";
import { type WatchManager } from "../WatchManager"; 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 ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets";
const DEFAULT_SETTINGS_EVENT_TYPE = "im.vector.web.settings"; 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) { } else if (event.getType() === ALLOWED_WIDGETS_EVENT_TYPE) {
this.watchers.notifyUpdate("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); 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; 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) { switch (settingName) {
// Special case URL previews // Special case URL previews
case "urlPreviewsEnabled": case "urlPreviewsEnabled":
@@ -117,7 +120,9 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
// Special case allowed widgets // Special case allowed widgets
case "allowedWidgets": case "allowedWidgets":
return this.setRoomAccountData(roomId, ALLOWED_WIDGETS_EVENT_TYPE, null, newValue); return this.setRoomAccountData(roomId, ALLOWED_WIDGETS_EVENT_TYPE, null, newValue);
case "mediaPreviewConfig":
// Handled in MediaPreviewConfigController.
return;
default: default:
return this.setRoomAccountData(roomId, DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); return this.setRoomAccountData(roomId, DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue);
} }

View File

@@ -12,6 +12,7 @@ import parse from "html-react-parser";
import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils"; import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils";
import SettingsStore from "../../src/settings/SettingsStore"; import SettingsStore from "../../src/settings/SettingsStore";
import { getMockClientWithEventEmitter } from "../test-utils";
import { SettingLevel } from "../../src/settings/SettingLevel"; import { SettingLevel } from "../../src/settings/SettingLevel";
import SdkConfig from "../../src/SdkConfig"; import SdkConfig from "../../src/SdkConfig";
@@ -231,6 +232,37 @@ describe("bodyToNode", () => {
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
it.each([[true], [false]])("should handle inline media when mediaIsVisible is %s", (mediaIsVisible) => {
const cli = getMockClientWithEventEmitter({
mxcUrlToHttp: jest.fn().mockReturnValue("https://example.org/img"),
});
const { className, formattedBody } = bodyToNode(
{
"body": "![foo](mxc://going/knowwhere) Hello there",
"format": "org.matrix.custom.html",
"formatted_body": `<img src="mxc://going/knowwhere">foo</img> Hello there`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$eventId",
},
},
"msgtype": "m.text",
},
[],
{
mediaIsVisible,
},
);
const { asFragment } = render(
<span className={className} dir="auto" dangerouslySetInnerHTML={{ __html: formattedBody! }} />,
);
expect(asFragment()).toMatchSnapshot();
// We do not want to download untrusted media.
// eslint-disable-next-line no-restricted-properties
expect(cli.mxcUrlToHttp).toHaveBeenCalledTimes(mediaIsVisible ? 1 : 0);
});
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });

View File

@@ -64,3 +64,30 @@ exports[`bodyToNode should generate big emoji for an emoji-only reply to a messa
</span> </span>
</DocumentFragment> </DocumentFragment>
`; `;
exports[`bodyToNode should handle inline media when mediaIsVisible is false 1`] = `
<DocumentFragment>
<span
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
<img />
foo Hello there
</span>
</DocumentFragment>
`;
exports[`bodyToNode should handle inline media when mediaIsVisible is true 1`] = `
<DocumentFragment>
<span
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
<img
src="https://example.org/img"
style="max-width:800px;max-height:600px"
/>
foo Hello there
</span>
</DocumentFragment>
`;

View File

@@ -17,6 +17,7 @@ import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { LocalRoom } from "../../../../../src/models/LocalRoom"; import { LocalRoom } from "../../../../../src/models/LocalRoom";
import * as AvatarModule from "../../../../../src/Avatar"; import * as AvatarModule from "../../../../../src/Avatar";
import { DirectoryMember } from "../../../../../src/utils/direct-messages"; import { DirectoryMember } from "../../../../../src/utils/direct-messages";
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel"; import { SettingLevel } from "../../../../../src/settings/SettingLevel";
@@ -37,18 +38,18 @@ describe("RoomAvatar", () => {
}); });
afterAll(() => { afterAll(() => {
SettingsStore.setValue(
"mediaPreviewConfig",
null,
SettingLevel.ACCOUNT,
SettingsStore.getDefaultValue("mediaPreviewConfig"),
);
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
afterEach(() => { afterEach(() => {
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReset(); mocked(DMRoomMap.shared().getUserIdForRoomId).mockReset();
mocked(AvatarModule.defaultAvatarUrlForString).mockClear(); mocked(AvatarModule.defaultAvatarUrlForString).mockClear();
SettingsStore.setValue(
"showAvatarsOnInvites",
null,
SettingLevel.ACCOUNT,
SettingsStore.getDefaultValue("showAvatarsOnInvites"),
);
}); });
it("should render as expected for a Room", () => { it("should render as expected for a Room", () => {
@@ -74,7 +75,6 @@ describe("RoomAvatar", () => {
expect(render(<RoomAvatar room={localRoom} />).container).toMatchSnapshot(); expect(render(<RoomAvatar room={localRoom} />).container).toMatchSnapshot();
}); });
it("should render an avatar for a room the user is invited to", () => { it("should render an avatar for a room the user is invited to", () => {
SettingsStore.setValue("showAvatarsOnInvites", null, SettingLevel.ACCOUNT, true);
const room = new Room("!room:example.com", client, client.getSafeUserId()); const room = new Room("!room:example.com", client, client.getSafeUserId());
jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar"); jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar");
room.name = "test room"; room.name = "test room";
@@ -93,7 +93,9 @@ describe("RoomAvatar", () => {
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot(); expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
}); });
it("should not render an invite avatar if the user has disabled it", () => { it("should not render an invite avatar if the user has disabled it", () => {
SettingsStore.setValue("showAvatarsOnInvites", null, SettingLevel.ACCOUNT, false); SettingsStore.setValue("mediaPreviewConfig", null, SettingLevel.ACCOUNT, {
invite_avatars: MediaPreviewValue.Off,
});
const room = new Room("!room:example.com", client, client.getSafeUserId()); const room = new Room("!room:example.com", client, client.getSafeUserId());
room.name = "test room"; room.name = "test room";
room.updateMyMembership("invite"); room.updateMyMembership("invite");

View File

@@ -8,20 +8,20 @@ Please see LICENSE files in the repository root for full details.
import React from "react"; import React from "react";
import { fireEvent, render, screen } from "jest-matrix-react"; import { fireEvent, render, screen } from "jest-matrix-react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { HideActionButton } from "../../../../../src/components/views/messages/HideActionButton"; import { HideActionButton } from "../../../../../src/components/views/messages/HideActionButton";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel"; import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import type { Settings } from "../../../../../src/settings/Settings"; import type { Settings } from "../../../../../src/settings/Settings";
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
import { getMockClientWithEventEmitter, withClientContextRenderOptions } from "../../../../test-utils";
import type { MockedObject } from "jest-mock";
function mockSetting( function mockSetting(mediaPreviews: MediaPreviewValue, showMediaEventIds: Settings["showMediaEventIds"]["default"]) {
showImages: Settings["showImages"]["default"],
showMediaEventIds: Settings["showMediaEventIds"]["default"],
) {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => { jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "showImages") { if (settingName === "mediaPreviewConfig") {
return showImages; return { media_previews: mediaPreviews, invite_avatars: MediaPreviewValue.Off };
} else if (settingName === "showMediaEventIds") { } else if (settingName === "showMediaEventIds") {
return showMediaEventIds; return showMediaEventIds;
} }
@@ -29,8 +29,10 @@ function mockSetting(
}); });
} }
const EVENT_ID = "$foo:bar";
const event = new MatrixEvent({ const event = new MatrixEvent({
event_id: "$foo:bar", event_id: EVENT_ID,
room_id: "!room:id", room_id: "!room:id",
sender: "@user:id", sender: "@user:id",
type: "m.room.message", type: "m.room.message",
@@ -42,32 +44,38 @@ const event = new MatrixEvent({
}); });
describe("HideActionButton", () => { describe("HideActionButton", () => {
let cli: MockedObject<MatrixClient>;
beforeEach(() => {
cli = getMockClientWithEventEmitter({
getRoom: jest.fn(),
});
});
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
it("should show button when event is visible by showMediaEventIds setting", async () => { it("should show button when event is visible by showMediaEventIds setting", async () => {
mockSetting(false, { "$foo:bar": true }); mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: true });
render(<HideActionButton mxEvent={event} />); render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.getByRole("button")).toBeVisible(); expect(screen.getByRole("button")).toBeVisible();
}); });
it("should show button when event is visible by showImages setting", async () => { it("should show button when event is visible by mediaPreviewConfig setting", async () => {
mockSetting(true, {}); mockSetting(MediaPreviewValue.On, {});
render(<HideActionButton mxEvent={event} />); render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.getByRole("button")).toBeVisible(); expect(screen.getByRole("button")).toBeVisible();
}); });
it("should hide button when event is hidden by showMediaEventIds setting", async () => { it("should hide button when event is hidden by showMediaEventIds setting", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({ "$foo:bar": false }); mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: false });
render(<HideActionButton mxEvent={event} />); render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.queryByRole("button")).toBeNull(); expect(screen.queryByRole("button")).toBeNull();
}); });
it("should hide button when event is hidden by showImages setting", async () => { it("should hide button when event is hidden by showImages setting", async () => {
mockSetting(false, {}); mockSetting(MediaPreviewValue.Off, {});
render(<HideActionButton mxEvent={event} />); render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
expect(screen.queryByRole("button")).toBeNull(); expect(screen.queryByRole("button")).toBeNull();
}); });
it("should store event as hidden when clicked", async () => { it("should store event as hidden when clicked", async () => {
const spy = jest.spyOn(SettingsStore, "setValue"); const spy = jest.spyOn(SettingsStore, "setValue");
render(<HideActionButton mxEvent={event} />); render(<HideActionButton mxEvent={event} />, withClientContextRenderOptions(cli));
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole("button"));
expect(spy).toHaveBeenCalledWith("showMediaEventIds", null, SettingLevel.DEVICE, { "$foo:bar": false }); expect(spy).toHaveBeenCalledWith("showMediaEventIds", null, SettingLevel.DEVICE, { "$foo:bar": false });
// Button should be hidden after the setting is set. // Button should be hidden after the setting is set.

View File

@@ -1,12 +1,12 @@
/* /*
Copyright 2024 New Vector Ltd. Copyright 2024, 2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial 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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { act } from "react"; import React from "react";
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react"; import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest"; import fetchMock from "fetch-mock-jest";
@@ -24,10 +24,11 @@ import {
mockClientMethodsDevice, mockClientMethodsDevice,
mockClientMethodsServer, mockClientMethodsServer,
mockClientMethodsUser, mockClientMethodsUser,
withClientContextRenderOptions,
} from "../../../../test-utils"; } from "../../../../test-utils";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel"; import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
jest.mock("matrix-encrypt-attachment", () => ({ jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn(), decryptAttachment: jest.fn(),
@@ -42,6 +43,7 @@ describe("<MImageBody/>", () => {
...mockClientMethodsDevice(deviceId), ...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(), ...mockClientMethodsCrypto(),
getRooms: jest.fn().mockReturnValue([]), getRooms: jest.fn().mockReturnValue([]),
getRoom: jest.fn(),
getIgnoredUsers: jest.fn(), getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ getVersions: jest.fn().mockResolvedValue({
unstable_features: { unstable_features: {
@@ -85,6 +87,7 @@ describe("<MImageBody/>", () => {
}); });
afterEach(() => { afterEach(() => {
SettingsStore.reset();
mocked(encrypt.decryptAttachment).mockReset(); mocked(encrypt.decryptAttachment).mockReset();
}); });
@@ -97,6 +100,7 @@ describe("<MImageBody/>", () => {
mxEvent={encryptedMediaEvent} mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>, />,
withClientContextRenderOptions(cli),
); );
// thumbnail with dimensions present // thumbnail with dimensions present
@@ -112,6 +116,7 @@ describe("<MImageBody/>", () => {
mxEvent={encryptedMediaEvent} mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>, />,
withClientContextRenderOptions(cli),
); );
expect(fetchMock).toHaveBeenCalledWith(url); expect(fetchMock).toHaveBeenCalledWith(url);
@@ -129,6 +134,7 @@ describe("<MImageBody/>", () => {
mxEvent={encryptedMediaEvent} mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>, />,
withClientContextRenderOptions(cli),
); );
await screen.findByText("Error decrypting image"); await screen.findByText("Error decrypting image");
@@ -136,25 +142,12 @@ describe("<MImageBody/>", () => {
describe("with image previews/thumbnails disabled", () => { describe("with image previews/thumbnails disabled", () => {
beforeEach(() => { beforeEach(() => {
act(() => { const origFn = SettingsStore.getValue;
SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false); jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
}); if (setting === "mediaPreviewConfig") {
}); return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off };
}
afterEach(() => { return origFn(setting, ...args);
act(() => {
SettingsStore.setValue(
"showImages",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showImages"),
);
SettingsStore.setValue(
"showMediaEventIds",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showMediaEventIds"),
);
}); });
}); });
@@ -167,6 +160,7 @@ describe("<MImageBody/>", () => {
mxEvent={encryptedMediaEvent} mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>, />,
withClientContextRenderOptions(cli),
); );
expect(screen.getByText("Show image")).toBeInTheDocument(); expect(screen.getByText("Show image")).toBeInTheDocument();
@@ -183,6 +177,7 @@ describe("<MImageBody/>", () => {
mxEvent={encryptedMediaEvent} mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>, />,
withClientContextRenderOptions(cli),
); );
expect(screen.getByText("Show image")).toBeInTheDocument(); expect(screen.getByText("Show image")).toBeInTheDocument();
@@ -220,6 +215,7 @@ describe("<MImageBody/>", () => {
const { container } = render( const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />, <MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
withClientContextRenderOptions(cli),
); );
const img = container.querySelector(".mx_MImageBody_thumbnail")!; const img = container.querySelector(".mx_MImageBody_thumbnail")!;
@@ -273,6 +269,7 @@ describe("<MImageBody/>", () => {
const { container } = render( const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />, <MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
withClientContextRenderOptions(cli),
); );
// Wait for spinners to go away // Wait for spinners to go away
@@ -298,6 +295,7 @@ describe("<MImageBody/>", () => {
const { container } = render( const { container } = render(
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />, <MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
withClientContextRenderOptions(cli),
); );
const img = container.querySelector(".mx_MImageBody_thumbnail")!; const img = container.querySelector(".mx_MImageBody_thumbnail")!;

View File

@@ -19,6 +19,7 @@ import {
mockClientMethodsDevice, mockClientMethodsDevice,
mockClientMethodsServer, mockClientMethodsServer,
mockClientMethodsUser, mockClientMethodsUser,
withClientContextRenderOptions,
} from "../../../../test-utils"; } from "../../../../test-utils";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import MStickerBody from "../../../../../src/components/views/messages/MStickerBody"; import MStickerBody from "../../../../../src/components/views/messages/MStickerBody";
@@ -31,6 +32,7 @@ describe("<MStickerBody/>", () => {
...mockClientMethodsServer(), ...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId), ...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(), ...mockClientMethodsCrypto(),
getRoom: jest.fn(),
getRooms: jest.fn().mockReturnValue([]), getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(), getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ getVersions: jest.fn().mockResolvedValue({
@@ -76,7 +78,7 @@ describe("<MStickerBody/>", () => {
it("should show a tooltip on hover", async () => { it("should show a tooltip on hover", async () => {
fetchMock.getOnce(url, { status: 200 }); fetchMock.getOnce(url, { status: 200 });
render(<MStickerBody {...props} mxEvent={mediaEvent} />); render(<MStickerBody {...props} mxEvent={mediaEvent} />, withClientContextRenderOptions(cli));
expect(screen.queryByRole("tooltip")).toBeNull(); expect(screen.queryByRole("tooltip")).toBeNull();
await userEvent.hover(screen.getByRole("img")); await userEvent.hover(screen.getByRole("img"));

View File

@@ -1,15 +1,16 @@
/* /*
Copyright 2024 New Vector Ltd. Copyright 2024, 2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial 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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { act } from "react"; import React from "react";
import { EventType, getHttpUriForMxc, type IContent, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { EventType, getHttpUriForMxc, type IContent, type MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { fireEvent, render, screen, type RenderResult } from "jest-matrix-react"; import { fireEvent, render, screen, type RenderResult } from "jest-matrix-react";
import fetchMock from "fetch-mock-jest"; import fetchMock from "fetch-mock-jest";
import { type MockedObject } from "jest-mock";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { type RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { type RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
@@ -20,11 +21,12 @@ import {
mockClientMethodsDevice, mockClientMethodsDevice,
mockClientMethodsServer, mockClientMethodsServer,
mockClientMethodsUser, mockClientMethodsUser,
withClientContextRenderOptions,
} from "../../../../test-utils"; } from "../../../../test-utils";
import MVideoBody from "../../../../../src/components/views/messages/MVideoBody"; import MVideoBody from "../../../../../src/components/views/messages/MVideoBody";
import type { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps"; import type { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import SettingsStore from "../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../src/settings/SettingsStore";
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
// Needed so we don't throw an error about failing to decrypt. // Needed so we don't throw an error about failing to decrypt.
jest.mock("matrix-encrypt-attachment", () => ({ jest.mock("matrix-encrypt-attachment", () => ({
@@ -36,13 +38,15 @@ describe("MVideoBody", () => {
const deviceId = "DEADB33F"; const deviceId = "DEADB33F";
const thumbUrl = "https://server/_matrix/media/v3/download/server/encrypted-poster"; const thumbUrl = "https://server/_matrix/media/v3/download/server/encrypted-poster";
let cli: MockedObject<MatrixClient>;
beforeEach(() => { beforeEach(() => {
const cli = getMockClientWithEventEmitter({ cli = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId), ...mockClientMethodsUser(userId),
...mockClientMethodsServer(), ...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId), ...mockClientMethodsDevice(deviceId),
...mockClientMethodsCrypto(), ...mockClientMethodsCrypto(),
getRoom: jest.fn(),
getRooms: jest.fn().mockReturnValue([]), getRooms: jest.fn().mockReturnValue([]),
getIgnoredUsers: jest.fn(), getIgnoredUsers: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ getVersions: jest.fn().mockResolvedValue({
@@ -65,6 +69,7 @@ describe("MVideoBody", () => {
room_id: "!room:server", room_id: "!room:server",
sender: userId, sender: userId,
type: EventType.RoomMessage, type: EventType.RoomMessage,
event_id: "$foo:bar",
content: { content: {
body: "alt for a test video", body: "alt for a test video",
info: { info: {
@@ -93,32 +98,25 @@ describe("MVideoBody", () => {
fetchMock.getOnce(thumbUrl, { status: 200 }); fetchMock.getOnce(thumbUrl, { status: 200 });
const { asFragment } = render( const { asFragment } = render(
<MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, <MVideoBody mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />,
withClientContextRenderOptions(cli),
); );
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
describe("with video previews/thumbnails disabled", () => { describe("with video previews/thumbnails disabled", () => {
beforeEach(() => { beforeEach(() => {
act(() => { const origFn = SettingsStore.getValue;
SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false); jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
if (setting === "mediaPreviewConfig") {
return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off };
}
return origFn(setting, ...args);
}); });
}); });
afterEach(() => { afterEach(() => {
act(() => { SettingsStore.reset();
SettingsStore.setValue( jest.restoreAllMocks();
"showImages",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showImages"),
);
SettingsStore.setValue(
"showMediaEventIds",
null,
SettingLevel.DEVICE,
SettingsStore.getDefaultValue("showMediaEventIds"),
);
});
}); });
it("should not download video", async () => { it("should not download video", async () => {
@@ -129,6 +127,7 @@ describe("MVideoBody", () => {
mxEvent={encryptedMediaEvent} mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>, />,
withClientContextRenderOptions(cli),
); );
expect(screen.getByText("Show video")).toBeInTheDocument(); expect(screen.getByText("Show video")).toBeInTheDocument();
@@ -144,6 +143,7 @@ describe("MVideoBody", () => {
mxEvent={encryptedMediaEvent} mxEvent={encryptedMediaEvent}
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
/>, />,
withClientContextRenderOptions(cli),
); );
const placeholderButton = screen.getByRole("button", { name: "Show video" }); const placeholderButton = screen.getByRole("button", { name: "Show video" });
@@ -191,6 +191,7 @@ function makeMVideoBody(w: number, h: number): RenderResult {
const mockClient = getMockClientWithEventEmitter({ const mockClient = getMockClientWithEventEmitter({
mxcUrlToHttp: jest.fn(), mxcUrlToHttp: jest.fn(),
getRoom: jest.fn(),
}); });
return render( return render(

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024 New Vector Ltd. Copyright 2024, 2025 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -44,9 +44,13 @@ describe("RoomPreviewCard", () => {
client.reEmitter.reEmit(room, [RoomStateEvent.Events]); client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
enabledFeatures = []; enabledFeatures = [];
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName): any => const origFn = SettingsStore.getValue;
enabledFeatures.includes(settingName) ? true : undefined, jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName): any => {
); if (enabledFeatures.includes(settingName)) {
return true;
}
return origFn(settingName);
});
}); });
afterEach(() => { afterEach(() => {

View File

@@ -0,0 +1,99 @@
/*
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 { render } from "jest-matrix-react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { type MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MediaPreviewAccountSettings } from "../../../../../../../src/components/views/settings/tabs/user/MediaPreviewAccountSettings";
import {
getMockClientWithEventEmitter,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../../../../test-utils";
import MatrixClientBackedController from "../../../../../../../src/settings/controllers/MatrixClientBackedController";
import MatrixClientBackedSettingsHandler from "../../../../../../../src/settings/handlers/MatrixClientBackedSettingsHandler";
import type { MockedObject } from "jest-mock";
import {
MEDIA_PREVIEW_ACCOUNT_DATA_TYPE,
type MediaPreviewConfig,
MediaPreviewValue,
} from "../../../../../../../src/@types/media_preview";
import MediaPreviewConfigController from "../../../../../../../src/settings/controllers/MediaPreviewConfigController";
describe("MediaPreviewAccountSettings", () => {
let client: MockedObject<MatrixClient>;
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
...mockClientMethodsUser(),
getRoom: jest.fn(),
setAccountData: jest.fn(),
isVersionSupported: jest.fn().mockResolvedValue(true),
});
MatrixClientBackedController.matrixClient = client;
MatrixClientBackedSettingsHandler.matrixClient = client;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should render", () => {
const { getByLabelText } = render(<MediaPreviewAccountSettings />);
// Defaults
expect(getByLabelText("Hide avatars of room and inviter")).not.toBeChecked();
expect(getByLabelText("Always hide")).not.toBeChecked();
expect(getByLabelText("In private rooms")).not.toBeChecked();
expect(getByLabelText("Always show")).toBeChecked();
});
it("should be able to toggle hide avatar", async () => {
const { getByLabelText } = render(<MediaPreviewAccountSettings />);
// Defaults
const element = getByLabelText("Hide avatars of room and inviter");
await userEvent.click(element);
expect(client.setAccountData).toHaveBeenCalledWith(MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, {
invite_avatars: MediaPreviewValue.Off,
media_previews: MediaPreviewValue.On,
});
// Ensure we don't double set the account data.
expect(client.setAccountData).toHaveBeenCalledTimes(1);
});
// Skip the default.
it.each([
["Always hide", MediaPreviewValue.Off],
["In private rooms", MediaPreviewValue.Private],
["Always show", MediaPreviewValue.On],
])("should be able to toggle media preview option %s", async (key, value) => {
if (value === MediaPreviewConfigController.default.media_previews) {
// This is the default, so switch away first.
client.getAccountData.mockImplementation((type) => {
if (type === MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) {
return new MatrixEvent({
content: {
media_previews: MediaPreviewValue.Off,
} satisfies Partial<MediaPreviewConfig>,
});
}
return undefined;
});
}
const { getByLabelText } = render(<MediaPreviewAccountSettings />);
const element = getByLabelText(key);
await userEvent.click(element);
expect(client.setAccountData).toHaveBeenCalledWith(MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, {
invite_avatars: MediaPreviewValue.On,
media_previews: value,
});
// Ensure we don't double set the account data.
expect(client.setAccountData).toHaveBeenCalledTimes(1);
});
});

View File

@@ -95,33 +95,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
/> />
</div> </div>
</div> </div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_QgU2PomxwKpa"
>
<span
class="mx_SettingsFlag_labelText"
>
Show avatars of rooms you have been invited to
</span>
</label>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Show avatars of rooms you have been invited to"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_QgU2PomxwKpa"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</div> </div>
</div> </div>
<div <div
@@ -144,7 +117,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_6hpi3YEetmBG" for="mx_SettingsFlag_QgU2PomxwKpa"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -162,7 +135,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Show all rooms in Home" aria-label="Show all rooms in Home"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_6hpi3YEetmBG" id="mx_SettingsFlag_QgU2PomxwKpa"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -212,7 +185,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_4yVCeEefiPqp" for="mx_SettingsFlag_6hpi3YEetmBG"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -225,7 +198,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Use Ctrl + F to search timeline" aria-label="Use Ctrl + F to search timeline"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_4yVCeEefiPqp" id="mx_SettingsFlag_6hpi3YEetmBG"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -285,7 +258,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_MRMwbPDmfGtm" for="mx_SettingsFlag_4yVCeEefiPqp"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -298,6 +271,33 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Show timestamps in 12 hour format (e.g. 2:30pm)" aria-label="Show timestamps in 12 hour format (e.g. 2:30pm)"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_4yVCeEefiPqp"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_MRMwbPDmfGtm"
>
<span
class="mx_SettingsFlag_labelText"
>
Always show message timestamps
</span>
</label>
<div
aria-checked="false"
aria-disabled="true"
aria-label="Always show message timestamps"
class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_MRMwbPDmfGtm" id="mx_SettingsFlag_MRMwbPDmfGtm"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -313,33 +313,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_GQvdMWe954DV" for="mx_SettingsFlag_GQvdMWe954DV"
>
<span
class="mx_SettingsFlag_labelText"
>
Always show message timestamps
</span>
</label>
<div
aria-checked="false"
aria-disabled="true"
aria-label="Always show message timestamps"
class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_GQvdMWe954DV"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_IAu5CsiHRD7n"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -352,7 +325,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Publish timezone on public profile" aria-label="Publish timezone on public profile"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_IAu5CsiHRD7n" id="mx_SettingsFlag_GQvdMWe954DV"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -392,7 +365,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_yrA2ohjWVJIP" for="mx_SettingsFlag_IAu5CsiHRD7n"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -405,7 +378,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Send read receipts" aria-label="Send read receipts"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_yrA2ohjWVJIP" id="mx_SettingsFlag_IAu5CsiHRD7n"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -419,7 +392,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_auy1OmnTidX4" for="mx_SettingsFlag_yrA2ohjWVJIP"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -432,7 +405,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Send typing notifications" aria-label="Send typing notifications"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_auy1OmnTidX4" id="mx_SettingsFlag_yrA2ohjWVJIP"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -463,7 +436,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_ePDS0OpWwAHG" for="mx_SettingsFlag_auy1OmnTidX4"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -476,7 +449,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Automatically replace plain text Emoji" aria-label="Automatically replace plain text Emoji"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_ePDS0OpWwAHG" id="mx_SettingsFlag_auy1OmnTidX4"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -490,7 +463,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_75JNTNkNU64r" for="mx_SettingsFlag_ePDS0OpWwAHG"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -514,6 +487,33 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Enable Markdown" aria-label="Enable Markdown"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_ePDS0OpWwAHG"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_75JNTNkNU64r"
>
<span
class="mx_SettingsFlag_labelText"
>
Enable Emoji suggestions while typing
</span>
</label>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Enable Emoji suggestions while typing"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_75JNTNkNU64r" id="mx_SettingsFlag_75JNTNkNU64r"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -533,14 +533,14 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Enable Emoji suggestions while typing Use Ctrl + Enter to send a message
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-label="Enable Emoji suggestions while typing" aria-label="Use Ctrl + Enter to send a message"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_aTLcRsQRlYy7" id="mx_SettingsFlag_aTLcRsQRlYy7"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -560,13 +560,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Use Ctrl + Enter to send a message Surround selected text when typing special characters
</span> </span>
</label> </label>
<div <div
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-label="Use Ctrl + Enter to send a message" aria-label="Surround selected text when typing special characters"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_5nfv5bOEPN1s" id="mx_SettingsFlag_5nfv5bOEPN1s"
role="switch" role="switch"
@@ -587,14 +587,14 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Surround selected text when typing special characters Show stickers button
</span> </span>
</label> </label>
<div <div
aria-checked="false" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Surround selected text when typing special characters" aria-label="Show stickers button"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_u1JYVtOyR5kb" id="mx_SettingsFlag_u1JYVtOyR5kb"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -610,33 +610,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_u3pEwuLn9Enn" for="mx_SettingsFlag_u3pEwuLn9Enn"
>
<span
class="mx_SettingsFlag_labelText"
>
Show stickers button
</span>
</label>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Show stickers button"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_u3pEwuLn9Enn"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_YuxfFEpOsztW"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -649,7 +622,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Insert a trailing colon after user mentions at the start of a message" aria-label="Insert a trailing colon after user mentions at the start of a message"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_YuxfFEpOsztW" id="mx_SettingsFlag_u3pEwuLn9Enn"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -680,7 +653,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_hQkBerF1ejc4" for="mx_SettingsFlag_YuxfFEpOsztW"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -693,6 +666,33 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Enable automatic language detection for syntax highlighting" aria-label="Enable automatic language detection for syntax highlighting"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_YuxfFEpOsztW"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_hQkBerF1ejc4"
>
<span
class="mx_SettingsFlag_labelText"
>
Expand code blocks by default
</span>
</label>
<div
aria-checked="false"
aria-disabled="true"
aria-label="Expand code blocks by default"
class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_hQkBerF1ejc4" id="mx_SettingsFlag_hQkBerF1ejc4"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -708,33 +708,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_GFes1UFzOK2n" for="mx_SettingsFlag_GFes1UFzOK2n"
>
<span
class="mx_SettingsFlag_labelText"
>
Expand code blocks by default
</span>
</label>
<div
aria-checked="false"
aria-disabled="true"
aria-label="Expand code blocks by default"
class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_GFes1UFzOK2n"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_vfGFMldL2r2v"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -747,7 +720,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Show line numbers in code blocks" aria-label="Show line numbers in code blocks"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_vfGFMldL2r2v" id="mx_SettingsFlag_GFes1UFzOK2n"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -778,7 +751,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_bsSwicmKUiOB" for="mx_SettingsFlag_vfGFMldL2r2v"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -791,6 +764,33 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Enable inline URL previews by default" aria-label="Enable inline URL previews by default"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_vfGFMldL2r2v"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_bsSwicmKUiOB"
>
<span
class="mx_SettingsFlag_labelText"
>
Autoplay GIFs
</span>
</label>
<div
aria-checked="false"
aria-disabled="true"
aria-label="Autoplay GIFs"
class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_bsSwicmKUiOB" id="mx_SettingsFlag_bsSwicmKUiOB"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -806,33 +806,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_dvqsxEaZtl3A" for="mx_SettingsFlag_dvqsxEaZtl3A"
>
<span
class="mx_SettingsFlag_labelText"
>
Autoplay GIFs
</span>
</label>
<div
aria-checked="false"
aria-disabled="true"
aria-label="Autoplay GIFs"
class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_dvqsxEaZtl3A"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_NIiWzqsApP1c"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -845,34 +818,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Autoplay videos" aria-label="Autoplay videos"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_NIiWzqsApP1c" id="mx_SettingsFlag_dvqsxEaZtl3A"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_q1SIAPqLMVXh"
>
<span
class="mx_SettingsFlag_labelText"
>
Show previews/thumbnails for images
</span>
</label>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Show previews/thumbnails for images"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_q1SIAPqLMVXh"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -903,7 +849,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_dXFDGgBsKXay" for="mx_SettingsFlag_NIiWzqsApP1c"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -916,6 +862,60 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Show typing notifications" aria-label="Show typing notifications"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_NIiWzqsApP1c"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_q1SIAPqLMVXh"
>
<span
class="mx_SettingsFlag_labelText"
>
Show a placeholder for removed messages
</span>
</label>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Show a placeholder for removed messages"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_q1SIAPqLMVXh"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_dXFDGgBsKXay"
>
<span
class="mx_SettingsFlag_labelText"
>
Show read receipts sent by other users
</span>
</label>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Show read receipts sent by other users"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_dXFDGgBsKXay" id="mx_SettingsFlag_dXFDGgBsKXay"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -935,13 +935,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Show a placeholder for removed messages Show join/leave messages (invites/removes/bans unaffected)
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Show a placeholder for removed messages" aria-label="Show join/leave messages (invites/removes/bans unaffected)"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_7Az0xw4Bs4Tt" id="mx_SettingsFlag_7Az0xw4Bs4Tt"
role="switch" role="switch"
@@ -962,13 +962,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Show read receipts sent by other users Show display name changes
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Show read receipts sent by other users" aria-label="Show display name changes"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_8jmzPIlPoBCv" id="mx_SettingsFlag_8jmzPIlPoBCv"
role="switch" role="switch"
@@ -989,13 +989,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Show join/leave messages (invites/removes/bans unaffected) Show chat effects (animations when receiving e.g. confetti)
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Show join/leave messages (invites/removes/bans unaffected)" aria-label="Show chat effects (animations when receiving e.g. confetti)"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_enFRaTjdsFou" id="mx_SettingsFlag_enFRaTjdsFou"
role="switch" role="switch"
@@ -1016,13 +1016,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Show display name changes Show profile picture changes
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Show display name changes" aria-label="Show profile picture changes"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_bfwnd5rz4XNX" id="mx_SettingsFlag_bfwnd5rz4XNX"
role="switch" role="switch"
@@ -1043,13 +1043,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Show chat effects (animations when receiving e.g. confetti) Show avatars in user, room and event mentions
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Show chat effects (animations when receiving e.g. confetti)" aria-label="Show avatars in user, room and event mentions"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_gs5uWEzYzZrS" id="mx_SettingsFlag_gs5uWEzYzZrS"
role="switch" role="switch"
@@ -1070,13 +1070,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Show profile picture changes Enable big emoji in chat
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Show profile picture changes" aria-label="Enable big emoji in chat"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_qWg7OgID1yRR" id="mx_SettingsFlag_qWg7OgID1yRR"
role="switch" role="switch"
@@ -1097,13 +1097,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Show avatars in user, room and event mentions Jump to the bottom of the timeline when you send a message
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="true"
aria-disabled="true" aria-disabled="true"
aria-label="Show avatars in user, room and event mentions" aria-label="Jump to the bottom of the timeline when you send a message"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_pOPewl7rtMbV" id="mx_SettingsFlag_pOPewl7rtMbV"
role="switch" role="switch"
@@ -1124,14 +1124,14 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
> >
Enable big emoji in chat Show current profile picture and name for users in message history
</span> </span>
</label> </label>
<div <div
aria-checked="true" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-label="Enable big emoji in chat" aria-label="Show current profile picture and name for users in message history"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_cmt3PZSyNp3v" id="mx_SettingsFlag_cmt3PZSyNp3v"
role="switch" role="switch"
tabindex="0" tabindex="0"
@@ -1141,62 +1141,170 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
/> />
</div> </div>
</div> </div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_dJJz3lHUv9XX"
>
<span
class="mx_SettingsFlag_labelText"
>
Jump to the bottom of the timeline when you send a message
</span>
</label>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Jump to the bottom of the timeline when you send a message"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_dJJz3lHUv9XX"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
for="mx_SettingsFlag_SBSSOZDRlzlA"
>
<span
class="mx_SettingsFlag_labelText"
>
Show current profile picture and name for users in message history
</span>
</label>
<div
aria-checked="false"
aria-disabled="true"
aria-label="Show current profile picture and name for users in message history"
class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_SBSSOZDRlzlA"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</div> </div>
</div> </div>
<div
class="mx_SettingsSubsection mx_SettingsSubsection_newUi"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Moderation and safety
</h3>
</div>
<div
class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi"
>
<form
class="_root_19upo_16 mx_MediaPreviewAccountSetting_Form"
>
<div
class="mx_SettingsFlag mx_MediaPreviewAccountSetting_ToggleSwitch"
>
<span
class="mx_SettingsFlag_label"
>
<div
id="mx_LabelledToggleSwitch_«r8»"
>
Hide avatars of room and inviter
</div>
</span>
<div
aria-checked="false"
aria-disabled="false"
aria-labelledby="mx_LabelledToggleSwitch_«r8»"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
aria-label="Show media in timeline"
class="_field_19upo_26"
id="mx_media_previews"
role="radiogroup"
>
<label
class="_label_19upo_59"
for="radix-«r9»"
>
Show media in timeline
</label>
<span
class="_message_19upo_85 _help-message_19upo_91 mx_MediaPreviewAccountSetting_RadioHelp"
id="radix-«ra»"
>
A hidden media can always be shown by tapping on it
</span>
<div
class="_inline-field_19upo_32 mx_MediaPreviewAccountSetting_Radio"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1e0uz_10"
>
<input
class="_input_1e0uz_18"
id="mx_media_previews_off"
type="radio"
/>
<div
class="_ui_1e0uz_19"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="mx_media_previews_off"
>
Always hide
</label>
</div>
</div>
<div
class="_inline-field_19upo_32 mx_MediaPreviewAccountSetting_Radio"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1e0uz_10"
>
<input
class="_input_1e0uz_18"
id="mx_media_previews_private"
type="radio"
/>
<div
class="_ui_1e0uz_19"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="mx_media_previews_private"
>
In private rooms
</label>
</div>
</div>
<div
class="_inline-field_19upo_32 mx_MediaPreviewAccountSetting_Radio"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1e0uz_10"
>
<input
checked=""
class="_input_1e0uz_18"
id="mx_media_previews_on"
type="radio"
/>
<div
class="_ui_1e0uz_19"
/>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
>
<label
class="_label_19upo_59"
for="mx_media_previews_on"
>
Always show
</label>
</div>
</div>
</div>
</form>
</div>
<div
class="_separator_7ckbw_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
</div>
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
> >
@@ -1217,7 +1325,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_FLEpLCb0jpp6" for="mx_SettingsFlag_dJJz3lHUv9XX"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -1230,7 +1338,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Show NSFW content" aria-label="Show NSFW content"
class="mx_AccessibleButton mx_ToggleSwitch" class="mx_AccessibleButton mx_ToggleSwitch"
id="mx_SettingsFlag_FLEpLCb0jpp6" id="mx_SettingsFlag_dJJz3lHUv9XX"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >
@@ -1261,7 +1369,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
> >
<label <label
class="mx_SettingsFlag_label" class="mx_SettingsFlag_label"
for="mx_SettingsFlag_NQFWldEwbV3q" for="mx_SettingsFlag_SBSSOZDRlzlA"
> >
<span <span
class="mx_SettingsFlag_labelText" class="mx_SettingsFlag_labelText"
@@ -1274,7 +1382,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
aria-disabled="true" aria-disabled="true"
aria-label="Prompt before sending invites to potentially invalid matrix IDs" aria-label="Prompt before sending invites to potentially invalid matrix IDs"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on" class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
id="mx_SettingsFlag_NQFWldEwbV3q" id="mx_SettingsFlag_SBSSOZDRlzlA"
role="switch" role="switch"
tabindex="0" tabindex="0"
> >

View File

@@ -6,48 +6,74 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { act, renderHook, waitFor } from "jest-matrix-react"; import { act, renderHook, waitFor } from "jest-matrix-react";
import { JoinRule, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { useMediaVisible } from "../../../src/hooks/useMediaVisible"; import { useMediaVisible } from "../../../src/hooks/useMediaVisible";
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../test-utils";
import { type MediaPreviewConfig, MediaPreviewValue } from "../../../src/@types/media_preview";
import MediaPreviewConfigController from "../../../src/settings/controllers/MediaPreviewConfigController";
import SettingsStore from "../../../src/settings/SettingsStore"; import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
const EVENT_ID = "$fibble:example.org"; const EVENT_ID = "$fibble:example.org";
const ROOM_ID = "!foobar:example.org";
function render() {
return renderHook(() => useMediaVisible(EVENT_ID));
}
describe("useMediaVisible", () => { describe("useMediaVisible", () => {
afterEach(() => { let matrixClient: MatrixClient;
// Using act here as otherwise React warns about state updates not being wrapped. let room: Room;
act(() => { const mediaPreviewConfig: MediaPreviewConfig = MediaPreviewConfigController.default;
SettingsStore.setValue(
"showMediaEventIds", function render() {
null, return renderHook(() => useMediaVisible(EVENT_ID, ROOM_ID), withClientContextRenderOptions(matrixClient));
SettingLevel.DEVICE, }
SettingsStore.getDefaultValue("showMediaEventIds"), beforeEach(() => {
); matrixClient = createTestClient();
SettingsStore.setValue( room = mkStubRoom(ROOM_ID, undefined, matrixClient);
"showImages", matrixClient.getRoom = jest.fn().mockReturnValue(room);
null, const origFn = SettingsStore.getValue;
SettingLevel.DEVICE, jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
SettingsStore.getDefaultValue("showImages"), if (setting === "mediaPreviewConfig") {
); return mediaPreviewConfig;
}
return origFn(setting, ...args);
}); });
}); });
it("should display images by default", async () => { afterEach(() => {
jest.restoreAllMocks();
});
it("should display media by default", async () => {
const { result } = render(); const { result } = render();
expect(result.current[0]).toEqual(true); expect(result.current[0]).toEqual(true);
}); });
it("should hide images when the default is changed", async () => { it("should hide media when media previews are Off", async () => {
SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false); mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
const { result } = render(); const { result } = render();
expect(result.current[0]).toEqual(false); expect(result.current[0]).toEqual(false);
}); });
it("should hide images after function is called", async () => { it.each([[JoinRule.Invite], [JoinRule.Knock], [JoinRule.Restricted]])(
"should display media when media previews are Private and the join rule is %s",
async (rule) => {
mediaPreviewConfig.media_previews = MediaPreviewValue.Private;
room.currentState.getJoinRule = jest.fn().mockReturnValue(rule);
const { result } = render();
expect(result.current[0]).toEqual(true);
},
);
it.each([[JoinRule.Public], ["anything_else"]])(
"should hide media when media previews are Private and the join rule is %s",
async (rule) => {
mediaPreviewConfig.media_previews = MediaPreviewValue.Private;
room.currentState.getJoinRule = jest.fn().mockReturnValue(rule);
const { result } = render();
expect(result.current[0]).toEqual(false);
},
);
it("should hide media after function is called", async () => {
const { result } = render(); const { result } = render();
expect(result.current[0]).toEqual(true); expect(result.current[0]).toEqual(true);
act(() => { act(() => {
@@ -57,8 +83,8 @@ describe("useMediaVisible", () => {
expect(result.current[0]).toEqual(false); expect(result.current[0]).toEqual(false);
}); });
}); });
it("should show images after function is called", async () => { it("should show media after function is called", async () => {
SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false); mediaPreviewConfig.media_previews = MediaPreviewValue.Off;
const { result } = render(); const { result } = render();
expect(result.current[0]).toEqual(false); expect(result.current[0]).toEqual(false);
act(() => { act(() => {

View File

@@ -0,0 +1,164 @@
/*
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 { MatrixEvent } from "matrix-js-sdk/src/matrix";
import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController";
import MediaPreviewConfigController from "../../../../src/settings/controllers/MediaPreviewConfigController";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { getMockClientWithEventEmitter, mockClientMethodsServer } from "../../../test-utils";
import { MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, MediaPreviewValue } from "../../../../src/@types/media_preview";
describe("MediaPreviewConfigController", () => {
afterEach(() => {
jest.restoreAllMocks();
});
const ROOM_ID = "!room:example.org";
it("gets the default settings when none are specified.", () => {
const controller = new MediaPreviewConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(null),
});
const value = controller.getValueOverride(SettingLevel.ACCOUNT, null);
expect(value).toEqual(MediaPreviewConfigController.default);
});
it("gets the default settings when the setting is empty.", () => {
const controller = new MediaPreviewConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest
.fn()
.mockReturnValue(new MatrixEvent({ type: MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, content: {} })),
});
const value = controller.getValueOverride(SettingLevel.ACCOUNT, null);
expect(value).toEqual(MediaPreviewConfigController.default);
});
it.each([["media_previews"], ["invite_avatars"]])("gets the correct value for %s at the global level", (key) => {
const controller = new MediaPreviewConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: MEDIA_PREVIEW_ACCOUNT_DATA_TYPE,
content: {
[key]: MediaPreviewValue.Off,
},
}),
),
getRoom: jest.fn().mockReturnValue({
getAccountData: jest.fn().mockReturnValue(null),
}),
});
const globalValue = controller.getValueOverride(SettingLevel.ACCOUNT, null);
expect(globalValue[key]).toEqual(MediaPreviewValue.Off);
// Should follow the global value.
const roomValue = controller.getValueOverride(SettingLevel.ROOM_ACCOUNT, ROOM_ID);
expect(roomValue[key]).toEqual(MediaPreviewValue.Off);
});
it.each([["media_previews"], ["invite_avatars"]])("gets the correct value for %s at the room level", (key) => {
const controller = new MediaPreviewConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(null),
getRoom: jest.fn().mockReturnValue({
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: MEDIA_PREVIEW_ACCOUNT_DATA_TYPE,
content: {
[key]: MediaPreviewValue.Off,
},
}),
),
}),
});
const globalValue = controller.getValueOverride(SettingLevel.ACCOUNT, null);
expect(globalValue[key]).toEqual(MediaPreviewValue.On);
// Should follow the global value.
const roomValue = controller.getValueOverride(SettingLevel.ROOM_ACCOUNT, ROOM_ID);
expect(roomValue[key]).toEqual(MediaPreviewValue.Off);
});
it.each([["media_previews"], ["invite_avatars"]])(
"uses defaults when an invalid value is set on the global level",
(key) => {
const controller = new MediaPreviewConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: MEDIA_PREVIEW_ACCOUNT_DATA_TYPE,
content: {
[key]: "bibble",
},
}),
),
getRoom: jest.fn().mockReturnValue({
getAccountData: jest.fn().mockReturnValue(null),
}),
});
const globalValue = controller.getValueOverride(SettingLevel.ACCOUNT, null);
expect(globalValue[key]).toEqual(MediaPreviewValue.On);
// Should follow the global value.
const roomValue = controller.getValueOverride(SettingLevel.ROOM_ACCOUNT, ROOM_ID);
expect(roomValue[key]).toEqual(MediaPreviewValue.On);
},
);
it.each([["media_previews"], ["invite_avatars"]])(
"uses global value when an invalid value is set on the room level",
(key) => {
const controller = new MediaPreviewConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: MEDIA_PREVIEW_ACCOUNT_DATA_TYPE,
content: {
[key]: MediaPreviewValue.Off,
},
}),
),
getRoom: jest.fn().mockReturnValue({
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: MEDIA_PREVIEW_ACCOUNT_DATA_TYPE,
content: {
[key]: "bibble",
},
}),
),
}),
});
const globalValue = controller.getValueOverride(SettingLevel.ACCOUNT, null);
expect(globalValue[key]).toEqual(MediaPreviewValue.Off);
// Should follow the global value.
const roomValue = controller.getValueOverride(SettingLevel.ROOM_ACCOUNT, ROOM_ID);
expect(roomValue[key]).toEqual(MediaPreviewValue.Off);
},
);
});