闭社主体 forked from https://github.com/tootsuite/mastodon
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.

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