Add option to change the size of images/videos in the timeline (#7017)

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
Co-authored-by: J. Ryan Stinnett <jryans@gmail.com>
Co-authored-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Travis Ralston 2021-11-17 08:19:30 -07:00 committed by GitHub
parent 816136de97
commit 3c06e7f7a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 293 additions and 45 deletions

View file

@ -36,7 +36,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import { logger } from "matrix-js-sdk/src/logger";

View file

@ -27,7 +27,7 @@ import { wantsDateSeparator } from '../../DateUtils';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import { _t } from "../../languageHandler";
import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile";
import { hasText } from "../../TextForEvent";

View file

@ -23,7 +23,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import { logger } from "matrix-js-sdk/src/logger";

View file

@ -44,7 +44,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile";

View file

@ -27,7 +27,7 @@ import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuB
import ContextMenu, { ChevronFace, useContextMenu } from './ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import TimelinePanel from './TimelinePanel';
import { Layout } from '../../settings/Layout';
import { Layout } from '../../settings/enums/Layout';
import { useEventEmitter } from '../../hooks/useEventEmitter';
import AccessibleButton from '../views/elements/AccessibleButton';
import { TileShape } from '../views/rooms/EventTile';

View file

@ -27,7 +27,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
import { TileShape } from '../views/rooms/EventTile';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/Layout';
import { Layout } from '../../settings/enums/Layout';
import TimelinePanel from './TimelinePanel';
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';

View file

@ -25,7 +25,7 @@ import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import { _t } from '../../languageHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomContext from "../../contexts/RoomContext";

View file

@ -25,7 +25,7 @@ import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings";
import { UIFeature } from "../../../settings/UIFeature";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import { avatarUrlForUser } from "../../../Avatar";

View file

@ -22,7 +22,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import { useStateToggle } from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton";
import { Layout } from '../../../settings/Layout';
import { Layout } from '../../../settings/enums/Layout';
interface IProps {
// An array of member events to summarise

View file

@ -22,7 +22,7 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import * as Avatar from '../../../Avatar';
import EventTile from '../rooms/EventTile';
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';

View file

@ -31,7 +31,7 @@ import { Action } from '../../../dispatcher/actions';
import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
import { jsxJoin } from '../../../utils/ReactUtils';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { Layout } from '../../../settings/Layout';
import { Layout } from '../../../settings/enums/Layout';
const onPinnedMessagesClick = (): void => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({

View file

@ -23,7 +23,7 @@ import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";

View file

@ -35,6 +35,7 @@ import classNames from 'classnames';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
import { logger } from "matrix-js-sdk/src/logger";
import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
interface IState {
decryptedUrl?: string;
@ -58,6 +59,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private unmounted = true;
private image = createRef<HTMLImageElement>();
private timeout?: number;
private sizeWatcher: string;
constructor(props: IBodyProps) {
super(props);
@ -317,12 +319,17 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}
}, 150);
}
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
});
}
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener('sync', this.onClientSync);
this.clearBlurhashTimeout();
SettingsStore.unwatchSetting(this.sizeWatcher);
}
protected messageContent(
@ -367,11 +374,25 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
infoHeight = this.state.loadedImageDimensions.naturalHeight;
}
// The maximum height of the thumbnail as it is rendered as an <img>
const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight);
// The maximum width of the thumbnail, as dictated by its natural
// maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight;
// The maximum size of the thumbnail as it is rendered as an <img>
// check for any height constraints
const imageSize = SettingsStore.getValue("Images.size") as ImageSize;
const suggestedAndPossibleWidth = Math.min(suggestedImageSize(imageSize).w, infoWidth);
const aspectRatio = infoWidth / infoHeight;
let maxWidth;
let maxHeight;
const maxHeightConstraint = forcedHeight || this.props.maxImageHeight || undefined;
if (maxHeightConstraint && maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth) {
// width is dictated by the maximum height that was defined by the props or the function param `forcedHeight`
maxWidth = maxHeightConstraint * aspectRatio;
// there is no need to check for infoHeight here since this is done with `maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth`
maxHeight = maxHeightConstraint;
} else {
// height is dictated by suggestedWidth (based on the Image.size setting)
maxWidth = suggestedAndPossibleWidth;
maxHeight = suggestedAndPossibleWidth / aspectRatio;
}
let img = null;
let placeholder = null;

View file

@ -28,6 +28,7 @@ import { IBodyProps } from "./IBodyProps";
import MFileBody from "./MFileBody";
import { logger } from "matrix-js-sdk/src/logger";
import { ImageSize, suggestedSize as suggestedVideoSize } from "../../../settings/enums/ImageSize";
interface IState {
decryptedUrl?: string;
@ -42,6 +43,7 @@ interface IState {
@replaceableComponent("views.messages.MVideoBody")
export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
private videoRef = React.createRef<HTMLVideoElement>();
private sizeWatcher: string;
constructor(props) {
super(props);
@ -57,7 +59,22 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
};
}
thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) {
private get suggestedDimensions(): { w: number, h: number } {
return suggestedVideoSize(SettingsStore.getValue("Images.size") as ImageSize);
}
private thumbScale(
fullWidth: number,
fullHeight: number,
thumbWidth?: number,
thumbHeight?: number,
): number {
if (!thumbWidth || !thumbHeight) {
const dims = this.suggestedDimensions;
thumbWidth = dims.w;
thumbHeight = dims.h;
}
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
@ -68,14 +85,8 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
return 1;
}
const widthMulti = thumbWidth / fullWidth;
const heightMulti = thumbHeight / fullHeight;
if (widthMulti < heightMulti) {
// width is the dominant dimension so scaling will be fixed on that
return widthMulti;
} else {
// height is the dominant dimension so scaling will be fixed on that
return heightMulti;
}
// always scale the videos based on their width.
return widthMulti;
}
private getContentUrl(): string|null {
@ -152,12 +163,16 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
}
}
async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayVideo") as boolean;
public async componentDidMount() {
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
});
this.loadBlurhash();
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
try {
const autoplay = SettingsStore.getValue("autoplayVideo") as boolean;
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
if (autoplay) {
logger.log("Preloading video");
@ -189,6 +204,10 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
}
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.sizeWatcher);
}
private videoOnPlay = async () => {
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
// We have the file, we are fetching the file, or there is an error.
@ -249,8 +268,9 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
const contentUrl = this.getContentUrl();
const thumbUrl = this.getThumbUrl();
let height = null;
let width = null;
const defaultDims = this.suggestedDimensions;
let height = defaultDims.h;
let width = defaultDims.w;
let poster = null;
let preload = "metadata";
if (content.info) {

View file

@ -28,7 +28,7 @@ import { _t } from '../../../languageHandler';
import { hasText } from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { ALL_RULE_TYPES } from "../../../mjolnir/BanList";

View file

@ -22,7 +22,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import Slider from "../elements/Slider";
import { FontWatcher } from "../../../settings/watchers/FontWatcher";
import { IValidationResult, IFieldState } from '../elements/Validation';
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { SettingLevel } from "../../../settings/SettingLevel";
import { _t } from "../../../languageHandler";

View file

@ -0,0 +1,79 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
import { _t } from "../../../languageHandler";
import { SettingLevel } from "../../../settings/SettingLevel";
import { ImageSize } from "../../../settings/enums/ImageSize";
interface IProps {
// none
}
interface IState {
size: ImageSize;
}
export default class ImageSizePanel extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
size: SettingsStore.getValue("Images.size"),
};
}
private onSizeChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
const newSize = ev.target.value as ImageSize;
this.setState({ size: newSize });
// noinspection JSIgnoredPromiseFromCall
SettingsStore.setValue("Images.size", null, SettingLevel.ACCOUNT, newSize);
};
public render(): JSX.Element {
return (
<div className="mx_SettingsTab_section mx_ImageSizePanel">
<span className="mx_SettingsTab_subheading">
{ _t("Image size in the timeline") }
</span>
<div className="mx_ImageSizePanel_radios">
<label>
<div className="mx_ImageSizePanel_size mx_ImageSizePanel_sizeDefault" />
<StyledRadioButton
name="image_size"
value={ImageSize.Normal}
checked={this.state.size === ImageSize.Normal}
onChange={this.onSizeChange}
>{ _t("Default") }</StyledRadioButton>
</label>
<label>
<div className="mx_ImageSizePanel_size mx_ImageSizePanel_sizeLarge" />
<StyledRadioButton
name="image_size"
value={ImageSize.Large}
checked={this.state.size === ImageSize.Large}
onChange={this.onSizeChange}
>{ _t("Large") }</StyledRadioButton>
</label>
</div>
</div>
);
}
}

View file

@ -22,7 +22,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import EventTilePreview from "../elements/EventTilePreview";
import StyledRadioButton from "../elements/StyledRadioButton";
import { _t } from "../../../languageHandler";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { SettingLevel } from "../../../settings/SettingLevel";
interface IProps {

View file

@ -25,12 +25,13 @@ import SettingsFlag from '../../../elements/SettingsFlag';
import Field from '../../../elements/Field';
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/Layout";
import { Layout } from "../../../../../settings/enums/Layout";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import LayoutSwitcher from "../../LayoutSwitcher";
import FontScalingPanel from '../../FontScalingPanel';
import ThemeChoicePanel from '../../ThemeChoicePanel';
import ImageSizePanel from "../../ImageSizePanel";
interface IProps {
}
@ -188,6 +189,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
{ layoutSection }
<FontScalingPanel />
{ this.renderAdvancedSection() }
<ImageSizePanel />
</div>
);
}