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.

368 lines
11 KiB

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