Skip to content

Commit 75d9898

Browse files
authored
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
1 parent da6ac36 commit 75d9898

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1280
-275
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import * as fs from "node:fs";
9+
import { type EventType, type MsgType, type RoomJoinRulesEventContent } from "matrix-js-sdk/src/types";
10+
11+
import { test, expect } from "../../element-web-test";
12+
13+
const MEDIA_FILE = fs.readFileSync("playwright/sample-files/riot.png");
14+
15+
test.describe("Media preview settings", () => {
16+
test.use({
17+
displayName: "Alan",
18+
room: async ({ app, page, homeserver, bot, user }, use) => {
19+
const mxc = (await bot.uploadContent(MEDIA_FILE, { name: "image.png", type: "image/png" })).content_uri;
20+
const roomId = await bot.createRoom({
21+
name: "Test room",
22+
invite: [user.userId],
23+
initial_state: [{ type: "m.room.avatar", content: { url: mxc }, state_key: "" }],
24+
});
25+
await bot.sendEvent(roomId, null, "m.room.message" as EventType, {
26+
msgtype: "m.image" as MsgType,
27+
body: "image.png",
28+
url: mxc,
29+
});
30+
31+
await use({ roomId });
32+
},
33+
});
34+
35+
test("should be able to hide avatars of inviters", { tag: "@screenshot" }, async ({ page, app, room, user }) => {
36+
let settings = await app.settings.openUserSettings("Preferences");
37+
await settings.getByLabel("Hide avatars of room and inviter").click();
38+
await app.closeDialog();
39+
await app.viewRoomById(room.roomId);
40+
await expect(
41+
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
42+
).toMatchScreenshot("invite-no-avatar.png");
43+
await expect(
44+
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
45+
).toMatchScreenshot("invite-room-tree-no-avatar.png");
46+
47+
// And then go back to being visible
48+
settings = await app.settings.openUserSettings("Preferences");
49+
await settings.getByLabel("Hide avatars of room and inviter").click();
50+
await app.closeDialog();
51+
await page.goto("#/home");
52+
await app.viewRoomById(room.roomId);
53+
await expect(
54+
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
55+
).toMatchScreenshot("invite-with-avatar.png");
56+
await expect(
57+
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
58+
).toMatchScreenshot("invite-room-tree-with-avatar.png");
59+
});
60+
61+
test("should be able to hide media in rooms globally", async ({ page, app, room, user }) => {
62+
const settings = await app.settings.openUserSettings("Preferences");
63+
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
64+
await app.closeDialog();
65+
await app.viewRoomById(room.roomId);
66+
await page.getByRole("button", { name: "Accept" }).click();
67+
await expect(page.getByText("Show image")).toBeVisible();
68+
});
69+
test("should be able to hide media in non-private rooms globally", async ({ page, app, room, user, bot }) => {
70+
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
71+
join_rule: "public",
72+
});
73+
const settings = await app.settings.openUserSettings("Preferences");
74+
await settings.getByLabel("Show media in timeline").getByLabel("In private rooms").click();
75+
await app.closeDialog();
76+
await app.viewRoomById(room.roomId);
77+
await page.getByRole("button", { name: "Accept" }).click();
78+
await expect(page.getByText("Show image")).toBeVisible();
79+
for (const joinRule of ["invite", "knock", "restricted"] as RoomJoinRulesEventContent["join_rule"][]) {
80+
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
81+
join_rule: joinRule,
82+
} satisfies RoomJoinRulesEventContent);
83+
await expect(page.getByText("Show image")).not.toBeVisible();
84+
}
85+
});
86+
test("should be able to show media in rooms globally", async ({ page, app, room, user }) => {
87+
const settings = await app.settings.openUserSettings("Preferences");
88+
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
89+
await app.closeDialog();
90+
await app.viewRoomById(room.roomId);
91+
await page.getByRole("button", { name: "Accept" }).click();
92+
await expect(page.getByText("Show image")).not.toBeVisible();
93+
});
94+
test("should be able to hide media in an individual room", async ({ page, app, room, user }) => {
95+
const settings = await app.settings.openUserSettings("Preferences");
96+
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
97+
await app.closeDialog();
98+
99+
await app.viewRoomById(room.roomId);
100+
await page.getByRole("button", { name: "Accept" }).click();
101+
102+
const roomSettings = await app.settings.openRoomSettings("General");
103+
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
104+
await app.closeDialog();
105+
106+
await expect(page.getByText("Show image")).toBeVisible();
107+
});
108+
test("should be able to show media in an individual room", async ({ page, app, room, user }) => {
109+
const settings = await app.settings.openUserSettings("Preferences");
110+
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
111+
await app.closeDialog();
112+
113+
await app.viewRoomById(room.roomId);
114+
await page.getByRole("button", { name: "Accept" }).click();
115+
116+
const roomSettings = await app.settings.openRoomSettings("General");
117+
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
118+
await app.closeDialog();
119+
120+
await expect(page.getByText("Show image")).not.toBeVisible();
121+
});
122+
});
Loading
Loading

res/css/_components.pcss

+1
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@
378378
@import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss";
379379
@import "./views/settings/tabs/user/_HelpUserSettingsTab.pcss";
380380
@import "./views/settings/tabs/user/_KeyboardUserSettingsTab.pcss";
381+
@import "./views/settings/tabs/user/_MediaPreviewAccountSettings.pcss";
381382
@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.pcss";
382383
@import "./views/settings/tabs/user/_PreferencesUserSettingsTab.pcss";
383384
@import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
.mx_MediaPreviewAccountSetting_Radio {
9+
margin: var(--cpd-space-1x) 0;
10+
}
11+
12+
.mx_MediaPreviewAccountSetting {
13+
margin-top: var(--cpd-space-1x);
14+
}
15+
16+
.mx_MediaPreviewAccountSetting_RadioHelp {
17+
margin-top: 0;
18+
margin-bottom: var(--cpd-space-1x);
19+
}
20+
21+
.mx_MediaPreviewAccountSetting_Form {
22+
width: 100%;
23+
}
24+
25+
.mx_MediaPreviewAccountSetting_ToggleSwitch {
26+
font: var(--cpd-font-body-md-medium);
27+
letter-spacing: var(--cpd-font-letter-spacing-body-md);
28+
}

src/@types/matrix-js-sdk.d.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2024 The Matrix.org Foundation C.I.C.
44
55
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";
1414
import type { EmptyObject } from "matrix-js-sdk/src/matrix";
1515
import type { DeviceClientInformation } from "../utils/device/types.ts";
1616
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
17+
import { type MediaPreviewConfig } from "./media_preview.ts";
1718

1819
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
1920
declare module "matrix-js-sdk/src/types" {
@@ -87,6 +88,8 @@ declare module "matrix-js-sdk/src/types" {
8788
"m.accepted_terms": {
8889
accepted: string[];
8990
};
91+
92+
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
9093
}
9194

9295
export interface AudioContent {

src/@types/media_preview.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
export enum MediaPreviewValue {
9+
/**
10+
* Media previews should be enabled.
11+
*/
12+
On = "on",
13+
/**
14+
* Media previews should only be enabled for rooms with non-public join rules.
15+
*/
16+
Private = "private",
17+
/**
18+
* Media previews should be disabled.
19+
*/
20+
Off = "off",
21+
}
22+
23+
export const MEDIA_PREVIEW_ACCOUNT_DATA_TYPE = "io.element.msc4278.media_preview_config";
24+
export interface MediaPreviewConfig extends Record<string, unknown> {
25+
/**
26+
* Media preview setting for thumbnails of media in rooms.
27+
*/
28+
media_previews: MediaPreviewValue;
29+
/**
30+
* Media preview settings for avatars of rooms we have been invited to.
31+
*/
32+
invite_avatars: MediaPreviewValue.On | MediaPreviewValue.Off;
33+
}

src/HtmlUtils.tsx

+19-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2019 Michael Telatynski <[email protected]>
44
Copyright 2019 The Matrix.org Foundation C.I.C.
55
Copyright 2017, 2018 New Vector Ltd
@@ -294,6 +294,10 @@ export interface EventRenderOpts {
294294
disableBigEmoji?: boolean;
295295
stripReplyFallback?: boolean;
296296
forComposerQuote?: boolean;
297+
/**
298+
* Should inline media be rendered?
299+
*/
300+
mediaIsVisible?: boolean;
297301
}
298302

299303
function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
@@ -302,6 +306,20 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
302306
sanitizeParams = composerSanitizeHtmlParams;
303307
}
304308

309+
if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) {
310+
// Prevent mutating the source of sanitizeParams.
311+
sanitizeParams = {
312+
...sanitizeParams,
313+
transformTags: {
314+
...sanitizeParams.transformTags,
315+
img: (tagName) => {
316+
// Remove element
317+
return { tagName, attribs: {} };
318+
},
319+
},
320+
};
321+
}
322+
305323
try {
306324
const isFormattedBody =
307325
content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string";

src/Linkify.tsx

+2-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2024 The Matrix.org Foundation C.I.C.
44
55
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -12,7 +12,6 @@ import { merge } from "lodash";
1212
import _Linkify from "linkify-react";
1313

1414
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
15-
import SettingsStore from "./settings/SettingsStore";
1615
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
1716
import { mediaFromMxc } from "./customisations/Media";
1817
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
@@ -47,10 +46,7 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
4746
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
4847
// because transformTags is used _before_ we filter by allowedSchemesByTag and
4948
// we don't want to allow images with `https?` `src`s.
50-
// We also drop inline images (as if they were not present at all) when the "show
51-
// images" preference is disabled. Future work might expose some UI to reveal them
52-
// like standalone image events have.
53-
if (!src || !SettingsStore.getValue("showImages")) {
49+
if (!src) {
5450
return { tagName, attribs: {} };
5551
}
5652

@@ -78,7 +74,6 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
7874
if (requestedHeight) {
7975
attribs.style += "height: 100%;";
8076
}
81-
8277
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!;
8378
return { tagName, attribs };
8479
},

src/components/views/avatars/RoomAvatar.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { filterBoolean } from "../../../utils/arrays";
2020
import { useSettingValue } from "../../../hooks/useSettings";
2121
import { useRoomState } from "../../../hooks/useRoomState";
2222
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
23+
import { MediaPreviewValue } from "../../../@types/media_preview";
2324

2425
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
2526
// Room may be left unset here, but if it is,
@@ -40,7 +41,8 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
4041
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
4142
const roomIdName = useRoomIdName(room, oobData);
4243

43-
const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
44+
const showAvatarsOnInvites =
45+
useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On;
4446

4547
const onRoomAvatarClick = useCallback(() => {
4648
const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
@@ -63,7 +65,6 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
6365
// parseInt ignores suffixes.
6466
const sizeInt = parseInt(size, 10);
6567
let oobAvatar: string | null = null;
66-
6768
if (oobData?.avatarUrl) {
6869
oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
6970
}

src/components/views/messages/EventContentBody.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
2929
import { useSettingValue } from "../../../hooks/useSettings.ts";
3030
import { filterBoolean } from "../../../utils/arrays.ts";
31+
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
3132

3233
/**
3334
* 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(
150151
forwardRef<HTMLElement, Props>(
151152
({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ...options }, ref) => {
152153
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
154+
const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId());
153155

154156
const replacer = useReplacer(content, mxEvent, options);
155157
const linkifyOptions = useMemo(
@@ -167,8 +169,9 @@ const EventContentBody = memo(
167169
disableBigEmoji: isEmote || !enableBigEmoji,
168170
// Part of Replies fallback support
169171
stripReplyFallback: stripReply,
172+
mediaIsVisible,
170173
}),
171-
[content, enableBigEmoji, highlights, isEmote, stripReply],
174+
[content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply],
172175
);
173176

174177
if (as === "div") includeDir = true; // force dir="auto" on divs

src/components/views/messages/HideActionButton.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2021 The Matrix.org Foundation C.I.C.
44
55
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -25,7 +25,7 @@ interface IProps {
2525
* Quick action button for marking a media event as hidden.
2626
*/
2727
export const HideActionButton: React.FC<IProps> = ({ mxEvent }) => {
28-
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId()!);
28+
const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId());
2929

3030
if (!mediaIsVisible) {
3131
return;

src/components/views/messages/MImageBody.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,7 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
686686

687687
// Wrap MImageBody component so we can use a hook here.
688688
const MImageBody: React.FC<IBodyProps> = (props) => {
689-
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
689+
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
690690
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
691691
};
692692

src/components/views/messages/MImageReplyBody.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
3838
}
3939
}
4040
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
41-
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
41+
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
4242
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
4343
};
4444

src/components/views/messages/MStickerBody.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class MStickerBodyInner extends MImageBodyInner {
7979
}
8080

8181
const MStickerBody: React.FC<IBodyProps> = (props) => {
82-
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
82+
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
8383
return <MStickerBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
8484
};
8585

src/components/views/messages/MVideoBody.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ class MVideoBodyInner extends React.PureComponent<IProps, IState> {
342342

343343
// Wrap MVideoBody component so we can use a hook here.
344344
const MVideoBody: React.FC<IBodyProps> = (props) => {
345-
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!);
345+
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId());
346346
return <MVideoBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
347347
};
348348

0 commit comments

Comments
 (0)