Merge pull request #6590 from matrix-org/palid/fix/images-loading-ux
Make loading encrypted images look snappier
This commit is contained in:
commit
fbc5729daf
4 changed files with 112 additions and 24 deletions
55
res/css/_animations.scss
Normal file
55
res/css/_animations.scss
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Transition Group animations are prefixed with 'mx_rtg--' so that we
|
||||||
|
* know they should not be used anywhere outside of React Transition Groups.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_rtg--fade-enter {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.mx_rtg--fade-enter-active {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 300ms ease;
|
||||||
|
}
|
||||||
|
.mx_rtg--fade-exit {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.mx_rtg--fade-exit-active {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes mx--anim-pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
@keyframes mx--anim-pulse {
|
||||||
|
// Override all keyframes in reduced-motion
|
||||||
|
}
|
||||||
|
.mx_rtg--fade-enter-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.mx_rtg--fade-exit-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ limitations under the License.
|
||||||
|
|
||||||
@import "./_font-sizes.scss";
|
@import "./_font-sizes.scss";
|
||||||
@import "./_font-weights.scss";
|
@import "./_font-weights.scss";
|
||||||
|
@import "./_animations.scss";
|
||||||
|
|
||||||
$hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic
|
$hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,12 @@ limitations under the License.
|
||||||
|
|
||||||
$timelineImageBorderRadius: 4px;
|
$timelineImageBorderRadius: 4px;
|
||||||
|
|
||||||
|
.mx_MImageBody_thumbnail--blurhash {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_MImageBody_thumbnail {
|
.mx_MImageBody_thumbnail {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: $timelineImageBorderRadius;
|
border-radius: $timelineImageBorderRadius;
|
||||||
|
@ -23,8 +29,11 @@ $timelineImageBorderRadius: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
> div > canvas {
|
.mx_Blurhash > canvas {
|
||||||
|
animation: mx--anim-pulse 1.75s infinite cubic-bezier(.4, 0, .6, 1);
|
||||||
border-radius: $timelineImageBorderRadius;
|
border-radius: $timelineImageBorderRadius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,12 +25,14 @@ import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import InlineSpinner from '../elements/InlineSpinner';
|
import InlineSpinner from '../elements/InlineSpinner';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import { mediaFromContent } from "../../../customisations/Media";
|
import { Media, mediaFromContent } from "../../../customisations/Media";
|
||||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||||
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
|
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
|
||||||
import ImageView from '../elements/ImageView';
|
import ImageView from '../elements/ImageView';
|
||||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||||
import { IBodyProps } from "./IBodyProps";
|
import { IBodyProps } from "./IBodyProps";
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { CSSTransition, SwitchTransition } from 'react-transition-group';
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
decryptedUrl?: string;
|
decryptedUrl?: string;
|
||||||
|
@ -157,19 +159,21 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
// this is only used as a fallback in case content.info.w/h is missing
|
// this is only used as a fallback in case content.info.w/h is missing
|
||||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ imgLoaded: true, loadedImageDimensions });
|
this.setState({ imgLoaded: true, loadedImageDimensions });
|
||||||
};
|
};
|
||||||
|
|
||||||
protected getContentUrl(): string {
|
protected getContentUrl(): string {
|
||||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
if (this.media.isEncrypted) {
|
||||||
if (media.isEncrypted) {
|
|
||||||
return this.state.decryptedUrl;
|
return this.state.decryptedUrl;
|
||||||
} else {
|
} else {
|
||||||
return media.srcHttp;
|
return this.media.srcHttp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get media(): Media {
|
||||||
|
return mediaFromContent(this.props.mxEvent.getContent());
|
||||||
|
}
|
||||||
|
|
||||||
protected getThumbUrl(): string {
|
protected getThumbUrl(): string {
|
||||||
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
|
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
|
||||||
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
||||||
|
@ -225,7 +229,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
info.w > thumbWidth ||
|
info.w > thumbWidth ||
|
||||||
info.h > thumbHeight
|
info.h > thumbHeight
|
||||||
);
|
);
|
||||||
const isLargeFileSize = info.size > 1*1024*1024; // 1mb
|
const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb
|
||||||
|
|
||||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||||
// image is too large physically and bytewise to clutter our timeline so
|
// image is too large physically and bytewise to clutter our timeline so
|
||||||
|
@ -374,23 +378,40 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classes = classNames({
|
||||||
|
'mx_MImageBody_thumbnail': true,
|
||||||
|
'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info[BLURHASH_FIELD],
|
||||||
|
});
|
||||||
|
|
||||||
|
// This has incredibly broken types.
|
||||||
|
const C = CSSTransition as any;
|
||||||
const thumbnail = (
|
const thumbnail = (
|
||||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
|
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
|
||||||
{ showPlaceholder &&
|
<SwitchTransition mode="out-in">
|
||||||
<div
|
<C
|
||||||
className="mx_MImageBody_thumbnail"
|
classNames="mx_rtg--fade"
|
||||||
|
key={`img-${showPlaceholder}`}
|
||||||
|
timeout={300}
|
||||||
|
>
|
||||||
|
{ /* This weirdly looking div is necessary here, otherwise SwitchTransition fails */ }
|
||||||
|
<div>
|
||||||
|
{ showPlaceholder && <div
|
||||||
|
className={classes}
|
||||||
style={{
|
style={{
|
||||||
// Constrain width here so that spinner appears central to the loaded thumbnail
|
// Constrain width here so that spinner appears central to the loaded thumbnail
|
||||||
maxWidth: `min(100%, ${infoWidth}px)`,
|
maxWidth: `min(100%, ${infoWidth}px)`,
|
||||||
|
maxHeight: maxHeight,
|
||||||
|
aspectRatio: `${infoWidth}/${infoHeight}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ placeholder }
|
{ placeholder }
|
||||||
|
</div> }
|
||||||
</div>
|
</div>
|
||||||
}
|
</C>
|
||||||
|
</SwitchTransition>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: !showPlaceholder ? undefined : 'none',
|
height: '100%',
|
||||||
height: '100%', // Also force to size of a parent to prevent scroll-jumps (see above)
|
|
||||||
}}>
|
}}>
|
||||||
{ img }
|
{ img }
|
||||||
{ gifLabel }
|
{ gifLabel }
|
||||||
|
@ -413,7 +434,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
// Overidden by MStickerBody
|
// Overidden by MStickerBody
|
||||||
protected getPlaceholder(width: number, height: number): JSX.Element {
|
protected getPlaceholder(width: number, height: number): JSX.Element {
|
||||||
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
|
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
|
||||||
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
|
if (blurhash) return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
|
||||||
return (
|
return (
|
||||||
<InlineSpinner w={32} h={32} />
|
<InlineSpinner w={32} h={32} />
|
||||||
);
|
);
|
||||||
|
@ -455,10 +476,12 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||||
const fileBody = this.getFileBody();
|
const fileBody = this.getFileBody();
|
||||||
|
|
||||||
return <div className="mx_MImageBody">
|
return (
|
||||||
|
<div className="mx_MImageBody">
|
||||||
{ thumbnail }
|
{ thumbnail }
|
||||||
{ fileBody }
|
{ fileBody }
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue