The code powering m.abunchtell.com https://m.abunchtell.com
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.
 
 
 
 

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