diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index b6d1315b8d..e0b5b32e1b 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -18,8 +18,6 @@ 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 { /** @@ -91,7 +89,7 @@ declare module "matrix-js-sdk/src/types" { accepted: string[]; }; - "io.element.msc4278.media_preview_config": MediaPreviewConfig, + "io.element.msc4278.media_preview_config": MediaPreviewConfig; } export interface AudioContent { diff --git a/src/@types/media_preview.ts b/src/@types/media_preview.ts index eaa110874c..e8cacbe81f 100644 --- a/src/@types/media_preview.ts +++ b/src/@types/media_preview.ts @@ -1,10 +1,11 @@ export enum MediaPreviewValue { On = "on", Private = "private", - Off = "off" + Off = "off", } +export const MEDIA_PREVIEW_ACCOUNT_DATA_TYPE = "io.element.msc4278.media_preview_config"; export interface MediaPreviewConfig extends Record { - media_previews: MediaPreviewValue, - invite_avatars: MediaPreviewValue, -} \ No newline at end of file + media_previews: MediaPreviewValue; + invite_avatars: MediaPreviewValue; +} diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 1a76274dd5..541d0fc2ff 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -97,8 +97,9 @@ 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("mediaPreviewConfig", props.room?.roomId).invite_avatars !== MediaPreviewValue.On) { + 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/LinkPreviewGroup.tsx b/src/components/views/rooms/LinkPreviewGroup.tsx index 84943c24e8..6f21e93bfa 100644 --- a/src/components/views/rooms/LinkPreviewGroup.tsx +++ b/src/components/views/rooms/LinkPreviewGroup.tsx @@ -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 ? ( { @@ -70,8 +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; - // HSTODO: Private rooms? - if (!SettingsStore.getValue("mediaPreviewConfig", this.props.mxEvent.getRoomId()).media_previews !== MediaPreviewValue.On) { + 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/user/MediaPreviewSetting.tsx b/src/components/views/settings/tabs/user/MediaPreviewSetting.tsx new file mode 100644 index 0000000000..434693c0db --- /dev/null +++ b/src/components/views/settings/tabs/user/MediaPreviewSetting.tsx @@ -0,0 +1,111 @@ +import React, { ChangeEventHandler } from "react"; +import { Field, HelpMessage, InlineField, Label, RadioInput, Root } from "@vector-im/compound-web"; +import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; +import { useCallback } from "react"; +import { 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 function MediaPreviewAccountSettings() { + const currentMediaPreview = useSettingValue("mediaPreviewConfig"); + + const avatarOnChange = useCallback( + (c: boolean) => { + const newValue = { + ...currentMediaPreview, + // N.B. Switch is inverted. "Hide avatars..." + invite_avatars: c ? MediaPreviewValue.Off : MediaPreviewValue.On, + } satisfies MediaPreviewConfig; + SettingsStore.setValue("mediaPreviewConfig", null, SettingLevel.ACCOUNT, newValue); + }, + [currentMediaPreview], + ); + + const mediaPreviewOnChangeOff = useCallback>( + (event) => { + if (!event.target.checked) { + return; + } + SettingsStore.setValue("mediaPreviewConfig", null, SettingLevel.ACCOUNT, { + ...currentMediaPreview, + media_previews: MediaPreviewValue.Off, + } satisfies MediaPreviewConfig); + }, + [currentMediaPreview], + ); + + const mediaPreviewOnChangePrivate = useCallback>( + (event) => { + if (!event.target.checked) { + return; + } + SettingsStore.setValue("mediaPreviewConfig", null, SettingLevel.ACCOUNT, { + ...currentMediaPreview, + media_previews: MediaPreviewValue.Private, + } satisfies MediaPreviewConfig); + }, + [currentMediaPreview], + ); + + const mediaPreviewOnChangeOn = useCallback>( + (event) => { + if (!event.target.checked) { + return; + } + SettingsStore.setValue("mediaPreviewConfig", null, SettingLevel.ACCOUNT, { + ...currentMediaPreview, + media_previews: MediaPreviewValue.On, + } satisfies MediaPreviewConfig); + }, + [currentMediaPreview], + ); + + return ( + + + + + {_t("settings|media_preview|media_preview_description")} + + } + > + + + + } + > + + + + } + > + + + + + ); +} diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 816fd8c33a..a2e0f4016c 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 "./MediaPreviewSetting.tsx"; interface IProps { closeSettingsFn(success: boolean): void; @@ -162,11 +163,6 @@ export default class PreferencesUserSettingsTab extends React.Component - {this.renderGroup(PreferencesUserSettingsTab.SAFETY_SETTINGS)} + diff --git a/src/hooks/useMediaVisible.ts b/src/hooks/useMediaVisible.ts index 4dd7360bdd..c975e6c9eb 100644 --- a/src/hooks/useMediaVisible.ts +++ b/src/hooks/useMediaVisible.ts @@ -35,6 +35,16 @@ export function useMediaVisible(eventId: string, roomId: string): [boolean, (vis [eventId, eventVisibility], ); + const roomIsPrivate = useMemo(() => { + const joinRule = client.getRoom(roomId)?.getJoinRule(); + if (PRIVATE_JOIN_RULES.includes(joinRule as JoinRule)) { + return true; + } else { // All other join rules, and unknown will default to hiding. + return false; + } + }, [client, roomId]) + + // Always prefer the explicit per-event user preference here. if (eventVisibility[eventId]) { return [true, setMediaVisible]; @@ -42,13 +52,12 @@ export function useMediaVisible(eventId: string, roomId: string): [boolean, (vis 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)) { - console.log("Room is private"); - return [true, setMediaVisible]; - } else { // All other join rules, and unknown will default to hiding. - console.log("Room is probably public"); + } 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 8af1f7944a..84f4df12e7 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": { @@ -2681,13 +2682,10 @@ "unable_to_load_msisdns": "Unable to load phone numbers", "username": "Username" }, - "show_media": "Show media in timeline", - "show_media_description": "A hidden media can always be shown by tapping on it", "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).", @@ -2746,6 +2744,14 @@ "labs_mjolnir": { "dialog_title": "Settings: Ignored Users" }, + "media_preview": { + "hide_avatars": "Hide avatars of room and inviter", + "media_preview_label": "Show media in timeline", + "media_preview_description": "A hidden media can always be shown by tapping on it", + "hide_media": "Always hide", + "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/SettingsStore.ts b/src/settings/SettingsStore.ts index ede37f1240..2dbe2db218 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -716,7 +716,7 @@ export default class SettingsStore { localStorage.setItem(MIGRATION_DONE_FLAG, "true"); } - /** + /** * Migrate the setting for visible images to a setting. */ private static migrateMediaControlsToSetting(): void { @@ -735,7 +735,6 @@ export default class SettingsStore { }); } // else, we don't set anything and use the server value - localStorage.setItem(MIGRATION_DONE_FLAG, "true"); } diff --git a/src/settings/controllers/MediaPreviewConfigController.ts b/src/settings/controllers/MediaPreviewConfigController.ts index 0e2b7ac120..65c5025b2d 100644 --- a/src/settings/controllers/MediaPreviewConfigController.ts +++ b/src/settings/controllers/MediaPreviewConfigController.ts @@ -7,23 +7,19 @@ Please see LICENSE files in the repository root for full details. 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 { MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, MediaPreviewConfig, MediaPreviewValue } from "../../@types/media_preview.ts"; import { SettingLevel } from "../SettingLevel.ts"; import MatrixClientBackedController from "./MatrixClientBackedController.ts"; - -const CLIENT_KEY = "io.element.msc4278.media_preview_config"; - /** * TODO */ export default class MediaPreviewConfigController extends MatrixClientBackedController { - public static readonly default: AccountDataEvents["io.element.msc4278.media_preview_config"] = { media_previews: MediaPreviewValue.On, - invite_avatars: MediaPreviewValue.On - } + invite_avatars: MediaPreviewValue.On, + }; private globalSetting: MediaPreviewConfig = MediaPreviewConfigController.default; @@ -31,29 +27,52 @@ export default class MediaPreviewConfigController extends MatrixClientBackedCont super(); } - private getRoomValue = (roomId: string): MediaPreviewConfig|null => { - return this.client?.getRoom(roomId)?.getAccountData(CLIENT_KEY)?.getContent() ?? null; - } + private getRoomValue = (roomId: string): MediaPreviewConfig | null => { + return ( + this.client + ?.getRoom(roomId) + ?.getAccountData(MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) + ?.getContent() ?? null + ); + }; private onAccountData = (event: MatrixEvent): void => { + if (event.getType() !== MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) { + return; + } + console.log("OnAccountData", event); // TODO: Validate. const roomId = event.getRoomId(); if (!roomId) { - this.globalSetting = event.getContent(); + this.globalSetting = { + ...MediaPreviewConfigController.default, + ...event.getContent(), + }; + console.log("CONFIG Updating global settings", this.globalSetting); } }; protected async initMatrixClient(newClient: MatrixClient, oldClient?: MatrixClient): Promise { oldClient?.off(ClientEvent.AccountData, this.onAccountData); newClient.on(ClientEvent.AccountData, this.onAccountData); - const accountData = newClient.getAccountData(CLIENT_KEY); + const accountData = newClient.getAccountData(MEDIA_PREVIEW_ACCOUNT_DATA_TYPE); if (accountData) this.onAccountData(accountData); } - public getValueOverride(level: SettingLevel, roomId: string | null,): MediaPreviewConfig { + public getValueOverride(level: SettingLevel, roomId: string | null): MediaPreviewConfig { // TODO: Use SettingLevel? if (roomId) { - return this.getRoomValue(roomId) ?? this.globalSetting; + console.log( + "MediaPreviewConfigController", + "getValueOverride", + this.getRoomValue(roomId), + this.globalSetting, + ); + // Use globals for any undefined setting + return { + ...this.getRoomValue(roomId), + ...this.globalSetting, + }; } return this.globalSetting; } @@ -62,13 +81,26 @@ export default class MediaPreviewConfigController extends MatrixClientBackedCont return false; } - public onChange(_level: SettingLevel, roomId: string | null, newValue: MediaPreviewConfig): void { - if (roomId) { - this.client?.setRoomAccountData(roomId, CLIENT_KEY, { - value: newValue - }); - return; + public async beforeChange( + level: SettingLevel, + roomId: string | null, + newValue: MediaPreviewConfig, + ): Promise { + if (!this.client) { + // No client! + return false; } - this.client?.setAccountDataRaw(CLIENT_KEY, newValue); + if (roomId) { + await this.client.setRoomAccountData(roomId, MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, { + value: newValue, + }); + return true; + } + await this.client.setAccountDataRaw(MEDIA_PREVIEW_ACCOUNT_DATA_TYPE, newValue); + return true; + } + + public onChange(_level: SettingLevel, roomId: string | null, newValue: MediaPreviewConfig): void { + console.log("onChange", roomId, newValue); } } diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index 6afd560867..51b2d5162b 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -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,9 @@ 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) { + console.log("notifyupdate"); + this.watchers.notifyUpdate("mediaPreviewConfig", null, SettingLevel.ROOM_ACCOUNT, event.getContent()); } }; diff --git a/src/settings/handlers/RoomAccountSettingsHandler.ts b/src/settings/handlers/RoomAccountSettingsHandler.ts index 045ef5cfe0..5811033da3 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.ts +++ b/src/settings/handlers/RoomAccountSettingsHandler.ts @@ -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,9 @@ 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) { + console.log("notifyupdate"); + this.watchers.notifyUpdate("mediaPreviewConfig", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); } };