commit: 0827c09c448ea8d61e62534dd3547719e148a4ae
parent: 938cd2875b14db3655a6c9f82f672f4baf7720a3
Author: abcang <abcang1015@gmail.com>
Date: Tue, 29 Aug 2017 05:23:44 +0900
Generalized the infinite scrollable list (#4697)
Diffstat:
8 files changed, 379 insertions(+), 323 deletions(-)
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
@@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent {
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
+ hidden: PropTypes.bool,
};
handleFollow = () => {
@@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent {
}
render () {
- const { account, me, intl } = this.props;
+ const { account, me, intl, hidden } = this.props;
if (!account) {
return <div />;
}
+ if (hidden) {
+ return (
+ <div>
+ {account.get('display_name')}
+ {account.get('username')}
+ </div>
+ );
+ }
+
let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) {
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -0,0 +1,122 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
+
+export default class IntersectionObserverArticle extends ImmutablePureComponent {
+
+ static propTypes = {
+ intersectionObserverWrapper: PropTypes.object,
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ children: PropTypes.node,
+ };
+
+ state = {
+ isHidden: false, // set to true in requestIdleCallback to trigger un-render
+ }
+
+ shouldComponentUpdate (nextProps, nextState) {
+ if (!nextState.isIntersecting && nextState.isHidden) {
+ // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
+ // that either "isIntersecting" or "isHidden" matter, and then they're
+ // the only things that matter (and updated ARIA attributes).
+ return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
+ } else if (nextState.isIntersecting && !this.state.isIntersecting) {
+ // If we're going from a non-intersecting state to an intersecting state,
+ // (i.e. offscreen to onscreen), then we definitely need to re-render
+ return true;
+ }
+ // Otherwise, diff based on "updateOnProps" and "updateOnStates"
+ return super.shouldComponentUpdate(nextProps, nextState);
+ }
+
+ componentDidMount () {
+ if (!this.props.intersectionObserverWrapper) {
+ // TODO: enable IntersectionObserver optimization for notification statuses.
+ // These are managed in notifications/index.js rather than status_list.js
+ return;
+ }
+ this.props.intersectionObserverWrapper.observe(
+ this.props.id,
+ this.node,
+ this.handleIntersection
+ );
+
+ this.componentMounted = true;
+ }
+
+ componentWillUnmount () {
+ if (this.props.intersectionObserverWrapper) {
+ this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
+ }
+
+ this.componentMounted = false;
+ }
+
+ handleIntersection = (entry) => {
+ if (this.node && this.node.children.length !== 0) {
+ // save the height of the fully-rendered element
+ this.height = getRectFromEntry(entry).height;
+
+ if (this.props.onHeightChange) {
+ this.props.onHeightChange(this.props.status, this.height);
+ }
+ }
+
+ this.setState((prevState) => {
+ if (prevState.isIntersecting && !entry.isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting: entry.isIntersecting,
+ isHidden: false,
+ };
+ });
+ }
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) {
+ return;
+ }
+
+ // When the browser gets a chance, test if we're still not intersecting,
+ // and if so, set our isHidden to true to trigger an unrender. The point of
+ // this is to save DOM nodes and avoid using up too much memory.
+ // See: https://github.com/tootsuite/mastodon/issues/2900
+ this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+ }
+
+ handleRef = (node) => {
+ this.node = node;
+ }
+
+ render () {
+ const { children, id, index, listLength } = this.props;
+ const { isIntersecting, isHidden } = this.state;
+
+ if (!isIntersecting && isHidden) {
+ return (
+ <article
+ ref={this.handleRef}
+ aria-posinset={index}
+ aria-setsize={listLength}
+ style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}
+ data-id={id}
+ tabIndex='0'
+ >
+ {children && React.cloneElement(children, { hidden: true })}
+ </article>
+ );
+ }
+
+ return (
+ <article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'>
+ {children && React.cloneElement(children, { hidden: false })}
+ </article>
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
@@ -0,0 +1,179 @@
+import React, { PureComponent } from 'react';
+import { ScrollContainer } from 'react-router-scroll';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticle from './intersection_observer_article';
+import LoadMore from './load_more';
+import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+
+export default class ScrollableList extends PureComponent {
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ onScrollToBottom: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ shouldUpdateScroll: PropTypes.func,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ children: PropTypes.node,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+ handleScroll = throttle(() => {
+ if (this.node) {
+ const { scrollTop, scrollHeight, clientHeight } = this.node;
+ const offset = scrollHeight - scrollTop - clientHeight;
+ this._oldScrollPosition = scrollHeight - scrollTop;
+
+ if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+ this.props.onScrollToBottom();
+ } else if (scrollTop < 100 && this.props.onScrollToTop) {
+ this.props.onScrollToTop();
+ } else if (this.props.onScroll) {
+ this.props.onScroll();
+ }
+ }
+ }, 150, {
+ trailing: true,
+ });
+
+ componentDidMount () {
+ this.attachScrollListener();
+ this.attachIntersectionObserver();
+
+ // Handle initial scroll posiiton
+ this.handleScroll();
+ }
+
+ componentDidUpdate (prevProps) {
+ // Reset the scroll position when a new child comes in in order not to
+ // jerk the scrollbar around if you're already scrolled down the page.
+ if (React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this._oldScrollPosition && this.node.scrollTop > 0) {
+ if (this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props)) {
+ const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
+ if (this.node.scrollTop !== newScrollTop) {
+ this.node.scrollTop = newScrollTop;
+ }
+ } else {
+ this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
+ }
+ }
+ }
+
+ componentWillUnmount () {
+ this.detachScrollListener();
+ this.detachIntersectionObserver();
+ }
+
+ attachIntersectionObserver () {
+ this.intersectionObserverWrapper.connect({
+ root: this.node,
+ rootMargin: '300% 0px',
+ });
+ }
+
+ detachIntersectionObserver () {
+ this.intersectionObserverWrapper.disconnect();
+ }
+
+ attachScrollListener () {
+ this.node.addEventListener('scroll', this.handleScroll);
+ }
+
+ detachScrollListener () {
+ this.node.removeEventListener('scroll', this.handleScroll);
+ }
+
+ getFirstChildKey (props) {
+ const { children } = props;
+ const firstChild = Array.isArray(children) ? children[0] : children;
+ return firstChild && firstChild.key;
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.onScrollToBottom();
+ }
+
+ handleKeyDown = (e) => {
+ if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
+ const article = (() => {
+ switch (e.key) {
+ case 'PageDown':
+ return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
+ case 'PageUp':
+ return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
+ case 'End':
+ return this.node.querySelector('[role="feed"] > article:last-of-type');
+ case 'Home':
+ return this.node.querySelector('[role="feed"] > article:first-of-type');
+ default:
+ return null;
+ }
+ })();
+
+
+ if (article) {
+ e.preventDefault();
+ article.focus();
+ article.scrollIntoView();
+ }
+ }
+ }
+
+ render () {
+ const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+ const childrenCount = React.Children.count(children);
+
+ const loadMore = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />;
+ let scrollableArea = null;
+
+ if (isLoading || childrenCount > 0 || !emptyMessage) {
+ scrollableArea = (
+ <div className='scrollable' ref={this.setRef}>
+ <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
+ {prepend}
+
+ {React.Children.map(this.props.children, (child, index) => (
+ <IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}>
+ {child}
+ </IntersectionObserverArticle>
+ ))}
+
+ {loadMore}
+ </div>
+ </div>
+ );
+ } else {
+ scrollableArea = (
+ <div className='empty-column-indicator' ref={this.setRef}>
+ {emptyMessage}
+ </div>
+ );
+ }
+
+ if (trackScroll) {
+ return (
+ <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+ {scrollableArea}
+ </ScrollContainer>
+ );
+ } else {
+ return scrollableArea;
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
@@ -9,13 +9,11 @@ import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
-import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
export default class Status extends ImmutablePureComponent {
@@ -26,7 +24,6 @@ export default class Status extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
- wrapped: PropTypes.bool,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
@@ -40,14 +37,11 @@ export default class Status extends ImmutablePureComponent {
boostModal: PropTypes.bool,
autoPlayGif: PropTypes.bool,
muted: PropTypes.bool,
- intersectionObserverWrapper: PropTypes.object,
- index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ hidden: PropTypes.bool,
};
state = {
isExpanded: false,
- isHidden: false, // set to true in requestIdleCallback to trigger un-render
}
// Avoid checking props that are functions (and whose equality will always
@@ -55,91 +49,15 @@ export default class Status extends ImmutablePureComponent {
updateOnProps = [
'status',
'account',
- 'wrapped',
'me',
'boostModal',
'autoPlayGif',
'muted',
- 'listLength',
+ 'hidden',
]
updateOnStates = ['isExpanded']
- shouldComponentUpdate (nextProps, nextState) {
- if (!nextState.isIntersecting && nextState.isHidden) {
- // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
- // that either "isIntersecting" or "isHidden" matter, and then they're
- // the only things that matter (and updated ARIA attributes).
- return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
- } else if (nextState.isIntersecting && !this.state.isIntersecting) {
- // If we're going from a non-intersecting state to an intersecting state,
- // (i.e. offscreen to onscreen), then we definitely need to re-render
- return true;
- }
- // Otherwise, diff based on "updateOnProps" and "updateOnStates"
- return super.shouldComponentUpdate(nextProps, nextState);
- }
-
- componentDidMount () {
- if (!this.props.intersectionObserverWrapper) {
- // TODO: enable IntersectionObserver optimization for notification statuses.
- // These are managed in notifications/index.js rather than status_list.js
- return;
- }
- this.props.intersectionObserverWrapper.observe(
- this.props.id,
- this.node,
- this.handleIntersection
- );
-
- this.componentMounted = true;
- }
-
- componentWillUnmount () {
- if (this.props.intersectionObserverWrapper) {
- this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
- }
-
- this.componentMounted = false;
- }
-
- handleIntersection = (entry) => {
- if (this.node && this.node.children.length !== 0) {
- // save the height of the fully-rendered element
- this.height = getRectFromEntry(entry).height;
-
- if (this.props.onHeightChange) {
- this.props.onHeightChange(this.props.status, this.height);
- }
- }
-
- this.setState((prevState) => {
- if (prevState.isIntersecting && !entry.isIntersecting) {
- scheduleIdleTask(this.hideIfNotIntersecting);
- }
- return {
- isIntersecting: entry.isIntersecting,
- isHidden: false,
- };
- });
- }
-
- hideIfNotIntersecting = () => {
- if (!this.componentMounted) {
- return;
- }
-
- // When the browser gets a chance, test if we're still not intersecting,
- // and if so, set our isHidden to true to trigger an unrender. The point of
- // this is to save DOM nodes and avoid using up too much memory.
- // See: https://github.com/tootsuite/mastodon/issues/2900
- this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
- }
-
- handleRef = (node) => {
- this.node = node;
- }
-
handleClick = () => {
if (!this.context.router) {
return;
@@ -173,25 +91,19 @@ export default class Status extends ImmutablePureComponent {
let media = null;
let statusAvatar;
- // Exclude intersectionObserverWrapper from `other` variable
- // because intersection is managed in here.
- const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props;
- const { isExpanded, isIntersecting, isHidden } = this.state;
+ const { status, account, hidden, ...other } = this.props;
+ const { isExpanded } = this.state;
if (status === null) {
return null;
}
- const hasIntersectionObserverWrapper = !!this.props.intersectionObserverWrapper;
- const isHiddenForSure = isIntersecting === false && isHidden;
- const visibilityUnknownButHeightIsCached = isIntersecting === undefined && status.has('height');
-
- if (hasIntersectionObserverWrapper && (isHiddenForSure || visibilityUnknownButHeightIsCached)) {
+ if (hidden) {
return (
- <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height || status.get('height')}px`, opacity: 0, overflow: 'hidden' }}>
+ <div>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')}
- </article>
+ </div>
);
}
@@ -199,14 +111,14 @@ export default class Status extends ImmutablePureComponent {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
return (
- <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'>
+ <div className='status__wrapper' data-id={status.get('id')} >
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
</div>
- <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} />
- </article>
+ <Status {...other} status={status.get('reblog')} account={status.get('account')} />
+ </div>
);
}
@@ -235,7 +147,7 @@ export default class Status extends ImmutablePureComponent {
}
return (
- <article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} tabIndex={wrapped ? null : '0'} ref={this.handleRef}>
+ <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
@@ -253,7 +165,7 @@ export default class Status extends ImmutablePureComponent {
{media}
<StatusActionBar {...this.props} />
- </article>
+ </div>
);
}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
@@ -1,12 +1,9 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
-import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container';
-import LoadMore from './load_more';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
-import { throttle } from 'lodash';
+import ScrollableList from './scrollable_list';
export default class StatusList extends ImmutablePureComponent {
@@ -28,145 +25,21 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true,
};
- intersectionObserverWrapper = new IntersectionObserverWrapper();
-
- handleScroll = throttle(() => {
- if (this.node) {
- const { scrollTop, scrollHeight, clientHeight } = this.node;
- const offset = scrollHeight - scrollTop - clientHeight;
- this._oldScrollPosition = scrollHeight - scrollTop;
-
- if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
- this.props.onScrollToBottom();
- } else if (scrollTop < 100 && this.props.onScrollToTop) {
- this.props.onScrollToTop();
- } else if (this.props.onScroll) {
- this.props.onScroll();
- }
- }
- }, 150, {
- trailing: true,
- });
-
- componentDidMount () {
- this.attachScrollListener();
- this.attachIntersectionObserver();
-
- // Handle initial scroll posiiton
- this.handleScroll();
- }
-
- componentDidUpdate (prevProps) {
- // Reset the scroll position when a new toot comes in in order not to
- // jerk the scrollbar around if you're already scrolled down the page.
- if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) {
- if (prevProps.statusIds.first() !== this.props.statusIds.first()) {
- let newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
- if (this.node.scrollTop !== newScrollTop) {
- this.node.scrollTop = newScrollTop;
- }
- } else {
- this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
- }
- }
- }
-
- componentWillUnmount () {
- this.detachScrollListener();
- this.detachIntersectionObserver();
- }
-
- attachIntersectionObserver () {
- this.intersectionObserverWrapper.connect({
- root: this.node,
- rootMargin: '300% 0px',
- });
- }
-
- detachIntersectionObserver () {
- this.intersectionObserverWrapper.disconnect();
- }
-
- attachScrollListener () {
- this.node.addEventListener('scroll', this.handleScroll);
- }
-
- detachScrollListener () {
- this.node.removeEventListener('scroll', this.handleScroll);
- }
-
- setRef = (c) => {
- this.node = c;
- }
-
- handleLoadMore = (e) => {
- e.preventDefault();
- this.props.onScrollToBottom();
- }
-
- handleKeyDown = (e) => {
- if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
- const article = (() => {
- switch (e.key) {
- case 'PageDown':
- return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
- case 'PageUp':
- return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
- case 'End':
- return this.node.querySelector('[role="feed"] > article:last-of-type');
- case 'Home':
- return this.node.querySelector('[role="feed"] > article:first-of-type');
- default:
- return null;
- }
- })();
-
-
- if (article) {
- e.preventDefault();
- article.focus();
- article.scrollIntoView();
- }
- }
- }
-
render () {
- const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
-
- const loadMore = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />;
- let scrollableArea = null;
-
- if (isLoading || statusIds.size > 0 || !emptyMessage) {
- scrollableArea = (
- <div className='scrollable' ref={this.setRef}>
- <div role='feed' className='status-list' onKeyDown={this.handleKeyDown}>
- {prepend}
-
- {statusIds.map((statusId, index) => {
- return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
- })}
-
- {loadMore}
- </div>
- </div>
- );
- } else {
- scrollableArea = (
- <div className='empty-column-indicator' ref={this.setRef}>
- {emptyMessage}
- </div>
- );
- }
-
- if (trackScroll) {
- return (
- <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
- {scrollableArea}
- </ScrollContainer>
- );
- } else {
- return scrollableArea;
- }
+ const { statusIds, ...other } = this.props;
+ const { isLoading } = other;
+
+ const scrollableContent = (isLoading || statusIds.size > 0) ? (
+ statusIds.map((statusId) => (
+ <StatusContainer key={statusId} id={statusId} />
+ ))
+ ) : null;
+
+ return (
+ <ScrollableList {...other}>
+ {scrollableContent}
+ </ScrollableList>
+ );
}
}
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -16,6 +16,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+ hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
});
@connect(mapStateToProps)
@@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
+ hasMore: PropTypes.bool,
};
componentWillMount () {
@@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent {
}
render () {
- const { intl, statusIds, columnId, multiColumn } = this.props;
+ const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId;
return (
@@ -81,6 +83,7 @@ export default class Favourites extends ImmutablePureComponent {
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`favourited_statuses-${columnId}`}
+ hasMore={hasMore}
onScrollToBottom={this.handleScrollToBottom}
/>
</Column>
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -1,4 +1,5 @@
import React from 'react';
+import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusContainer from '../../../containers/status_container';
import AccountContainer from '../../../containers/account_container';
@@ -10,6 +11,7 @@ export default class Notification extends ImmutablePureComponent {
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
+ hidden: PropTypes.bool,
};
renderFollow (account, link) {
@@ -23,13 +25,13 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
</div>
- <AccountContainer id={account.get('id')} withNote={false} />
+ <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div>
);
}
renderMention (notification) {
- return <StatusContainer id={notification.get('status')} withDismiss />;
+ return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
}
renderFavourite (notification, link) {
@@ -42,7 +44,7 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
</div>
- <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+ <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
</div>
);
}
@@ -57,7 +59,7 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div>
- <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+ <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
</div>
);
}
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
@@ -7,13 +7,12 @@ import ColumnHeader from '../../components/column_header';
import { expandNotifications, scrollTopNotifications } from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container';
-import { ScrollContainer } from 'react-router-scroll';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable';
-import LoadMore from '../../components/load_more';
import { debounce } from 'lodash';
+import ScrollableList from '../../components/scrollable_list';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -51,40 +50,18 @@ export default class Notifications extends React.PureComponent {
trackScroll: true,
};
- dispatchExpandNotifications = debounce(() => {
+ handleScrollToBottom = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(false));
this.props.dispatch(expandNotifications());
}, 300, { leading: true });
- dispatchScrollToTop = debounce((top) => {
- this.props.dispatch(scrollTopNotifications(top));
+ handleScrollToTop = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(true));
}, 100);
- handleScroll = (e) => {
- const { scrollTop, scrollHeight, clientHeight } = e.target;
- const offset = scrollHeight - scrollTop - clientHeight;
- this._oldScrollPosition = scrollHeight - scrollTop;
-
- if (250 > offset && this.props.hasMore && !this.props.isLoading) {
- this.dispatchExpandNotifications();
- }
-
- if (scrollTop < 100) {
- this.dispatchScrollToTop(true);
- } else {
- this.dispatchScrollToTop(false);
- }
- }
-
- componentDidUpdate (prevProps) {
- if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) {
- this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
- }
- }
-
- handleLoadMore = (e) => {
- e.preventDefault();
- this.dispatchExpandNotifications();
- }
+ handleScroll = debounce(() => {
+ this.props.dispatch(scrollTopNotifications(false));
+ }, 100);
handlePin = () => {
const { columnId, dispatch } = this.props;
@@ -105,10 +82,6 @@ export default class Notifications extends React.PureComponent {
this.column.scrollTop();
}
- setRef = (c) => {
- this.node = c;
- }
-
setColumnRef = c => {
this.column = c;
}
@@ -116,52 +89,34 @@ export default class Notifications extends React.PureComponent {
render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId;
+ const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
- let loadMore = '';
- let scrollableArea = '';
- let unread = '';
- let scrollContainer = '';
+ let scrollableContent = null;
- if (!isLoading && hasMore) {
- loadMore = <LoadMore onClick={this.handleLoadMore} />;
- }
-
- if (isUnread) {
- unread = <div className='notifications__unread-indicator' />;
- }
-
- if (isLoading && this.scrollableArea) {
- scrollableArea = this.scrollableArea;
+ if (isLoading && this.scrollableContent) {
+ scrollableContent = this.scrollableContent;
} else if (notifications.size > 0 || hasMore) {
- scrollableArea = (
- <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
- {unread}
-
- <div>
- {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
- {loadMore}
- </div>
- </div>
- );
+ scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
} else {
- scrollableArea = (
- <div className='empty-column-indicator' ref={this.setRef}>
- <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
- </div>
- );
+ scrollableContent = null;
}
- if (pinned) {
- scrollContainer = scrollableArea;
- } else {
- scrollContainer = (
- <ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}>
- {scrollableArea}
- </ScrollContainer>
- );
- }
-
- this.scrollableArea = scrollableArea;
+ this.scrollableContent = scrollableContent;
+
+ const scrollContainer = (
+ <ScrollableList
+ scrollKey={`notifications-${columnId}`}
+ isLoading={isLoading}
+ hasMore={hasMore}
+ emptyMessage={emptyMessage}
+ onScrollToBottom={this.handleScrollToBottom}
+ onScrollToTop={this.handleScrollToTop}
+ onScroll={this.handleScroll}
+ shouldUpdateScroll={shouldUpdateScroll}
+ >
+ {scrollableContent}
+ </ScrollableList>
+ );
return (
<Column ref={this.setColumnRef}>