You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

368 lines
11 KiB

  1. import React, { PureComponent } from 'react';
  2. import { ScrollContainer } from 'react-router-scroll-4';
  3. import PropTypes from 'prop-types';
  4. import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
  5. import LoadMore from './load_more';
  6. import LoadPending from './load_pending';
  7. import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
  8. import { throttle } from 'lodash';
  9. import { List as ImmutableList } from 'immutable';
  10. import classNames from 'classnames';
  11. import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
  12. import LoadingIndicator from './loading_indicator';
  13. import { connect } from 'react-redux';
  14. const MOUSE_IDLE_DELAY = 300;
  15. const mapStateToProps = (state, { scrollKey }) => {
  16. return {
  17. preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
  18. };
  19. };
  20. export default @connect(mapStateToProps, null, null, { forwardRef: true })
  21. class ScrollableList extends PureComponent {
  22. static contextTypes = {
  23. router: PropTypes.object,
  24. };
  25. static propTypes = {
  26. scrollKey: PropTypes.string.isRequired,
  27. onLoadMore: PropTypes.func,
  28. onLoadPending: PropTypes.func,
  29. onScrollToTop: PropTypes.func,
  30. onScroll: PropTypes.func,
  31. trackScroll: PropTypes.bool,
  32. shouldUpdateScroll: PropTypes.func,
  33. isLoading: PropTypes.bool,
  34. showLoading: PropTypes.bool,
  35. hasMore: PropTypes.bool,
  36. numPending: PropTypes.number,
  37. prepend: PropTypes.node,
  38. append: PropTypes.node,
  39. alwaysPrepend: PropTypes.bool,
  40. emptyMessage: PropTypes.node,
  41. children: PropTypes.node,
  42. bindToDocument: PropTypes.bool,
  43. preventScroll: PropTypes.bool,
  44. };
  45. static defaultProps = {
  46. trackScroll: true,
  47. };
  48. state = {
  49. fullscreen: null,
  50. cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
  51. };
  52. intersectionObserverWrapper = new IntersectionObserverWrapper();
  53. handleScroll = throttle(() => {
  54. if (this.node) {
  55. const scrollTop = this.getScrollTop();
  56. const scrollHeight = this.getScrollHeight();
  57. const clientHeight = this.getClientHeight();
  58. const offset = scrollHeight - scrollTop - clientHeight;
  59. if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) {
  60. this.props.onLoadMore();
  61. }
  62. if (scrollTop < 100 && this.props.onScrollToTop) {
  63. this.props.onScrollToTop();
  64. } else if (this.props.onScroll) {
  65. this.props.onScroll();
  66. }
  67. if (!this.lastScrollWasSynthetic) {
  68. // If the last scroll wasn't caused by setScrollTop(), assume it was
  69. // intentional and cancel any pending scroll reset on mouse idle
  70. this.scrollToTopOnMouseIdle = false;
  71. }
  72. this.lastScrollWasSynthetic = false;
  73. }
  74. }, 150, {
  75. trailing: true,
  76. });
  77. mouseIdleTimer = null;
  78. mouseMovedRecently = false;
  79. lastScrollWasSynthetic = false;
  80. scrollToTopOnMouseIdle = false;
  81. _getScrollingElement = () => {
  82. if (this.props.bindToDocument) {
  83. return (document.scrollingElement || document.body);
  84. } else {
  85. return this.node;
  86. }
  87. }
  88. setScrollTop = newScrollTop => {
  89. if (this.getScrollTop() !== newScrollTop) {
  90. this.lastScrollWasSynthetic = true;
  91. this._getScrollingElement().scrollTop = newScrollTop;
  92. }
  93. };
  94. clearMouseIdleTimer = () => {
  95. if (this.mouseIdleTimer === null) {
  96. return;
  97. }
  98. clearTimeout(this.mouseIdleTimer);
  99. this.mouseIdleTimer = null;
  100. };
  101. handleMouseMove = throttle(() => {
  102. // As long as the mouse keeps moving, clear and restart the idle timer.
  103. this.clearMouseIdleTimer();
  104. this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
  105. if (!this.mouseMovedRecently && this.getScrollTop() === 0) {
  106. // Only set if we just started moving and are scrolled to the top.
  107. this.scrollToTopOnMouseIdle = true;
  108. }
  109. // Save setting this flag for last, so we can do the comparison above.
  110. this.mouseMovedRecently = true;
  111. }, MOUSE_IDLE_DELAY / 2);
  112. handleWheel = throttle(() => {
  113. this.scrollToTopOnMouseIdle = false;
  114. }, 150, {
  115. trailing: true,
  116. });
  117. handleMouseIdle = () => {
  118. if (this.scrollToTopOnMouseIdle && !this.props.preventScroll) {
  119. this.setScrollTop(0);
  120. }
  121. this.mouseMovedRecently = false;
  122. this.scrollToTopOnMouseIdle = false;
  123. }
  124. componentDidMount () {
  125. this.attachScrollListener();
  126. this.attachIntersectionObserver();
  127. attachFullscreenListener(this.onFullScreenChange);
  128. // Handle initial scroll posiiton
  129. this.handleScroll();
  130. }
  131. getScrollPosition = () => {
  132. if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
  133. return { height: this.getScrollHeight(), top: this.getScrollTop() };
  134. } else {
  135. return null;
  136. }
  137. }
  138. getScrollTop = () => {
  139. return this._getScrollingElement().scrollTop;
  140. }
  141. getScrollHeight = () => {
  142. return this._getScrollingElement().scrollHeight;
  143. }
  144. getClientHeight = () => {
  145. return this._getScrollingElement().clientHeight;
  146. }
  147. updateScrollBottom = (snapshot) => {
  148. const newScrollTop = this.getScrollHeight() - snapshot;
  149. this.setScrollTop(newScrollTop);
  150. }
  151. getSnapshotBeforeUpdate (prevProps) {
  152. const someItemInserted = React.Children.count(prevProps.children) > 0 &&
  153. React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
  154. this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
  155. const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
  156. if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) {
  157. return this.getScrollHeight() - this.getScrollTop();
  158. } else {
  159. return null;
  160. }
  161. }
  162. componentDidUpdate (prevProps, prevState, snapshot) {
  163. // Reset the scroll position when a new child comes in in order not to
  164. // jerk the scrollbar around if you're already scrolled down the page.
  165. if (snapshot !== null) {
  166. this.setScrollTop(this.getScrollHeight() - snapshot);
  167. }
  168. }
  169. cacheMediaWidth = (width) => {
  170. if (width && this.state.cachedMediaWidth !== width) {
  171. this.setState({ cachedMediaWidth: width });
  172. }
  173. }
  174. componentWillUnmount () {
  175. this.clearMouseIdleTimer();
  176. this.detachScrollListener();
  177. this.detachIntersectionObserver();
  178. detachFullscreenListener(this.onFullScreenChange);
  179. }
  180. onFullScreenChange = () => {
  181. this.setState({ fullscreen: isFullscreen() });
  182. }
  183. attachIntersectionObserver () {
  184. let nodeOptions = {
  185. root: this.node,
  186. rootMargin: '300% 0px',
  187. };
  188. this.intersectionObserverWrapper
  189. .connect(this.props.bindToDocument ? {} : nodeOptions);
  190. }
  191. detachIntersectionObserver () {
  192. this.intersectionObserverWrapper.disconnect();
  193. }
  194. attachScrollListener () {
  195. if (this.props.bindToDocument) {
  196. document.addEventListener('scroll', this.handleScroll);
  197. document.addEventListener('wheel', this.handleWheel);
  198. } else {
  199. this.node.addEventListener('scroll', this.handleScroll);
  200. this.node.addEventListener('wheel', this.handleWheel);
  201. }
  202. }
  203. detachScrollListener () {
  204. if (this.props.bindToDocument) {
  205. document.removeEventListener('scroll', this.handleScroll);
  206. document.removeEventListener('wheel', this.handleWheel);
  207. } else {
  208. this.node.removeEventListener('scroll', this.handleScroll);
  209. this.node.removeEventListener('wheel', this.handleWheel);
  210. }
  211. }
  212. getFirstChildKey (props) {
  213. const { children } = props;
  214. let firstChild = children;
  215. if (children instanceof ImmutableList) {
  216. firstChild = children.get(0);
  217. } else if (Array.isArray(children)) {
  218. firstChild = children[0];
  219. }
  220. return firstChild && firstChild.key;
  221. }
  222. setRef = (c) => {
  223. this.node = c;
  224. }
  225. handleLoadMore = e => {
  226. e.preventDefault();
  227. this.props.onLoadMore();
  228. }
  229. handleLoadPending = e => {
  230. e.preventDefault();
  231. this.props.onLoadPending();
  232. // Prevent the weird scroll-jumping behavior, as we explicitly don't want to
  233. // scroll to top, and we know the scroll height is going to change
  234. this.scrollToTopOnMouseIdle = false;
  235. this.lastScrollWasSynthetic = false;
  236. this.clearMouseIdleTimer();
  237. this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
  238. this.mouseMovedRecently = true;
  239. }
  240. render () {
  241. const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
  242. const { fullscreen } = this.state;
  243. const childrenCount = React.Children.count(children);
  244. const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
  245. const loadPending = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null;
  246. let scrollableArea = null;
  247. if (showLoading) {
  248. scrollableArea = (
  249. <div className='scrollable scrollable--flex' ref={this.setRef}>
  250. <div role='feed' className='item-list'>
  251. {prepend}
  252. </div>
  253. <div className='scrollable__append'>
  254. <LoadingIndicator />
  255. </div>
  256. </div>
  257. );
  258. } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
  259. scrollableArea = (
  260. <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
  261. <div role='feed' className='item-list'>
  262. {prepend}
  263. {loadPending}
  264. {React.Children.map(this.props.children, (child, index) => (
  265. <IntersectionObserverArticleContainer
  266. key={child.key}
  267. id={child.key}
  268. index={index}
  269. listLength={childrenCount}
  270. intersectionObserverWrapper={this.intersectionObserverWrapper}
  271. saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
  272. >
  273. {React.cloneElement(child, {
  274. getScrollPosition: this.getScrollPosition,
  275. updateScrollBottom: this.updateScrollBottom,
  276. cachedMediaWidth: this.state.cachedMediaWidth,
  277. cacheMediaWidth: this.cacheMediaWidth,
  278. })}
  279. </IntersectionObserverArticleContainer>
  280. ))}
  281. {loadMore}
  282. {!hasMore && append}
  283. </div>
  284. </div>
  285. );
  286. } else {
  287. scrollableArea = (
  288. <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}>
  289. {alwaysPrepend && prepend}
  290. <div className='empty-column-indicator'>
  291. {emptyMessage}
  292. </div>
  293. </div>
  294. );
  295. }
  296. if (trackScroll) {
  297. return (
  298. <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
  299. {scrollableArea}
  300. </ScrollContainer>
  301. );
  302. } else {
  303. return scrollableArea;
  304. }
  305. }
  306. }