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';
14 const MOUSE_IDLE_DELAY
= 300;
16 export default class ScrollableList
extends PureComponent
{
18 static contextTypes
= {
19 router: PropTypes
.object
,
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
,
41 static defaultProps
= {
47 cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
50 intersectionObserverWrapper
= new IntersectionObserverWrapper();
52 handleScroll
= throttle(() => {
54 const scrollTop
= this.getScrollTop();
55 const scrollHeight
= this.getScrollHeight();
56 const clientHeight
= this.getClientHeight();
57 const offset
= scrollHeight
- scrollTop
- clientHeight
;
59 if (400 > offset
&& this.props
.onLoadMore
&& this.props
.hasMore
&& !this.props
.isLoading
) {
60 this.props
.onLoadMore();
63 if (scrollTop
< 100 && this.props
.onScrollToTop
) {
64 this.props
.onScrollToTop();
65 } else if (this.props
.onScroll
) {
66 this.props
.onScroll();
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;
74 this.lastScrollWasSynthetic
= false;
80 mouseIdleTimer
= null;
81 mouseMovedRecently
= false;
82 lastScrollWasSynthetic
= false;
83 scrollToTopOnMouseIdle
= false;
85 setScrollTop
= newScrollTop
=> {
86 if (this.getScrollTop() !== newScrollTop
) {
87 this.lastScrollWasSynthetic
= true;
89 if (this.props
.bindToDocument
) {
90 document
.scrollingElement
.scrollTop
= newScrollTop
;
92 this.node
.scrollTop
= newScrollTop
;
97 clearMouseIdleTimer
= () => {
98 if (this.mouseIdleTimer
=== null) {
102 clearTimeout(this.mouseIdleTimer
);
103 this.mouseIdleTimer
= null;
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
);
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;
116 // Save setting this flag for last, so we can do the comparison above.
117 this.mouseMovedRecently
= true;
118 }, MOUSE_IDLE_DELAY
/ 2);
120 handleWheel
= throttle(() => {
121 this.scrollToTopOnMouseIdle
= false;
126 handleMouseIdle
= () => {
127 if (this.scrollToTopOnMouseIdle
) {
128 this.setScrollTop(0);
131 this.mouseMovedRecently
= false;
132 this.scrollToTopOnMouseIdle
= false;
135 componentDidMount () {
136 this.attachScrollListener();
137 this.attachIntersectionObserver();
139 attachFullscreenListener(this.onFullScreenChange
);
141 // Handle initial scroll posiiton
145 getScrollPosition
= () => {
146 if (this.node
&& (this.getScrollTop() > 0 || this.mouseMovedRecently
)) {
147 return { height: this.getScrollHeight(), top: this.getScrollTop() };
153 getScrollTop
= () => {
154 return this.props
.bindToDocument
? document
.scrollingElement
.scrollTop : this.node
.scrollTop
;
157 getScrollHeight
= () => {
158 return this.props
.bindToDocument
? document
.scrollingElement
.scrollHeight : this.node
.scrollHeight
;
161 getClientHeight
= () => {
162 return this.props
.bindToDocument
? document
.scrollingElement
.clientHeight : this.node
.clientHeight
;
165 updateScrollBottom
= (snapshot
) => {
166 const newScrollTop
= this.getScrollHeight() - snapshot
;
168 this.setScrollTop(newScrollTop
);
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);
177 if (pendingChanged
|| someItemInserted
&& (this.getScrollTop() > 0 || this.mouseMovedRecently
)) {
178 return this.getScrollHeight() - this.getScrollTop();
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
);
192 cacheMediaWidth
= (width
) => {
193 if (width
&& this.state
.cachedMediaWidth
!== width
) {
194 this.setState({ cachedMediaWidth: width
});
198 componentWillUnmount () {
199 this.clearMouseIdleTimer();
200 this.detachScrollListener();
201 this.detachIntersectionObserver();
203 detachFullscreenListener(this.onFullScreenChange
);
206 onFullScreenChange
= () => {
207 this.setState({ fullscreen: isFullscreen() });
210 attachIntersectionObserver () {
211 this.intersectionObserverWrapper
.connect({
213 rootMargin: '300% 0px',
217 detachIntersectionObserver () {
218 this.intersectionObserverWrapper
.disconnect();
221 attachScrollListener () {
222 if (this.props
.bindToDocument
) {
223 document
.addEventListener('scroll', this.handleScroll
);
224 document
.addEventListener('wheel', this.handleWheel
);
226 this.node
.addEventListener('scroll', this.handleScroll
);
227 this.node
.addEventListener('wheel', this.handleWheel
);
231 detachScrollListener () {
232 if (this.props
.bindToDocument
) {
233 document
.removeEventListener('scroll', this.handleScroll
);
234 document
.removeEventListener('wheel', this.handleWheel
);
236 this.node
.removeEventListener('scroll', this.handleScroll
);
237 this.node
.removeEventListener('wheel', this.handleWheel
);
241 getFirstChildKey (props
) {
242 const { children
} = props
;
243 let firstChild
= children
;
245 if (children
instanceof ImmutableList
) {
246 firstChild
= children
.get(0);
247 } else if (Array
.isArray(children
)) {
248 firstChild
= children
[0];
251 return firstChild
&& firstChild
.key
;
258 handleLoadMore
= e
=> {
260 this.props
.onLoadMore();
263 handleLoadPending
= e
=> {
265 this.props
.onLoadPending();
266 // Prevent the weird scroll-jumping behavior, as we explicitly don't want to
267 // scroll to top, and we know the scroll height is going to change
268 this.scrollToTopOnMouseIdle
= false;
269 this.lastScrollWasSynthetic
= false;
270 this.clearMouseIdleTimer();
271 this.mouseIdleTimer
= setTimeout(this.handleMouseIdle
, MOUSE_IDLE_DELAY
);
272 this.mouseMovedRecently
= true;
276 const { children
, scrollKey
, trackScroll
, shouldUpdateScroll
, showLoading
, isLoading
, hasMore
, numPending
, prepend
, alwaysPrepend
, emptyMessage
, onLoadMore
} = this.props
;
277 const { fullscreen
} = this.state
;
278 const childrenCount
= React
.Children
.count(children
);
280 const loadMore
= (hasMore
&& onLoadMore
) ? <LoadMore visible
={!isLoading
} onClick
={this.handleLoadMore
} /> : null;
281 const loadPending
= (numPending
> 0) ? <LoadPending count
={numPending
} onClick
={this.handleLoadPending
} /> : null;
282 let scrollableArea
= null;
286 <div className
='scrollable scrollable--flex' ref
={this.setRef
}>
287 <div role
='feed' className
='item-list'>
291 <div className
='scrollable__append'>
296 } else if (isLoading
|| childrenCount
> 0 || hasMore
|| !emptyMessage
) {
298 <div className
={classNames('scrollable', { fullscreen
})} ref
={this.setRef
} onMouseMove
={this.handleMouseMove
}>
299 <div role
='feed' className
='item-list'>
304 {React
.Children
.map(this.props
.children
, (child
, index
) => (
305 <IntersectionObserverArticleContainer
309 listLength
={childrenCount
}
310 intersectionObserverWrapper
={this.intersectionObserverWrapper
}
311 saveHeightKey
={trackScroll
? `${this.context.router.route.location.key}:${scrollKey}` : null}
313 {React
.cloneElement(child
, {
314 getScrollPosition: this.getScrollPosition
,
315 updateScrollBottom: this.updateScrollBottom
,
316 cachedMediaWidth: this.state
.cachedMediaWidth
,
317 cacheMediaWidth: this.cacheMediaWidth
,
319 </IntersectionObserverArticleContainer
>
328 <div className
={classNames('scrollable scrollable--flex', { fullscreen
})} ref
={this.setRef
}>
329 {alwaysPrepend
&& prepend
}
331 <div className
='empty-column-indicator'>
340 <ScrollContainer scrollKey
={scrollKey
} shouldUpdateScroll
={shouldUpdateScroll
}>
345 return scrollableArea
;