]> cat aescling's git repositories - mastodon.git/blob - app/javascript/mastodon/components/scrollable_list.js
Fix invisible load more button (#4962)
[mastodon.git] / app / javascript / mastodon / components / scrollable_list.js
1 import React, { PureComponent } from 'react';
2 import { ScrollContainer } from 'react-router-scroll';
3 import PropTypes from 'prop-types';
4 import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
5 import LoadMore from './load_more';
6 import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
7 import { throttle } from 'lodash';
8 import { List as ImmutableList } from 'immutable';
9
10 export default class ScrollableList extends PureComponent {
11
12 static contextTypes = {
13 router: PropTypes.object,
14 };
15
16 static propTypes = {
17 scrollKey: PropTypes.string.isRequired,
18 onScrollToBottom: PropTypes.func,
19 onScrollToTop: PropTypes.func,
20 onScroll: PropTypes.func,
21 trackScroll: PropTypes.bool,
22 shouldUpdateScroll: PropTypes.func,
23 isLoading: PropTypes.bool,
24 hasMore: PropTypes.bool,
25 prepend: PropTypes.node,
26 emptyMessage: PropTypes.node,
27 children: PropTypes.node,
28 };
29
30 static defaultProps = {
31 trackScroll: true,
32 };
33
34 state = {
35 lastMouseMove: null,
36 };
37
38 intersectionObserverWrapper = new IntersectionObserverWrapper();
39
40 handleScroll = throttle(() => {
41 if (this.node) {
42 const { scrollTop, scrollHeight, clientHeight } = this.node;
43 const offset = scrollHeight - scrollTop - clientHeight;
44 this._oldScrollPosition = scrollHeight - scrollTop;
45
46 if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
47 this.props.onScrollToBottom();
48 } else if (scrollTop < 100 && this.props.onScrollToTop) {
49 this.props.onScrollToTop();
50 } else if (this.props.onScroll) {
51 this.props.onScroll();
52 }
53 }
54 }, 150, {
55 trailing: true,
56 });
57
58 handleMouseMove = throttle(() => {
59 this._lastMouseMove = new Date();
60 }, 300);
61
62 handleMouseLeave = () => {
63 this._lastMouseMove = null;
64 }
65
66 componentDidMount () {
67 this.attachScrollListener();
68 this.attachIntersectionObserver();
69
70 // Handle initial scroll posiiton
71 this.handleScroll();
72 }
73
74 componentDidUpdate (prevProps) {
75 const someItemInserted = React.Children.count(prevProps.children) > 0 &&
76 React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
77 this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
78
79 // Reset the scroll position when a new child comes in in order not to
80 // jerk the scrollbar around if you're already scrolled down the page.
81 if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
82 const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
83
84 if (this.node.scrollTop !== newScrollTop) {
85 this.node.scrollTop = newScrollTop;
86 }
87 } else {
88 this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
89 }
90 }
91
92 componentWillUnmount () {
93 this.detachScrollListener();
94 this.detachIntersectionObserver();
95 }
96
97 attachIntersectionObserver () {
98 this.intersectionObserverWrapper.connect({
99 root: this.node,
100 rootMargin: '300% 0px',
101 });
102 }
103
104 detachIntersectionObserver () {
105 this.intersectionObserverWrapper.disconnect();
106 }
107
108 attachScrollListener () {
109 this.node.addEventListener('scroll', this.handleScroll);
110 }
111
112 detachScrollListener () {
113 this.node.removeEventListener('scroll', this.handleScroll);
114 }
115
116 getFirstChildKey (props) {
117 const { children } = props;
118 let firstChild = children;
119 if (children instanceof ImmutableList) {
120 firstChild = children.get(0);
121 } else if (Array.isArray(children)) {
122 firstChild = children[0];
123 }
124 return firstChild && firstChild.key;
125 }
126
127 setRef = (c) => {
128 this.node = c;
129 }
130
131 handleLoadMore = (e) => {
132 e.preventDefault();
133 this.props.onScrollToBottom();
134 }
135
136 _recentlyMoved () {
137 return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
138 }
139
140 handleKeyDown = (e) => {
141 if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
142 const article = (() => {
143 switch (e.key) {
144 case 'PageDown':
145 return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
146 case 'PageUp':
147 return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
148 case 'End':
149 return this.node.querySelector('[role="feed"] > article:last-of-type');
150 case 'Home':
151 return this.node.querySelector('[role="feed"] > article:first-of-type');
152 default:
153 return null;
154 }
155 })();
156
157
158 if (article) {
159 e.preventDefault();
160 article.focus();
161 article.scrollIntoView();
162 }
163 }
164 }
165
166 render () {
167 const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
168 const childrenCount = React.Children.count(children);
169
170 const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
171 let scrollableArea = null;
172
173 if (isLoading || childrenCount > 0 || !emptyMessage) {
174 scrollableArea = (
175 <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
176 <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
177 {prepend}
178
179 {React.Children.map(this.props.children, (child, index) => (
180 <IntersectionObserverArticleContainer
181 key={child.key}
182 id={child.key}
183 index={index}
184 listLength={childrenCount}
185 intersectionObserverWrapper={this.intersectionObserverWrapper}
186 saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
187 >
188 {child}
189 </IntersectionObserverArticleContainer>
190 ))}
191
192 {loadMore}
193 </div>
194 </div>
195 );
196 } else {
197 scrollableArea = (
198 <div className='empty-column-indicator' ref={this.setRef}>
199 {emptyMessage}
200 </div>
201 );
202 }
203
204 if (trackScroll) {
205 return (
206 <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
207 {scrollableArea}
208 </ScrollContainer>
209 );
210 } else {
211 return scrollableArea;
212 }
213 }
214
215 }
This page took 0.120338 seconds and 5 git commands to generate.