Fix timeline jumps (#10001)

* Avoid two-step rendering of statuses as much as possible

Cache width shared by Video player, MediaGallery and Cards at the
ScrollableList level, pass it down through StatusList and Notifications.

* Adjust scroll when new preview cards appear

* Adjust scroll when statuses above the current scroll position are deleted
This commit is contained in:
ThibG 2019-02-11 13:19:59 +01:00 committed by Eugen Rochko
parent c0a564feaa
commit aee93bfc9c
6 changed files with 134 additions and 12 deletions

View file

@ -194,6 +194,8 @@ class MediaGallery extends React.PureComponent {
height: PropTypes.number.isRequired, height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
@ -202,6 +204,7 @@ class MediaGallery extends React.PureComponent {
state = { state = {
visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all',
width: this.props.defaultWidth,
}; };
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
@ -221,6 +224,7 @@ class MediaGallery extends React.PureComponent {
handleRef = (node) => { handleRef = (node) => {
if (node /*&& this.isStandaloneEligible()*/) { if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to // offsetWidth triggers a layout, so only calculate when we need to
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
this.setState({ this.setState({
width: node.offsetWidth, width: node.offsetWidth,
}); });
@ -233,8 +237,10 @@ class MediaGallery extends React.PureComponent {
} }
render () { render () {
const { media, intl, sensitive, height } = this.props; const { media, intl, sensitive, height, defaultWidth } = this.props;
const { width, visible } = this.state; const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children; let children;

View file

@ -40,6 +40,7 @@ export default class ScrollableList extends PureComponent {
state = { state = {
fullscreen: null, fullscreen: null,
cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
}; };
intersectionObserverWrapper = new IntersectionObserverWrapper(); intersectionObserverWrapper = new IntersectionObserverWrapper();
@ -130,6 +131,20 @@ export default class ScrollableList extends PureComponent {
this.handleScroll(); this.handleScroll();
} }
getScrollPosition = () => {
if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) {
return { height: this.node.scrollHeight, top: this.node.scrollTop };
} else {
return null;
}
}
updateScrollBottom = (snapshot) => {
const newScrollTop = this.node.scrollHeight - snapshot;
this.setScrollTop(newScrollTop);
}
getSnapshotBeforeUpdate (prevProps) { getSnapshotBeforeUpdate (prevProps) {
const someItemInserted = React.Children.count(prevProps.children) > 0 && const someItemInserted = React.Children.count(prevProps.children) > 0 &&
React.Children.count(prevProps.children) < React.Children.count(this.props.children) && React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
@ -150,6 +165,12 @@ export default class ScrollableList extends PureComponent {
} }
} }
cacheMediaWidth = (width) => {
if (width && this.state.cachedMediaWidth !== width) {
this.setState({ cachedMediaWidth: width });
}
}
componentWillUnmount () { componentWillUnmount () {
this.clearMouseIdleTimer(); this.clearMouseIdleTimer();
this.detachScrollListener(); this.detachScrollListener();
@ -239,7 +260,12 @@ export default class ScrollableList extends PureComponent {
intersectionObserverWrapper={this.intersectionObserverWrapper} intersectionObserverWrapper={this.intersectionObserverWrapper}
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
> >
{child} {React.cloneElement(child, {
getScrollPosition: this.getScrollPosition,
updateScrollBottom: this.updateScrollBottom,
cachedMediaWidth: this.state.cachedMediaWidth,
cacheMediaWidth: this.cacheMediaWidth,
})}
</IntersectionObserverArticleContainer> </IntersectionObserverArticleContainer>
))} ))}

View file

@ -69,6 +69,10 @@ class Status extends ImmutablePureComponent {
onMoveUp: PropTypes.func, onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func, onMoveDown: PropTypes.func,
showThread: PropTypes.bool, showThread: PropTypes.bool,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
}; };
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@ -80,6 +84,43 @@ class Status extends ImmutablePureComponent {
'hidden', 'hidden',
]; ];
// Track height changes we know about to compensate scrolling
componentDidMount () {
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status.get('card');
}
getSnapshotBeforeUpdate () {
if (this.props.getScrollPosition) {
return this.props.getScrollPosition();
} else {
return null;
}
}
// Compensate height changes
componentDidUpdate (prevProps, prevState, snapshot) {
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status.get('card');
if (doShowCard && !this.didShowCard) {
this.didShowCard = true;
if (snapshot !== null && this.props.updateScrollBottom) {
if (this.node && this.node.offsetTop < snapshot.top) {
this.props.updateScrollBottom(snapshot.height - snapshot.top);
}
}
}
}
componentWillUnmount() {
if (this.node && this.props.getScrollPosition) {
const position = this.props.getScrollPosition();
if (position !== null && this.node.offsetTop < position.top) {
requestAnimationFrame(() => {
this.props.updateScrollBottom(position.height - position.top);
});
}
}
}
handleClick = () => { handleClick = () => {
if (this.props.onClick) { if (this.props.onClick) {
this.props.onClick(); this.props.onClick();
@ -166,6 +207,10 @@ class Status extends ImmutablePureComponent {
} }
} }
handleRef = c => {
this.node = c;
}
render () { render () {
let media = null; let media = null;
let statusAvatar, prepend, rebloggedByText; let statusAvatar, prepend, rebloggedByText;
@ -180,7 +225,7 @@ class Status extends ImmutablePureComponent {
if (hidden) { if (hidden) {
return ( return (
<div> <div ref={this.handleRef}>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')} {status.get('content')}
</div> </div>
@ -195,7 +240,7 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'> <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' /> <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</div> </div>
</HotKeys> </HotKeys>
@ -243,11 +288,12 @@ class Status extends ImmutablePureComponent {
preview={video.get('preview_url')} preview={video.get('preview_url')}
src={video.get('url')} src={video.get('url')}
alt={video.get('description')} alt={video.get('description')}
width={239} width={this.props.cachedMediaWidth}
height={110} height={110}
inline inline
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth}
/> />
)} )}
</Bundle> </Bundle>
@ -255,7 +301,16 @@ class Status extends ImmutablePureComponent {
} else { } else {
media = ( media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />} {Component => (
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.props.onOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
/>
)}
</Bundle> </Bundle>
); );
} }
@ -265,6 +320,8 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.props.onOpenMedia} onOpenMedia={this.props.onOpenMedia}
card={status.get('card')} card={status.get('card')}
compact compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
/> />
); );
} }
@ -291,7 +348,7 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} ref={this.handleRef}>
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>

View file

@ -35,6 +35,10 @@ class Notification extends ImmutablePureComponent {
onToggleHidden: PropTypes.func.isRequired, onToggleHidden: PropTypes.func.isRequired,
status: PropTypes.option, status: PropTypes.option,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
}; };
handleMoveUp = () => { handleMoveUp = () => {
@ -129,6 +133,10 @@ class Notification extends ImmutablePureComponent {
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
contextType='notifications' contextType='notifications'
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/> />
); );
} }
@ -149,7 +157,17 @@ class Notification extends ImmutablePureComponent {
</span> </span>
</div> </div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} /> <StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={!!this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div> </div>
</HotKeys> </HotKeys>
); );
@ -171,7 +189,17 @@ class Notification extends ImmutablePureComponent {
</span> </span>
</div> </div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} /> <StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div> </div>
</HotKeys> </HotKeys>
); );

View file

@ -61,6 +61,8 @@ export default class Card extends React.PureComponent {
maxDescription: PropTypes.number, maxDescription: PropTypes.number,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
compact: PropTypes.bool, compact: PropTypes.bool,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
@ -69,7 +71,7 @@ export default class Card extends React.PureComponent {
}; };
state = { state = {
width: 280, width: this.props.defaultWidth || 280,
embedded: false, embedded: false,
}; };
@ -112,6 +114,7 @@ export default class Card extends React.PureComponent {
setRef = c => { setRef = c => {
if (c) { if (c) {
if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
this.setState({ width: c.offsetWidth }); this.setState({ width: c.offsetWidth });
} }
} }

View file

@ -100,6 +100,7 @@ class Video extends React.PureComponent {
onCloseVideo: PropTypes.func, onCloseVideo: PropTypes.func,
detailed: PropTypes.bool, detailed: PropTypes.bool,
inline: PropTypes.bool, inline: PropTypes.bool,
cacheWidth: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -109,7 +110,7 @@ class Video extends React.PureComponent {
volume: 0.5, volume: 0.5,
paused: true, paused: true,
dragging: false, dragging: false,
containerWidth: false, containerWidth: this.props.width,
fullscreen: false, fullscreen: false,
hovered: false, hovered: false,
muted: false, muted: false,
@ -129,6 +130,7 @@ class Video extends React.PureComponent {
this.player = c; this.player = c;
if (c) { if (c) {
if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
this.setState({ this.setState({
containerWidth: c.offsetWidth, containerWidth: c.offsetWidth,
}); });