]> cat aescling's git repositories - mastodon.git/blob - app/javascript/mastodon/components/scrollable_list.js
Fix unread indicator not updating for notifications (#11923)
[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
206 onFullScreenChange = () => {
207 this.setState({ fullscreen: isFullscreen() });
208 }
209
210 attachIntersectionObserver () {
211 this.intersectionObserverWrapper.connect({
212 root: this.node,
213 rootMargin: '300% 0px',
214 });
215 }
216
217 detachIntersectionObserver () {
218 this.intersectionObserverWrapper.disconnect();
219 }
220
221 attachScrollListener () {
222 if (this.props.bindToDocument) {
223 document.addEventListener('scroll', this.handleScroll);
224 document.addEventListener('wheel', this.handleWheel);
225 } else {
226 this.node.addEventListener('scroll', this.handleScroll);
227 this.node.addEventListener('wheel', this.handleWheel);
228 }
229 }
230
231 detachScrollListener () {
232 if (this.props.bindToDocument) {
233 document.removeEventListener('scroll', this.handleScroll);
234 document.removeEventListener('wheel', this.handleWheel);
235 } else {
236 this.node.removeEventListener('scroll', this.handleScroll);
237 this.node.removeEventListener('wheel', this.handleWheel);
238 }
239 }
240
241 getFirstChildKey (props) {
242 const { children } = props;
243 let firstChild = children;
244
245 if (children instanceof ImmutableList) {
246 firstChild = children.get(0);
247 } else if (Array.isArray(children)) {
248 firstChild = children[0];
249 }
250
251 return firstChild && firstChild.key;
252 }
253
254 setRef = (c) => {
255 this.node = c;
256 }
257
258 handleLoadMore = e => {
259 e.preventDefault();
260 this.props.onLoadMore();
261 }
262
263 handleLoadPending = e => {
264 e.preventDefault();
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;
273 }
274
275 render () {
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);
279
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;
283
284 if (showLoading) {
285 scrollableArea = (
286 <div className='scrollable scrollable--flex' ref={this.setRef}>
287 <div role='feed' className='item-list'>
288 {prepend}
289 </div>
290
291 <div className='scrollable__append'>
292 <LoadingIndicator />
293 </div>
294 </div>
295 );
296 } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
297 scrollableArea = (
298 <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
299 <div role='feed' className='item-list'>
300 {prepend}
301
302 {loadPending}
303
304 {React.Children.map(this.props.children, (child, index) => (
305 <IntersectionObserverArticleContainer
306 key={child.key}
307 id={child.key}
308 index={index}
309 listLength={childrenCount}
310 intersectionObserverWrapper={this.intersectionObserverWrapper}
311 saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
312 >
313 {React.cloneElement(child, {
314 getScrollPosition: this.getScrollPosition,
315 updateScrollBottom: this.updateScrollBottom,
316 cachedMediaWidth: this.state.cachedMediaWidth,
317 cacheMediaWidth: this.cacheMediaWidth,
318 })}
319 </IntersectionObserverArticleContainer>
320 ))}
321
322 {loadMore}
323 </div>
324 </div>
325 );
326 } else {
327 scrollableArea = (
328 <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}>
329 {alwaysPrepend && prepend}
330
331 <div className='empty-column-indicator'>
332 {emptyMessage}
333 </div>
334 </div>
335 );
336 }
337
338 if (trackScroll) {
339 return (
340 <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
341 {scrollableArea}
342 </ScrollContainer>
343 );
344 } else {
345 return scrollableArea;
346 }
347 }
348
349 }
This page took 0.138739 seconds and 6 git commands to generate.