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.

367 lines
11 KiB

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