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