scrollable_list.jsx 12 KB

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