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