diff --git a/playwright/e2e/timeline/media-preview-settings.spec.ts b/playwright/e2e/timeline/media-preview-settings.spec.ts new file mode 100644 index 0000000000..d09a576629 --- /dev/null +++ b/playwright/e2e/timeline/media-preview-settings.spec.ts @@ -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(); + }); +}); diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index 91d402ed8a..5f92c00fc3 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-no-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-no-avatar-linux.png new file mode 100644 index 0000000000..f706297f52 Binary files /dev/null and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-no-avatar-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-no-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-no-avatar-linux.png new file mode 100644 index 0000000000..29d129f73f Binary files /dev/null and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-no-avatar-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-with-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-with-avatar-linux.png new file mode 100644 index 0000000000..452f08d3e2 Binary files /dev/null and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-room-tree-with-avatar-linux.png differ diff --git a/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png new file mode 100644 index 0000000000..00fdb29c81 Binary files /dev/null and b/playwright/snapshots/timeline/media-preview-settings.spec.ts/invite-with-avatar-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b58a53a325..666a1c69dd 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -378,6 +378,7 @@ @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.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/_PreferencesUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss"; diff --git a/res/css/views/settings/tabs/user/_MediaPreviewAccountSettings.pcss b/res/css/views/settings/tabs/user/_MediaPreviewAccountSettings.pcss new file mode 100644 index 0000000000..68187854c1 --- /dev/null +++ b/res/css/views/settings/tabs/user/_MediaPreviewAccountSettings.pcss @@ -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); +} diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index 92b76c4c4d..c81c5377bf 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2024 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -14,6 +14,7 @@ import type { EncryptedFile } from "matrix-js-sdk/src/types"; import type { EmptyObject } from "matrix-js-sdk/src/matrix"; import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { UserWidget } from "../utils/WidgetUtils-types.ts"; +import { type MediaPreviewConfig } from "./media_preview.ts"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types declare module "matrix-js-sdk/src/types" { @@ -87,6 +88,8 @@ declare module "matrix-js-sdk/src/types" { "m.accepted_terms": { accepted: string[]; }; + + "io.element.msc4278.media_preview_config": MediaPreviewConfig; } export interface AudioContent { diff --git a/src/@types/media_preview.ts b/src/@types/media_preview.ts new file mode 100644 index 0000000000..d340e64caf --- /dev/null +++ b/src/@types/media_preview.ts @@ -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 { + /** + * 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; +} diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index f704657c32..3d06b71b1e 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2017, 2018 New Vector Ltd @@ -294,6 +294,10 @@ export interface EventRenderOpts { disableBigEmoji?: boolean; stripReplyFallback?: boolean; forComposerQuote?: boolean; + /** + * Should inline media be rendered? + */ + mediaIsVisible?: boolean; } function analyseEvent(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): EventAnalysis { @@ -302,6 +306,20 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E sanitizeParams = composerSanitizeHtmlParams; } + if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) { + // Prevent mutating the source of sanitizeParams. + sanitizeParams = { + ...sanitizeParams, + transformTags: { + ...sanitizeParams.transformTags, + img: (tagName) => { + // Remove element + return { tagName, attribs: {} }; + }, + }, + }; + } + try { const isFormattedBody = content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string"; diff --git a/src/Linkify.tsx b/src/Linkify.tsx index 27dd4783be..846bf8e82d 100644 --- a/src/Linkify.tsx +++ b/src/Linkify.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2024 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -12,7 +12,6 @@ import { merge } from "lodash"; import _Linkify from "linkify-react"; import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; -import SettingsStore from "./settings/SettingsStore"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { mediaFromMxc } from "./customisations/Media"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; @@ -47,10 +46,7 @@ export const transformTags: NonNullable = { // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. - // We also drop inline images (as if they were not present at all) when the "show - // images" preference is disabled. Future work might expose some UI to reveal them - // like standalone image events have. - if (!src || !SettingsStore.getValue("showImages")) { + if (!src) { return { tagName, attribs: {} }; } @@ -78,7 +74,6 @@ export const transformTags: NonNullable = { if (requestedHeight) { attribs.style += "height: 100%;"; } - attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!; return { tagName, attribs }; }, diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index ae4d6b3605..6490378731 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -20,6 +20,7 @@ import { filterBoolean } from "../../../utils/arrays"; import { useSettingValue } from "../../../hooks/useSettings"; import { useRoomState } from "../../../hooks/useRoomState"; import { useRoomIdName } from "../../../hooks/room/useRoomIdName"; +import { MediaPreviewValue } from "../../../@types/media_preview"; interface IProps extends Omit, "name" | "idName" | "url" | "onClick" | "size"> { // Room may be left unset here, but if it is, @@ -40,7 +41,8 @@ const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobDat const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, "")); const roomIdName = useRoomIdName(room, oobData); - const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId); + const showAvatarsOnInvites = + useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On; const onRoomAvatarClick = useCallback(() => { const avatarUrl = Avatar.avatarUrlForRoom(room ?? null); @@ -63,7 +65,6 @@ const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobDat // parseInt ignores suffixes. const sizeInt = parseInt(size, 10); let oobAvatar: string | null = null; - if (oobData?.avatarUrl) { oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop"); } diff --git a/src/components/views/messages/EventContentBody.tsx b/src/components/views/messages/EventContentBody.tsx index 3e51691c24..66ff7915ea 100644 --- a/src/components/views/messages/EventContentBody.tsx +++ b/src/components/views/messages/EventContentBody.tsx @@ -28,6 +28,7 @@ import { import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx"; import { useSettingValue } from "../../../hooks/useSettings.ts"; import { filterBoolean } from "../../../utils/arrays.ts"; +import { useMediaVisible } from "../../../hooks/useMediaVisible.ts"; /** * Returns a RegExp pattern for the keyword in the push rule of the given Matrix event, if any @@ -150,6 +151,7 @@ const EventContentBody = memo( forwardRef( ({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ...options }, ref) => { const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji"); + const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId()); const replacer = useReplacer(content, mxEvent, options); const linkifyOptions = useMemo( @@ -167,8 +169,9 @@ const EventContentBody = memo( disableBigEmoji: isEmote || !enableBigEmoji, // Part of Replies fallback support stripReplyFallback: stripReply, + mediaIsVisible, }), - [content, enableBigEmoji, highlights, isEmote, stripReply], + [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply], ); if (as === "div") includeDir = true; // force dir="auto" on divs diff --git a/src/components/views/messages/HideActionButton.tsx b/src/components/views/messages/HideActionButton.tsx index 8d2baf0220..0c9817b2a6 100644 --- a/src/components/views/messages/HideActionButton.tsx +++ b/src/components/views/messages/HideActionButton.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -25,7 +25,7 @@ interface IProps { * Quick action button for marking a media event as hidden. */ export const HideActionButton: React.FC = ({ mxEvent }) => { - const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId()!); + const [mediaIsVisible, setVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId()); if (!mediaIsVisible) { return; diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 7c42ea4462..79f840ce39 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -686,7 +686,7 @@ export class MImageBodyInner extends React.Component { // Wrap MImageBody component so we can use a hook here. const MImageBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!); + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId()); return ; }; diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index 788ca2d7e9..b73f8f77c3 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -38,7 +38,7 @@ class MImageReplyBodyInner extends MImageBodyInner { } } const MImageReplyBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!); + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId()); return ; }; diff --git a/src/components/views/messages/MStickerBody.tsx b/src/components/views/messages/MStickerBody.tsx index 365ebc652a..3a922d35aa 100644 --- a/src/components/views/messages/MStickerBody.tsx +++ b/src/components/views/messages/MStickerBody.tsx @@ -79,7 +79,7 @@ class MStickerBodyInner extends MImageBodyInner { } const MStickerBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!); + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId()); return ; }; diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 58ba7575fe..6a36dae6a8 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -342,7 +342,7 @@ class MVideoBodyInner extends React.PureComponent { // Wrap MVideoBody component so we can use a hook here. const MVideoBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId()!); + const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent.getId(), props.mxEvent.getRoomId()); return ; }; diff --git a/src/components/views/rooms/LinkPreviewGroup.tsx b/src/components/views/rooms/LinkPreviewGroup.tsx index 84943c24e8..69c98cb6c9 100644 --- a/src/components/views/rooms/LinkPreviewGroup.tsx +++ b/src/components/views/rooms/LinkPreviewGroup.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -17,6 +17,7 @@ import AccessibleButton from "../elements/AccessibleButton"; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; +import { useMediaVisible } from "../../../hooks/useMediaVisible"; const INITIAL_NUM_PREVIEWS = 2; @@ -29,6 +30,7 @@ interface IProps { const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick }) => { const cli = useContext(MatrixClientContext); const [expanded, toggleExpanded] = useStateToggle(); + const [mediaVisible] = useMediaVisible(mxEvent.getId(), mxEvent.getRoomId()); const ts = mxEvent.getTs(); const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>( @@ -55,7 +57,13 @@ const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick }) = return (
{showPreviews.map(([link, preview], i) => ( - + {i === 0 ? ( { @@ -69,7 +69,7 @@ export default class LinkPreviewWidget extends React.Component { // FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing? let image: string | null = p["og:image"] ?? null; - if (!SettingsStore.getValue("showImages")) { + if (!this.props.mediaVisible) { image = null; // Don't render a button to show the image, just hide it outright } const imageMaxWidth = 100; diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index 2615c083c9..b50eb22a7c 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019-2024 New Vector Ltd. +Copyright 2019-2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. @@ -22,6 +22,7 @@ import { SettingsSubsection } from "../../shared/SettingsSubsection"; import SettingsTab from "../SettingsTab"; import { SettingsSection } from "../../shared/SettingsSection"; import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings"; +import { MediaPreviewAccountSettings } from "../user/MediaPreviewAccountSettings"; interface IProps { room: Room; @@ -92,6 +93,9 @@ export default class GeneralRoomSettingsTab extends React.Component {urlPreviewSettings} + + + {leaveSection} diff --git a/src/components/views/settings/tabs/user/MediaPreviewAccountSettings.tsx b/src/components/views/settings/tabs/user/MediaPreviewAccountSettings.tsx new file mode 100644 index 0000000000..f6a2b7cf38 --- /dev/null +++ b/src/components/views/settings/tabs/user/MediaPreviewAccountSettings.tsx @@ -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>( + (event) => { + if (!event.target.checked) { + return; + } + changeSetting({ + ...currentMediaPreview, + media_previews: MediaPreviewValue.Off, + }); + }, + [changeSetting, currentMediaPreview], + ); + + const mediaPreviewOnChangePrivate = useCallback>( + (event) => { + if (!event.target.checked) { + return; + } + changeSetting({ + ...currentMediaPreview, + media_previews: MediaPreviewValue.Private, + }); + }, + [changeSetting, currentMediaPreview], + ); + + const mediaPreviewOnChangeOn = useCallback>( + (event) => { + if (!event.target.checked) { + return; + } + changeSetting({ + ...currentMediaPreview, + media_previews: MediaPreviewValue.On, + }); + }, + [changeSetting, currentMediaPreview], + ); + + return ( + + {!roomId && ( + + )} + {/* Explict label here because htmlFor is not supported for linking to radiogroups */} + + + + {_t("settings|media_preview|media_preview_description")} + + + } + > + + + {!roomId && ( + + } + > + + + )} + + } + > + + + + + ); +}; diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index e7929348d6..fa72cfe28e 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -32,6 +32,7 @@ import SpellCheckSettings from "../../SpellCheckSettings"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import * as TimezoneHandler from "../../../../../TimezoneHandler"; import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx"; +import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx"; interface IProps { closeSettingsFn(success: boolean): void; @@ -116,7 +117,7 @@ const SpellCheckSection: React.FC = () => { }; export default class PreferencesUserSettingsTab extends React.Component { - private static ROOM_LIST_SETTINGS: BooleanSettingKey[] = ["breadcrumbs", "showAvatarsOnInvites"]; + private static ROOM_LIST_SETTINGS: BooleanSettingKey[] = ["breadcrumbs"]; private static SPACES_SETTINGS: BooleanSettingKey[] = ["Spaces.allRoomsInHome"]; @@ -146,7 +147,6 @@ export default class PreferencesUserSettingsTab extends React.Component + + + + {this.renderGroup(PreferencesUserSettingsTab.ROOM_DIRECTORY_SETTINGS)} diff --git a/src/hooks/useMediaVisible.ts b/src/hooks/useMediaVisible.ts index e244d500cf..f367e87c4f 100644 --- a/src/hooks/useMediaVisible.ts +++ b/src/hooks/useMediaVisible.ts @@ -6,30 +6,52 @@ Please see LICENSE files in the repository root for full details. */ import { useCallback } from "react"; +import { JoinRule } from "matrix-js-sdk/src/matrix"; import { SettingLevel } from "../settings/SettingLevel"; import { useSettingValue } from "./useSettings"; import SettingsStore from "../settings/SettingsStore"; +import { useMatrixClientContext } from "../contexts/MatrixClientContext"; +import { MediaPreviewValue } from "../@types/media_preview"; +import { useRoomState } from "./useRoomState"; + +const PRIVATE_JOIN_RULES: JoinRule[] = [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted]; /** * Should the media event be visible in the client, or hidden. * @param eventId The eventId of the media event. * @returns A boolean describing the hidden status, and a function to set the visiblity. */ -export function useMediaVisible(eventId: string): [boolean, (visible: boolean) => void] { - const defaultShowImages = useSettingValue("showImages", SettingLevel.DEVICE); - const eventVisibility = useSettingValue("showMediaEventIds", SettingLevel.DEVICE); +export function useMediaVisible(eventId?: string, roomId?: string): [boolean, (visible: boolean) => void] { + const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId); + const client = useMatrixClientContext(); + const eventVisibility = useSettingValue("showMediaEventIds"); + const joinRule = useRoomState(client.getRoom(roomId) ?? undefined, (state) => state.getJoinRule()); const setMediaVisible = useCallback( (visible: boolean) => { SettingsStore.setValue("showMediaEventIds", null, SettingLevel.DEVICE, { ...eventVisibility, - [eventId]: visible, + [eventId!]: visible, }); }, [eventId, eventVisibility], ); + const roomIsPrivate = joinRule ? PRIVATE_JOIN_RULES.includes(joinRule) : false; + + const explicitEventVisiblity = eventId ? eventVisibility[eventId] : undefined; // Always prefer the explicit per-event user preference here. - const imgIsVisible = eventVisibility[eventId] ?? defaultShowImages; - return [imgIsVisible, setMediaVisible]; + if (explicitEventVisiblity !== undefined) { + return [explicitEventVisiblity, setMediaVisible]; + } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Off) { + return [false, setMediaVisible]; + } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.On) { + return [true, setMediaVisible]; + } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Private) { + return [roomIsPrivate, setMediaVisible]; + } else { + // Invalid setting. + console.warn("Invalid media visibility setting", mediaPreviewSetting.media_previews); + return [false, setMediaVisible]; + } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1fb2294eb6..9dcf5aa21d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -525,6 +525,7 @@ "message_timestamp_invalid": "Invalid timestamp", "microphone": "Microphone", "model": "Model", + "moderation_and_safety": "Moderation and safety", "modern": "Modern", "mute": "Mute", "n_members": { @@ -2668,12 +2669,10 @@ "unable_to_load_msisdns": "Unable to load phone numbers", "username": "Username" }, - "image_thumbnails": "Show previews/thumbnails for images", "inline_url_previews_default": "Enable inline URL previews by default", "inline_url_previews_room": "Enable URL previews by default for participants in this room", "inline_url_previews_room_account": "Enable URL previews for this room (only affects you)", "insert_trailing_colon_mentions": "Insert a trailing colon after user mentions at the start of a message", - "invite_avatars": "Show avatars of rooms you have been invited to", "jump_to_bottom_on_send": "Jump to the bottom of the timeline when you send a message", "key_backup": { "backup_in_progress": "Your keys are being backed up (the first backup could take a few minutes).", @@ -2732,6 +2731,14 @@ "labs_mjolnir": { "dialog_title": "Settings: Ignored Users" }, + "media_preview": { + "hide_avatars": "Hide avatars of room and inviter", + "hide_media": "Always hide", + "media_preview_description": "A hidden media can always be shown by tapping on it", + "media_preview_label": "Show media in timeline", + "show_in_private": "In private rooms", + "show_media": "Always show" + }, "notifications": { "default_setting_description": "This setting will be applied by default to all your rooms.", "default_setting_section": "I want to be notified for (Default Setting)", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index aee9141c29..36030713ec 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactNode } from "react"; import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; +import { type MediaPreviewConfig } from "../@types/media_preview.ts"; import { _t, _td, type TranslationKey } from "../languageHandler"; import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts"; import { @@ -45,6 +46,7 @@ import { type Json, type JsonValue } from "../@types/json.ts"; import { type RecentEmojiData } from "../emojipicker/recent.ts"; import { type Assignable } from "../@types/common.ts"; import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts"; +import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts"; export const defaultWatchManager = new WatchManager(); @@ -312,8 +314,6 @@ export interface Settings { "showHiddenEventsInTimeline": IBaseSetting; "lowBandwidth": IBaseSetting; "fallbackICEServerAllowed": IBaseSetting; - "showImages": IBaseSetting; - "showAvatarsOnInvites": IBaseSetting; "RoomList.preferredSorting": IBaseSetting; "RoomList.showMessagePreview": IBaseSetting; "RightPanel.phasesGlobal": IBaseSetting; @@ -349,6 +349,7 @@ export interface Settings { "Electron.alwaysShowMenuBar": IBaseSetting; "Electron.showTrayIcon": IBaseSetting; "Electron.enableHardwareAcceleration": IBaseSetting; + "mediaPreviewConfig": IBaseSetting; "Developer.elementCallUrl": IBaseSetting; } @@ -427,6 +428,11 @@ export const SETTINGS: Settings = { supportedLevelsAreOrdered: true, default: false, }, + "mediaPreviewConfig": { + controller: new MediaPreviewConfigController(), + supportedLevels: LEVELS_ROOM_SETTINGS, + default: MediaPreviewConfigController.default, + }, "feature_report_to_moderators": { isFeature: true, labsGroup: LabGroup.Moderation, @@ -1123,16 +1129,6 @@ export const SETTINGS: Settings = { default: null, controller: new FallbackIceServerController(), }, - "showImages": { - supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td("settings|image_thumbnails"), - default: true, - }, - "showAvatarsOnInvites": { - supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td("settings|invite_avatars"), - default: true, - }, "RoomList.preferredSorting": { supportedLevels: [SettingLevel.DEVICE], default: SortingAlgorithm.Recency, @@ -1386,7 +1382,6 @@ export const SETTINGS: Settings = { displayName: _td("settings|preferences|enable_hardware_acceleration"), default: true, }, - "Developer.elementCallUrl": { supportedLevels: [SettingLevel.DEVICE], displayName: _td("devtools|settings|elementCallUrl"), diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 7dac777399..17859f1b3f 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -38,6 +38,7 @@ import { Action } from "../dispatcher/actions"; import PlatformSettingsHandler from "./handlers/PlatformSettingsHandler"; import ReloadOnChangeController from "./controllers/ReloadOnChangeController"; import { MatrixClientPeg } from "../MatrixClientPeg"; +import { MediaPreviewValue } from "../@types/media_preview"; // Convert the settings to easier to manage objects for the handlers const defaultSettings: Record = {}; @@ -715,6 +716,29 @@ export default class SettingsStore { localStorage.setItem(MIGRATION_DONE_FLAG, "true"); } + /** + * Migrate the setting for visible images to a setting. + */ + private static migrateMediaControlsToSetting(): void { + const MIGRATION_DONE_FLAG = "mx_migrate_media_controls"; + if (localStorage.getItem(MIGRATION_DONE_FLAG)) return; + + logger.info("Performing one-time settings migration of show images and invite avatars to account data"); + const handler = LEVEL_HANDLERS[SettingLevel.ACCOUNT]; + const showImages = handler.getValue("showImages", null); + const showAvatarsOnInvites = handler.getValue("showAvatarsOnInvites", null); + + const AccountHandler = LEVEL_HANDLERS[SettingLevel.ACCOUNT]; + if (showImages !== null || showAvatarsOnInvites !== null) { + AccountHandler.setValue("mediaPreviewConfig", null, { + invite_avatars: showAvatarsOnInvites === false ? MediaPreviewValue.Off : MediaPreviewValue.On, + media_previews: showImages === false ? MediaPreviewValue.Off : MediaPreviewValue.On, + }); + } // else, we don't set anything and use the server value + + localStorage.setItem(MIGRATION_DONE_FLAG, "true"); + } + /** * Runs or queues any setting migrations needed. */ @@ -732,6 +756,12 @@ export default class SettingsStore { // will now be hidden again, so this fails safely. SettingsStore.migrateShowImagesToSettings(); + // This can be removed once enough users have run a version of Element with + // this migration. + // The consequences of missing the migration are that the previously set + // media controls for this user will be missing + SettingsStore.migrateMediaControlsToSetting(); + // Dev notes: to add your migration, just add a new `migrateMyFeature` function, call it, and // add a comment to note when it can be removed. return; diff --git a/src/settings/controllers/MatrixClientBackedController.ts b/src/settings/controllers/MatrixClientBackedController.ts index c7816222d5..b305ad5fa2 100644 --- a/src/settings/controllers/MatrixClientBackedController.ts +++ b/src/settings/controllers/MatrixClientBackedController.ts @@ -26,7 +26,7 @@ export default abstract class MatrixClientBackedController extends SettingContro MatrixClientBackedController._matrixClient = client; for (const instance of MatrixClientBackedController.instances) { - instance.initMatrixClient(client, oldClient); + instance.initMatrixClient?.(client, oldClient); } } @@ -40,5 +40,5 @@ export default abstract class MatrixClientBackedController extends SettingContro return MatrixClientBackedController._matrixClient; } - protected abstract initMatrixClient(newClient: MatrixClient, oldClient?: MatrixClient): void; + protected initMatrixClient?(newClient: MatrixClient, oldClient?: MatrixClient): void; } diff --git a/src/settings/controllers/MediaPreviewConfigController.ts b/src/settings/controllers/MediaPreviewConfigController.ts new file mode 100644 index 0000000000..cb8b9b34aa --- /dev/null +++ b/src/settings/controllers/MediaPreviewConfigController.ts @@ -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 { + 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() ?? {}; + + 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 { + 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; + } +} diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index 6afd560867..3536169c59 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2017 Travis Ralston @@ -15,6 +15,7 @@ import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandl import { objectClone, objectKeyChanges } from "../../utils/objects"; import { SettingLevel } from "../SettingLevel"; import { type WatchManager } from "../WatchManager"; +import { MEDIA_PREVIEW_ACCOUNT_DATA_TYPE } from "../../@types/media_preview"; const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms"; const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs"; @@ -68,6 +69,8 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa } else if (event.getType() === RECENT_EMOJI_EVENT_TYPE) { const val = event.getContent()["enabled"]; this.watchers.notifyUpdate("recent_emoji", null, SettingLevel.ACCOUNT, val); + } else if (event.getType() === MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) { + this.watchers.notifyUpdate("mediaPreviewConfig", null, SettingLevel.ROOM_ACCOUNT, event.getContent()); } }; @@ -173,7 +176,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa await deferred.promise; } - public setValue(settingName: string, roomId: string, newValue: any): Promise { + public async setValue(settingName: string, roomId: string, newValue: any): Promise { switch (settingName) { // Special case URL previews case "urlPreviewsEnabled": @@ -199,7 +202,9 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa // Special case analytics case "pseudonymousAnalyticsOptIn": return this.setAccountData(ANALYTICS_EVENT_TYPE, "pseudonymousAnalyticsOptIn", newValue); - + case "mediaPreviewConfig": + // Handled in MediaPreviewConfigController. + return; default: return this.setAccountData(DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); } diff --git a/src/settings/handlers/RoomAccountSettingsHandler.ts b/src/settings/handlers/RoomAccountSettingsHandler.ts index 045ef5cfe0..e98cb3725d 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.ts +++ b/src/settings/handlers/RoomAccountSettingsHandler.ts @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2017 Travis Ralston @@ -14,6 +14,7 @@ import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandl import { objectClone, objectKeyChanges } from "../../utils/objects"; import { SettingLevel } from "../SettingLevel"; import { type WatchManager } from "../WatchManager"; +import { MEDIA_PREVIEW_ACCOUNT_DATA_TYPE } from "../../@types/media_preview"; const ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets"; const DEFAULT_SETTINGS_EVENT_TYPE = "im.vector.web.settings"; @@ -56,6 +57,8 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin } } else if (event.getType() === ALLOWED_WIDGETS_EVENT_TYPE) { this.watchers.notifyUpdate("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); + } else if (event.getType() === MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) { + this.watchers.notifyUpdate("mediaPreviewConfig", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); } }; @@ -108,7 +111,7 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin await deferred.promise; } - public setValue(settingName: string, roomId: string, newValue: any): Promise { + public async setValue(settingName: string, roomId: string, newValue: any): Promise { switch (settingName) { // Special case URL previews case "urlPreviewsEnabled": @@ -117,7 +120,9 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin // Special case allowed widgets case "allowedWidgets": return this.setRoomAccountData(roomId, ALLOWED_WIDGETS_EVENT_TYPE, null, newValue); - + case "mediaPreviewConfig": + // Handled in MediaPreviewConfigController. + return; default: return this.setRoomAccountData(roomId, DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); } diff --git a/test/unit-tests/HtmlUtils-test.tsx b/test/unit-tests/HtmlUtils-test.tsx index 16546e69dc..1dbd9cabda 100644 --- a/test/unit-tests/HtmlUtils-test.tsx +++ b/test/unit-tests/HtmlUtils-test.tsx @@ -12,6 +12,7 @@ import parse from "html-react-parser"; import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils"; import SettingsStore from "../../src/settings/SettingsStore"; +import { getMockClientWithEventEmitter } from "../test-utils"; import { SettingLevel } from "../../src/settings/SettingLevel"; import SdkConfig from "../../src/SdkConfig"; @@ -231,6 +232,37 @@ describe("bodyToNode", () => { 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": `foo Hello there`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$eventId", + }, + }, + "msgtype": "m.text", + }, + [], + { + mediaIsVisible, + }, + ); + + const { asFragment } = render( + , + ); + 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(() => { jest.resetAllMocks(); }); diff --git a/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap b/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap index 09ab44bfcb..018a6721c1 100644 --- a/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap +++ b/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap @@ -64,3 +64,30 @@ exports[`bodyToNode should generate big emoji for an emoji-only reply to a messa `; + +exports[`bodyToNode should handle inline media when mediaIsVisible is false 1`] = ` + + + + foo Hello there + + +`; + +exports[`bodyToNode should handle inline media when mediaIsVisible is true 1`] = ` + + + + foo Hello there + + +`; diff --git a/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx b/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx index 26c215bf19..1c8db39040 100644 --- a/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx +++ b/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx @@ -17,6 +17,7 @@ import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { LocalRoom } from "../../../../../src/models/LocalRoom"; import * as AvatarModule from "../../../../../src/Avatar"; import { DirectoryMember } from "../../../../../src/utils/direct-messages"; +import { MediaPreviewValue } from "../../../../../src/@types/media_preview"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../../src/settings/SettingLevel"; @@ -37,18 +38,18 @@ describe("RoomAvatar", () => { }); afterAll(() => { + SettingsStore.setValue( + "mediaPreviewConfig", + null, + SettingLevel.ACCOUNT, + SettingsStore.getDefaultValue("mediaPreviewConfig"), + ); jest.restoreAllMocks(); }); afterEach(() => { mocked(DMRoomMap.shared().getUserIdForRoomId).mockReset(); mocked(AvatarModule.defaultAvatarUrlForString).mockClear(); - SettingsStore.setValue( - "showAvatarsOnInvites", - null, - SettingLevel.ACCOUNT, - SettingsStore.getDefaultValue("showAvatarsOnInvites"), - ); }); it("should render as expected for a Room", () => { @@ -74,7 +75,6 @@ describe("RoomAvatar", () => { expect(render().container).toMatchSnapshot(); }); 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()); jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar"); room.name = "test room"; @@ -93,7 +93,9 @@ describe("RoomAvatar", () => { expect(render().container).toMatchSnapshot(); }); 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()); room.name = "test room"; room.updateMyMembership("invite"); diff --git a/test/unit-tests/components/views/messages/HideActionButton-test.tsx b/test/unit-tests/components/views/messages/HideActionButton-test.tsx index 57e92b02b8..650afe69e6 100644 --- a/test/unit-tests/components/views/messages/HideActionButton-test.tsx +++ b/test/unit-tests/components/views/messages/HideActionButton-test.tsx @@ -8,20 +8,20 @@ Please see LICENSE files in the repository root for full details. import React from "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 SettingsStore from "../../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../../src/settings/SettingLevel"; 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( - showImages: Settings["showImages"]["default"], - showMediaEventIds: Settings["showMediaEventIds"]["default"], -) { +function mockSetting(mediaPreviews: MediaPreviewValue, showMediaEventIds: Settings["showMediaEventIds"]["default"]) { jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => { - if (settingName === "showImages") { - return showImages; + if (settingName === "mediaPreviewConfig") { + return { media_previews: mediaPreviews, invite_avatars: MediaPreviewValue.Off }; } else if (settingName === "showMediaEventIds") { return showMediaEventIds; } @@ -29,8 +29,10 @@ function mockSetting( }); } +const EVENT_ID = "$foo:bar"; + const event = new MatrixEvent({ - event_id: "$foo:bar", + event_id: EVENT_ID, room_id: "!room:id", sender: "@user:id", type: "m.room.message", @@ -42,32 +44,38 @@ const event = new MatrixEvent({ }); describe("HideActionButton", () => { + let cli: MockedObject; + beforeEach(() => { + cli = getMockClientWithEventEmitter({ + getRoom: jest.fn(), + }); + }); afterEach(() => { jest.restoreAllMocks(); }); it("should show button when event is visible by showMediaEventIds setting", async () => { - mockSetting(false, { "$foo:bar": true }); - render(); + mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: true }); + render(, withClientContextRenderOptions(cli)); expect(screen.getByRole("button")).toBeVisible(); }); - it("should show button when event is visible by showImages setting", async () => { - mockSetting(true, {}); - render(); + it("should show button when event is visible by mediaPreviewConfig setting", async () => { + mockSetting(MediaPreviewValue.On, {}); + render(, withClientContextRenderOptions(cli)); expect(screen.getByRole("button")).toBeVisible(); }); it("should hide button when event is hidden by showMediaEventIds setting", async () => { - jest.spyOn(SettingsStore, "getValue").mockReturnValue({ "$foo:bar": false }); - render(); + mockSetting(MediaPreviewValue.Off, { [EVENT_ID]: false }); + render(, withClientContextRenderOptions(cli)); expect(screen.queryByRole("button")).toBeNull(); }); it("should hide button when event is hidden by showImages setting", async () => { - mockSetting(false, {}); - render(); + mockSetting(MediaPreviewValue.Off, {}); + render(, withClientContextRenderOptions(cli)); expect(screen.queryByRole("button")).toBeNull(); }); it("should store event as hidden when clicked", async () => { const spy = jest.spyOn(SettingsStore, "setValue"); - render(); + render(, withClientContextRenderOptions(cli)); fireEvent.click(screen.getByRole("button")); expect(spy).toHaveBeenCalledWith("showMediaEventIds", null, SettingLevel.DEVICE, { "$foo:bar": false }); // Button should be hidden after the setting is set. diff --git a/test/unit-tests/components/views/messages/MImageBody-test.tsx b/test/unit-tests/components/views/messages/MImageBody-test.tsx index b46ae29f3b..edda0d3add 100644 --- a/test/unit-tests/components/views/messages/MImageBody-test.tsx +++ b/test/unit-tests/components/views/messages/MImageBody-test.tsx @@ -1,12 +1,12 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. 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 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 { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import fetchMock from "fetch-mock-jest"; @@ -24,10 +24,11 @@ import { mockClientMethodsDevice, mockClientMethodsServer, mockClientMethodsUser, + withClientContextRenderOptions, } from "../../../../test-utils"; import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { SettingLevel } from "../../../../../src/settings/SettingLevel"; +import { MediaPreviewValue } from "../../../../../src/@types/media_preview"; jest.mock("matrix-encrypt-attachment", () => ({ decryptAttachment: jest.fn(), @@ -42,6 +43,7 @@ describe("", () => { ...mockClientMethodsDevice(deviceId), ...mockClientMethodsCrypto(), getRooms: jest.fn().mockReturnValue([]), + getRoom: jest.fn(), getIgnoredUsers: jest.fn(), getVersions: jest.fn().mockResolvedValue({ unstable_features: { @@ -85,6 +87,7 @@ describe("", () => { }); afterEach(() => { + SettingsStore.reset(); mocked(encrypt.decryptAttachment).mockReset(); }); @@ -97,6 +100,7 @@ describe("", () => { mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, + withClientContextRenderOptions(cli), ); // thumbnail with dimensions present @@ -112,6 +116,7 @@ describe("", () => { mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, + withClientContextRenderOptions(cli), ); expect(fetchMock).toHaveBeenCalledWith(url); @@ -129,6 +134,7 @@ describe("", () => { mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, + withClientContextRenderOptions(cli), ); await screen.findByText("Error decrypting image"); @@ -136,25 +142,12 @@ describe("", () => { describe("with image previews/thumbnails disabled", () => { beforeEach(() => { - act(() => { - SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false); - }); - }); - - afterEach(() => { - act(() => { - SettingsStore.setValue( - "showImages", - null, - SettingLevel.DEVICE, - SettingsStore.getDefaultValue("showImages"), - ); - SettingsStore.setValue( - "showMediaEventIds", - null, - SettingLevel.DEVICE, - SettingsStore.getDefaultValue("showMediaEventIds"), - ); + const origFn = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => { + if (setting === "mediaPreviewConfig") { + return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off }; + } + return origFn(setting, ...args); }); }); @@ -167,6 +160,7 @@ describe("", () => { mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, + withClientContextRenderOptions(cli), ); expect(screen.getByText("Show image")).toBeInTheDocument(); @@ -183,6 +177,7 @@ describe("", () => { mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, + withClientContextRenderOptions(cli), ); expect(screen.getByText("Show image")).toBeInTheDocument(); @@ -220,6 +215,7 @@ describe("", () => { const { container } = render( , + withClientContextRenderOptions(cli), ); const img = container.querySelector(".mx_MImageBody_thumbnail")!; @@ -273,6 +269,7 @@ describe("", () => { const { container } = render( , + withClientContextRenderOptions(cli), ); // Wait for spinners to go away @@ -298,6 +295,7 @@ describe("", () => { const { container } = render( , + withClientContextRenderOptions(cli), ); const img = container.querySelector(".mx_MImageBody_thumbnail")!; diff --git a/test/unit-tests/components/views/messages/MStickerBody-test.tsx b/test/unit-tests/components/views/messages/MStickerBody-test.tsx index d78f797dea..3ae44215d1 100644 --- a/test/unit-tests/components/views/messages/MStickerBody-test.tsx +++ b/test/unit-tests/components/views/messages/MStickerBody-test.tsx @@ -19,6 +19,7 @@ import { mockClientMethodsDevice, mockClientMethodsServer, mockClientMethodsUser, + withClientContextRenderOptions, } from "../../../../test-utils"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import MStickerBody from "../../../../../src/components/views/messages/MStickerBody"; @@ -31,6 +32,7 @@ describe("", () => { ...mockClientMethodsServer(), ...mockClientMethodsDevice(deviceId), ...mockClientMethodsCrypto(), + getRoom: jest.fn(), getRooms: jest.fn().mockReturnValue([]), getIgnoredUsers: jest.fn(), getVersions: jest.fn().mockResolvedValue({ @@ -76,7 +78,7 @@ describe("", () => { it("should show a tooltip on hover", async () => { fetchMock.getOnce(url, { status: 200 }); - render(); + render(, withClientContextRenderOptions(cli)); expect(screen.queryByRole("tooltip")).toBeNull(); await userEvent.hover(screen.getByRole("img")); diff --git a/test/unit-tests/components/views/messages/MVideoBody-test.tsx b/test/unit-tests/components/views/messages/MVideoBody-test.tsx index dd916e8de0..1d058a7b0c 100644 --- a/test/unit-tests/components/views/messages/MVideoBody-test.tsx +++ b/test/unit-tests/components/views/messages/MVideoBody-test.tsx @@ -1,15 +1,16 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. 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 Please see LICENSE files in the repository root for full details. */ -import React, { act } from "react"; -import { EventType, getHttpUriForMxc, type IContent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React from "react"; +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 fetchMock from "fetch-mock-jest"; +import { type MockedObject } from "jest-mock"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { type RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; @@ -20,11 +21,12 @@ import { mockClientMethodsDevice, mockClientMethodsServer, mockClientMethodsUser, + withClientContextRenderOptions, } from "../../../../test-utils"; import MVideoBody from "../../../../../src/components/views/messages/MVideoBody"; import type { IBodyProps } from "../../../../../src/components/views/messages/IBodyProps"; -import { SettingLevel } from "../../../../../src/settings/SettingLevel"; 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. jest.mock("matrix-encrypt-attachment", () => ({ @@ -36,13 +38,15 @@ describe("MVideoBody", () => { const deviceId = "DEADB33F"; const thumbUrl = "https://server/_matrix/media/v3/download/server/encrypted-poster"; + let cli: MockedObject; beforeEach(() => { - const cli = getMockClientWithEventEmitter({ + cli = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsServer(), ...mockClientMethodsDevice(deviceId), ...mockClientMethodsCrypto(), + getRoom: jest.fn(), getRooms: jest.fn().mockReturnValue([]), getIgnoredUsers: jest.fn(), getVersions: jest.fn().mockResolvedValue({ @@ -65,6 +69,7 @@ describe("MVideoBody", () => { room_id: "!room:server", sender: userId, type: EventType.RoomMessage, + event_id: "$foo:bar", content: { body: "alt for a test video", info: { @@ -93,32 +98,25 @@ describe("MVideoBody", () => { fetchMock.getOnce(thumbUrl, { status: 200 }); const { asFragment } = render( , + withClientContextRenderOptions(cli), ); expect(asFragment()).toMatchSnapshot(); }); describe("with video previews/thumbnails disabled", () => { beforeEach(() => { - act(() => { - SettingsStore.setValue("showImages", null, SettingLevel.DEVICE, false); + const origFn = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => { + if (setting === "mediaPreviewConfig") { + return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off }; + } + return origFn(setting, ...args); }); }); afterEach(() => { - act(() => { - SettingsStore.setValue( - "showImages", - null, - SettingLevel.DEVICE, - SettingsStore.getDefaultValue("showImages"), - ); - SettingsStore.setValue( - "showMediaEventIds", - null, - SettingLevel.DEVICE, - SettingsStore.getDefaultValue("showMediaEventIds"), - ); - }); + SettingsStore.reset(); + jest.restoreAllMocks(); }); it("should not download video", async () => { @@ -129,6 +127,7 @@ describe("MVideoBody", () => { mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, + withClientContextRenderOptions(cli), ); expect(screen.getByText("Show video")).toBeInTheDocument(); @@ -144,6 +143,7 @@ describe("MVideoBody", () => { mxEvent={encryptedMediaEvent} mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)} />, + withClientContextRenderOptions(cli), ); const placeholderButton = screen.getByRole("button", { name: "Show video" }); @@ -191,6 +191,7 @@ function makeMVideoBody(w: number, h: number): RenderResult { const mockClient = getMockClientWithEventEmitter({ mxcUrlToHttp: jest.fn(), + getRoom: jest.fn(), }); return render( diff --git a/test/unit-tests/components/views/rooms/RoomPreviewCard-test.tsx b/test/unit-tests/components/views/rooms/RoomPreviewCard-test.tsx index 375366e8f9..e12fabeb3f 100644 --- a/test/unit-tests/components/views/rooms/RoomPreviewCard-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomPreviewCard-test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. 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 @@ -44,9 +44,13 @@ describe("RoomPreviewCard", () => { client.reEmitter.reEmit(room, [RoomStateEvent.Events]); enabledFeatures = []; - jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName): any => - enabledFeatures.includes(settingName) ? true : undefined, - ); + const origFn = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName): any => { + if (enabledFeatures.includes(settingName)) { + return true; + } + return origFn(settingName); + }); }); afterEach(() => { diff --git a/test/unit-tests/components/views/settings/tabs/user/MediaPreviewAccountSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/MediaPreviewAccountSettingsTab-test.tsx new file mode 100644 index 0000000000..e5946c070d --- /dev/null +++ b/test/unit-tests/components/views/settings/tabs/user/MediaPreviewAccountSettingsTab-test.tsx @@ -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; + 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(); + // 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(); + // 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, + }); + } + return undefined; + }); + } + const { getByLabelText } = render(); + + 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); + }); +}); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap index 34d89172df..3e3662bd68 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap @@ -95,33 +95,6 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` />
-
- -
-
-
-
+
+ +
- - Always show message timestamps - - -
-
-
-
-
-
+
+ +
- Enable Emoji suggestions while typing + Use Ctrl + Enter to send a message
- Use Ctrl + Enter to send a message + Surround selected text when typing special characters
- Surround selected text when typing special characters + Show stickers button
- - Show stickers button - - -
-
-
-
-
-
+
+ +
- - Expand code blocks by default - - -
-
-
-
-
-
+
+ +
- - Autoplay GIFs - - -
-
-
-
-
-
-
- -
@@ -903,7 +849,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` >
+
+ +
+
+
+
+
+ +
- Show a placeholder for removed messages + Show join/leave messages (invites/removes/bans unaffected)
- Show read receipts sent by other users + Show display name changes
- Show join/leave messages (invites/removes/bans unaffected) + Show chat effects (animations when receiving e.g. confetti)
- Show display name changes + Show profile picture changes
- Show chat effects (animations when receiving e.g. confetti) + Show avatars in user, room and event mentions
- Show profile picture changes + Enable big emoji in chat
- Show avatars in user, room and event mentions + Jump to the bottom of the timeline when you send a message
- Enable big emoji in chat + Show current profile picture and name for users in message history
-
- -
-
-
-
-
- -
-
-
-
+
+
+

+ Moderation and safety +

+
+
+
+
+ +
+ Hide avatars of room and inviter +
+
+
+
+
+
+
+ + + A hidden media can always be shown by tapping on it + +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
@@ -1217,7 +1325,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` >