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.

202 lines
5.9 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 'flavours/glitch/containers/intersection_observer_article_container';
  5. import LoadMore from './load_more';
  6. import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper';
  7. import { throttle } from 'lodash';
  8. import { List as ImmutableList } from 'immutable';
  9. import classNames from 'classnames';
  10. import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen';
  11. export default class ScrollableList extends PureComponent {
  12. static contextTypes = {
  13. router: PropTypes.object,
  14. };
  15. static propTypes = {
  16. scrollKey: PropTypes.string.isRequired,
  17. onLoadMore: PropTypes.func,
  18. onScrollToTop: PropTypes.func,
  19. onScroll: PropTypes.func,
  20. trackScroll: PropTypes.bool,
  21. shouldUpdateScroll: PropTypes.func,
  22. isLoading: PropTypes.bool,
  23. hasMore: PropTypes.bool,
  24. prepend: PropTypes.node,
  25. emptyMessage: PropTypes.node,
  26. children: PropTypes.node,
  27. };
  28. static defaultProps = {
  29. trackScroll: true,
  30. };
  31. state = {
  32. fullscreen: null,
  33. };
  34. intersectionObserverWrapper = new IntersectionObserverWrapper();
  35. handleScroll = throttle(() => {
  36. if (this.node) {
  37. const { scrollTop, scrollHeight, clientHeight } = this.node;
  38. const offset = scrollHeight - scrollTop - clientHeight;
  39. if (400 > offset && this.props.onLoadMore && !this.props.isLoading) {
  40. this.props.onLoadMore();
  41. }
  42. if (scrollTop < 100 && this.props.onScrollToTop) {
  43. this.props.onScrollToTop();
  44. } else if (this.props.onScroll) {
  45. this.props.onScroll();
  46. }
  47. }
  48. }, 150, {
  49. trailing: true,
  50. });
  51. componentDidMount () {
  52. this.attachScrollListener();
  53. this.attachIntersectionObserver();
  54. attachFullscreenListener(this.onFullScreenChange);
  55. // Handle initial scroll posiiton
  56. this.handleScroll();
  57. }
  58. getScrollPosition = () => {
  59. if (this.node && this.node.scrollTop > 0) {
  60. return {height: this.node.scrollHeight, top: this.node.scrollTop};
  61. } else {
  62. return null;
  63. }
  64. }
  65. updateScrollBottom = (snapshot) => {
  66. const newScrollTop = this.node.scrollHeight - snapshot;
  67. if (this.node.scrollTop !== newScrollTop) {
  68. this.node.scrollTop = newScrollTop;
  69. }
  70. }
  71. getSnapshotBeforeUpdate (prevProps, prevState) {
  72. const someItemInserted = React.Children.count(prevProps.children) > 0 &&
  73. React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
  74. this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
  75. if (someItemInserted && this.node.scrollTop > 0) {
  76. return this.node.scrollHeight - this.node.scrollTop;
  77. } else {
  78. return null;
  79. }
  80. }
  81. componentDidUpdate (prevProps, prevState, snapshot) {
  82. // Reset the scroll position when a new child comes in in order not to
  83. // jerk the scrollbar around if you're already scrolled down the page.
  84. if (snapshot !== null) this.updateScrollBottom(snapshot);
  85. }
  86. componentWillUnmount () {
  87. this.detachScrollListener();
  88. this.detachIntersectionObserver();
  89. detachFullscreenListener(this.onFullScreenChange);
  90. }
  91. onFullScreenChange = () => {
  92. this.setState({ fullscreen: isFullscreen() });
  93. }
  94. attachIntersectionObserver () {
  95. this.intersectionObserverWrapper.connect({
  96. root: this.node,
  97. rootMargin: '300% 0px',
  98. });
  99. }
  100. detachIntersectionObserver () {
  101. this.intersectionObserverWrapper.disconnect();
  102. }
  103. attachScrollListener () {
  104. this.node.addEventListener('scroll', this.handleScroll);
  105. }
  106. detachScrollListener () {
  107. this.node.removeEventListener('scroll', this.handleScroll);
  108. }
  109. getFirstChildKey (props) {
  110. const { children } = props;
  111. let firstChild = children;
  112. if (children instanceof ImmutableList) {
  113. firstChild = children.get(0);
  114. } else if (Array.isArray(children)) {
  115. firstChild = children[0];
  116. }
  117. return firstChild && firstChild.key;
  118. }
  119. setRef = (c) => {
  120. this.node = c;
  121. }
  122. handleLoadMore = (e) => {
  123. e.preventDefault();
  124. this.props.onLoadMore();
  125. }
  126. render () {
  127. const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
  128. const { fullscreen } = this.state;
  129. const childrenCount = React.Children.count(children);
  130. const loadMore = (hasMore && childrenCount > 0 && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
  131. let scrollableArea = null;
  132. if (isLoading || childrenCount > 0 || !emptyMessage) {
  133. scrollableArea = (
  134. <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
  135. <div role='feed' className='item-list'>
  136. {prepend}
  137. {React.Children.map(this.props.children, (child, index) => (
  138. <IntersectionObserverArticleContainer
  139. key={child.key}
  140. id={child.key}
  141. index={index}
  142. listLength={childrenCount}
  143. intersectionObserverWrapper={this.intersectionObserverWrapper}
  144. saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
  145. >
  146. {React.cloneElement(child, {getScrollPosition: this.getScrollPosition, updateScrollBottom: this.updateScrollBottom})}
  147. </IntersectionObserverArticleContainer>
  148. ))}
  149. {loadMore}
  150. </div>
  151. </div>
  152. );
  153. } else {
  154. scrollableArea = (
  155. <div className='empty-column-indicator' ref={this.setRef}>
  156. {emptyMessage}
  157. </div>
  158. );
  159. }
  160. if (trackScroll) {
  161. return (
  162. <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
  163. {scrollableArea}
  164. </ScrollContainer>
  165. );
  166. } else {
  167. return scrollableArea;
  168. }
  169. }
  170. }