diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index 92b76c4c4d..b6d1315b8d 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 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,8 +14,12 @@ 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 { 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" { export interface FileInfo { /** @@ -59,7 +63,6 @@ declare module "matrix-js-sdk/src/types" { }; }; } - export interface AccountDataEvents { // Analytics account data event "im.vector.analytics": { @@ -87,6 +90,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..eaa110874c --- /dev/null +++ b/src/@types/media_preview.ts @@ -0,0 +1,10 @@ +export enum MediaPreviewValue { + On = "on", + Private = "private", + Off = "off" +} + +export interface MediaPreviewConfig extends Record { + media_previews: MediaPreviewValue, + invite_avatars: MediaPreviewValue, +} \ No newline at end of file diff --git a/src/Linkify.tsx b/src/Linkify.tsx index 27dd4783be..6738c624a0 100644 --- a/src/Linkify.tsx +++ b/src/Linkify.tsx @@ -16,6 +16,7 @@ import SettingsStore from "./settings/SettingsStore"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { mediaFromMxc } from "./customisations/Media"; import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils"; +import { MediaPreviewValue } from "./@types/media_preview"; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; @@ -50,7 +51,8 @@ export const transformTags: NonNullable = { // 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")) { + // TODO: Is this a private room? + if (!src || SettingsStore.getValue("mediaPreviewConfig").media_previews !== MediaPreviewValue.On ) { return { tagName, attribs: {} }; } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index e1e3e9157d..1a76274dd5 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -27,6 +27,7 @@ import { type IOOBData } from "../../../stores/ThreepidInviteStore"; import { LocalRoom } from "../../../models/LocalRoom"; import { filterBoolean } from "../../../utils/arrays"; import SettingsStore from "../../../settings/SettingsStore"; +import { MediaPreviewValue } from "../../../@types/media_preview"; interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, @@ -96,7 +97,8 @@ export default class RoomAvatar extends React.Component { private static getImageUrls(props: IProps): string[] { const myMembership = props.room?.getMyMembership(); if (myMembership === KnownMembership.Invite || !myMembership) { - if (SettingsStore.getValue("showAvatarsOnInvites") === false) { + + if (SettingsStore.getValue("mediaPreviewConfig", props.room?.roomId).invite_avatars !== MediaPreviewValue.On) { // The user has opted out of showing avatars, so return no urls here. return []; } diff --git a/src/components/views/rooms/LinkPreviewWidget.tsx b/src/components/views/rooms/LinkPreviewWidget.tsx index 9b381ede7d..417087efb5 100644 --- a/src/components/views/rooms/LinkPreviewWidget.tsx +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -18,6 +18,7 @@ import { mediaFromMxc } from "../../../customisations/Media"; import ImageView from "../elements/ImageView"; import LinkWithTooltip from "../elements/LinkWithTooltip"; import PlatformPeg from "../../../PlatformPeg"; +import { MediaPreviewValue } from "../../../@types/media_preview"; interface IProps { link: string; @@ -69,7 +70,8 @@ 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")) { + // HSTODO: Private rooms? + if (!SettingsStore.getValue("mediaPreviewConfig", this.props.mxEvent.getRoomId()).media_previews !== MediaPreviewValue.On) { 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/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 11b889304a..a9f4f1dba1 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -116,7 +116,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 +146,6 @@ export default class PreferencesUserSettingsTab extends React.Component void] { const mediaPreviewSetting = useSettingValue("mediaPreviewConfig", roomId); - // const defaultShowImages = useSettingValue("showImages"); const client = useMatrixClientContext(); const eventVisibility = useSettingValue("showMediaEventIds"); const setMediaVisible = useCallback( @@ -38,10 +38,10 @@ export function useMediaVisible(eventId: string, roomId: string): [boolean, (vis // Always prefer the explicit per-event user preference here. if (eventVisibility[eventId]) { return [true, setMediaVisible]; - } else if (mediaPreviewSetting === MediaPreviewConfig.Off) { - return [false, setMediaVisible]; - } else if (mediaPreviewSetting === MediaPreviewConfig.On) { + } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.Off) { return [false, setMediaVisible]; + } else if (mediaPreviewSetting.media_previews === MediaPreviewValue.On) { + return [true, setMediaVisible]; } const joinRule = client.getRoom(roomId)?.getJoinRule(); if (PRIVATE_JOIN_RULES.includes(joinRule as JoinRule)) { @@ -51,5 +51,4 @@ export function useMediaVisible(eventId: string, roomId: string): [boolean, (vis console.log("Room is probably public"); return [false, setMediaVisible]; } - } diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index f2dfc632ee..bd833a1b1e 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -8,7 +8,8 @@ Please see LICENSE files in the repository root for full details. */ import React, { type ReactNode } from "react"; -import { MediaPreviewConfig, UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; +import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; +import { MediaPreviewConfig } from "../@types/media_preview.ts"; import { _t, _td, type TranslationKey } from "../languageHandler"; import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts"; @@ -313,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; @@ -432,7 +431,7 @@ export const SETTINGS: Settings = { "mediaPreviewConfig": { controller: new MediaPreviewConfigController(), supportedLevels: LEVELS_ROOM_OR_ACCOUNT, - default: MediaPreviewConfig.Private, + default: MediaPreviewConfigController.default, }, "feature_report_to_moderators": { isFeature: true, @@ -1130,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, diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 7dac777399..ede37f1240 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); + + if (showImages !== null || showAvatarsOnInvites !== null) { + this.setValue("mediaPreviewConfig", null, SettingLevel.ACCOUNT, { + 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/MediaPreviewConfigController.ts b/src/settings/controllers/MediaPreviewConfigController.ts index 577191fb24..0e2b7ac120 100644 --- a/src/settings/controllers/MediaPreviewConfigController.ts +++ b/src/settings/controllers/MediaPreviewConfigController.ts @@ -5,33 +5,41 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ClientEvent, MatrixEvent, MediaPreviewConfig, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { AccountDataEvents } from "matrix-js-sdk/src/types"; +import { MediaPreviewConfig, MediaPreviewValue } from "../../@types/media_preview.ts"; import { SettingLevel } from "../SettingLevel.ts"; import MatrixClientBackedController from "./MatrixClientBackedController.ts"; -const CLIENT_KEY = "m.media_preview_config"; +const CLIENT_KEY = "io.element.msc4278.media_preview_config"; /** * TODO */ export default class MediaPreviewConfigController extends MatrixClientBackedController { - private globalSetting: MediaPreviewConfig = MediaPreviewConfig.Private; + + public static readonly default: AccountDataEvents["io.element.msc4278.media_preview_config"] = { + media_previews: MediaPreviewValue.On, + invite_avatars: MediaPreviewValue.On + } + + private globalSetting: MediaPreviewConfig = MediaPreviewConfigController.default; public constructor() { super(); } private getRoomValue = (roomId: string): MediaPreviewConfig|null => { - return this.client?.getRoom(roomId)?.getAccountData(CLIENT_KEY)?.getContent().value ?? null; + return this.client?.getRoom(roomId)?.getAccountData(CLIENT_KEY)?.getContent() ?? null; } private onAccountData = (event: MatrixEvent): void => { // TODO: Validate. const roomId = event.getRoomId(); if (!roomId) { - this.globalSetting = event.getContent().value; + this.globalSetting = event.getContent(); } }; @@ -56,13 +64,11 @@ export default class MediaPreviewConfigController extends MatrixClientBackedCont public onChange(_level: SettingLevel, roomId: string | null, newValue: MediaPreviewConfig): void { if (roomId) { - this.client?.setRoomAccountData(roomId, "m.media_preview_config", { + this.client?.setRoomAccountData(roomId, CLIENT_KEY, { value: newValue }); return; } - this.client?.setAccountDataRaw( "m.media_preview_config", { - value: newValue - }); + this.client?.setAccountDataRaw(CLIENT_KEY, newValue); } }