Global configuration flag for media previews (#29582)

* Modify useMediaVisible to take a room.

* Add initial support for a account data level key.

* Update controls.

* Update settings

* Lint and fixes

* make some tests go happy

* lint

* i18n

* update preferences

* prettier

* Update settings tab.

* update screenshot

* Update docs

* Rewrite controller

* Rewrite tons of tests

* Rewrite RoomAvatar to be a functional component

This is so we can use hooks to determine the setting state.

* lint

* lint

* Tidy up comments

* Apply media visible hook to inline images.

* Move conditionals.

* copyright all the things

* Review changes

* Update html utils to properly discard media.

* Types fix

* Fixing tests that break settings getValue expectations

* Fix logic around media preview calculation

* Fix room header tests

* Fixup tests for timelinePanel

* Clear settings in matrixchat

* Update tests to use SettingsStore where possible.

* fix bug

* revert changes to client.ts

* copyright years

* Add header

* Add a test for MediaPreviewAccountSettingsTab

* Mark initMatrixClient as optional

* Improve on types

* Ensure we do not set the account data twice.

* lint

* Review changes

* Ensure we include the client on rendered messages.

* Fix test

* update labels

* clean designs

* update settings tab

* update snapshot

* copyright

* prevent mutation
This commit is contained in:
Will Hunt
2025-04-22 10:37:47 +01:00
committed by GitHub
parent da6ac36f11
commit 75d9898dff
44 changed files with 1427 additions and 422 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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