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:
parent
c0a564feaa
commit
aee93bfc9c
6 changed files with 134 additions and 12 deletions
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
@ -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')}>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue