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.

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