Generalized the infinite scrollable list (#4697)

This commit is contained in:
abcang 2017-08-29 05:23:44 +09:00 committed by Eugen Rochko
parent 938cd2875b
commit 0827c09c44
8 changed files with 376 additions and 320 deletions

View File

@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent {
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
}; };
handleFollow = () => { handleFollow = () => {
@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent {
} }
render () { render () {
const { account, me, intl } = this.props; const { account, me, intl, hidden } = this.props;
if (!account) { if (!account) {
return <div />; return <div />;
} }
if (hidden) {
return (
<div>
{account.get('display_name')}
{account.get('username')}
</div>
);
}
let buttons; let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) { if (account.get('id') !== me && account.get('relationship', null) !== null) {

View File

@ -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>
);
}
}

View File

@ -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;
}
}
}

View File

@ -9,13 +9,11 @@ import StatusContent from './status_content';
import StatusActionBar from './status_action_bar'; import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; 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'; import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
// We use the component (and not the container) since we do not want // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle'; import Bundle from '../features/ui/components/bundle';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
export default class Status extends ImmutablePureComponent { export default class Status extends ImmutablePureComponent {
@ -26,7 +24,6 @@ export default class Status extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
wrapped: PropTypes.bool,
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
@ -40,14 +37,11 @@ export default class Status extends ImmutablePureComponent {
boostModal: PropTypes.bool, boostModal: PropTypes.bool,
autoPlayGif: PropTypes.bool, autoPlayGif: PropTypes.bool,
muted: PropTypes.bool, muted: PropTypes.bool,
intersectionObserverWrapper: PropTypes.object, hidden: PropTypes.bool,
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}; };
state = { state = {
isExpanded: false, isExpanded: false,
isHidden: false, // set to true in requestIdleCallback to trigger un-render
} }
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@ -55,91 +49,15 @@ export default class Status extends ImmutablePureComponent {
updateOnProps = [ updateOnProps = [
'status', 'status',
'account', 'account',
'wrapped',
'me', 'me',
'boostModal', 'boostModal',
'autoPlayGif', 'autoPlayGif',
'muted', 'muted',
'listLength', 'hidden',
] ]
updateOnStates = ['isExpanded'] 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 = () => { handleClick = () => {
if (!this.context.router) { if (!this.context.router) {
return; return;
@ -173,25 +91,19 @@ export default class Status extends ImmutablePureComponent {
let media = null; let media = null;
let statusAvatar; let statusAvatar;
// Exclude intersectionObserverWrapper from `other` variable const { status, account, hidden, ...other } = this.props;
// because intersection is managed in here. const { isExpanded } = this.state;
const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props;
const { isExpanded, isIntersecting, isHidden } = this.state;
if (status === null) { if (status === null) {
return null; return null;
} }
const hasIntersectionObserverWrapper = !!this.props.intersectionObserverWrapper; if (hidden) {
const isHiddenForSure = isIntersecting === false && isHidden;
const visibilityUnknownButHeightIsCached = isIntersecting === undefined && status.has('height');
if (hasIntersectionObserverWrapper && (isHiddenForSure || visibilityUnknownButHeightIsCached)) {
return ( 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.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')} {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']) }; const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
return ( 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'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <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> }} /> <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> </div>
<Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} /> <Status {...other} status={status.get('reblog')} account={status.get('account')} />
</article> </div>
); );
} }
@ -235,7 +147,7 @@ export default class Status extends ImmutablePureComponent {
} }
return ( 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'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <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} {media}
<StatusActionBar {...this.props} /> <StatusActionBar {...this.props} />
</article> </div>
); );
} }

View File

@ -1,12 +1,9 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container'; import StatusContainer from '../containers/status_container';
import LoadMore from './load_more';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import ScrollableList from './scrollable_list';
import { throttle } from 'lodash';
export default class StatusList extends ImmutablePureComponent { export default class StatusList extends ImmutablePureComponent {
@ -28,145 +25,21 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, 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 () { render () {
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; const { statusIds, ...other } = this.props;
const { isLoading } = other;
const loadMore = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />; const scrollableContent = (isLoading || statusIds.size > 0) ? (
let scrollableArea = null; statusIds.map((statusId) => (
<StatusContainer key={statusId} id={statusId} />
))
) : null;
if (isLoading || statusIds.size > 0 || !emptyMessage) { return (
scrollableArea = ( <ScrollableList {...other}>
<div className='scrollable' ref={this.setRef}> {scrollableContent}
<div role='feed' className='status-list' onKeyDown={this.handleKeyDown}> </ScrollableList>
{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;
}
} }
} }

View File

@ -16,6 +16,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']), statusIds: state.getIn(['status_lists', 'favourites', 'items']),
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
}; };
componentWillMount () { componentWillMount () {
@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent {
} }
render () { render () {
const { intl, statusIds, columnId, multiColumn } = this.props; const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
@ -81,6 +83,7 @@ export default class Favourites extends ImmutablePureComponent {
trackScroll={!pinned} trackScroll={!pinned}
statusIds={statusIds} statusIds={statusIds}
scrollKey={`favourited_statuses-${columnId}`} scrollKey={`favourited_statuses-${columnId}`}
hasMore={hasMore}
onScrollToBottom={this.handleScrollToBottom} onScrollToBottom={this.handleScrollToBottom}
/> />
</Column> </Column>

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
@ -10,6 +11,7 @@ export default class Notification extends ImmutablePureComponent {
static propTypes = { static propTypes = {
notification: ImmutablePropTypes.map.isRequired, notification: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool,
}; };
renderFollow (account, link) { renderFollow (account, link) {
@ -23,13 +25,13 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
</div> </div>
<AccountContainer id={account.get('id')} withNote={false} /> <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div> </div>
); );
} }
renderMention (notification) { renderMention (notification) {
return <StatusContainer id={notification.get('status')} withDismiss />; return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
} }
renderFavourite (notification, link) { 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 }} /> <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
</div> </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> </div>
); );
} }
@ -57,7 +59,7 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div> </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> </div>
); );
} }

View File

@ -7,13 +7,12 @@ import ColumnHeader from '../../components/column_header';
import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; import { expandNotifications, scrollTopNotifications } from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container'; import NotificationContainer from './containers/notification_container';
import { ScrollContainer } from 'react-router-scroll';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import LoadMore from '../../components/load_more';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' }, title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -51,40 +50,18 @@ export default class Notifications extends React.PureComponent {
trackScroll: true, trackScroll: true,
}; };
dispatchExpandNotifications = debounce(() => { handleScrollToBottom = debounce(() => {
this.props.dispatch(scrollTopNotifications(false));
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
}, 300, { leading: true }); }, 300, { leading: true });
dispatchScrollToTop = debounce((top) => { handleScrollToTop = debounce(() => {
this.props.dispatch(scrollTopNotifications(top)); this.props.dispatch(scrollTopNotifications(true));
}, 100); }, 100);
handleScroll = (e) => { handleScroll = debounce(() => {
const { scrollTop, scrollHeight, clientHeight } = e.target; this.props.dispatch(scrollTopNotifications(false));
const offset = scrollHeight - scrollTop - clientHeight; }, 100);
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();
}
handlePin = () => { handlePin = () => {
const { columnId, dispatch } = this.props; const { columnId, dispatch } = this.props;
@ -105,10 +82,6 @@ export default class Notifications extends React.PureComponent {
this.column.scrollTop(); this.column.scrollTop();
} }
setRef = (c) => {
this.node = c;
}
setColumnRef = c => { setColumnRef = c => {
this.column = c; this.column = c;
} }
@ -116,52 +89,34 @@ export default class Notifications extends React.PureComponent {
render () { render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId; 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 scrollableContent = null;
let scrollableArea = '';
let unread = '';
let scrollContainer = '';
if (!isLoading && hasMore) { if (isLoading && this.scrollableContent) {
loadMore = <LoadMore onClick={this.handleLoadMore} />; scrollableContent = this.scrollableContent;
}
if (isUnread) {
unread = <div className='notifications__unread-indicator' />;
}
if (isLoading && this.scrollableArea) {
scrollableArea = this.scrollableArea;
} else if (notifications.size > 0 || hasMore) { } else if (notifications.size > 0 || hasMore) {
scrollableArea = ( scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
<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>
);
} else { } else {
scrollableArea = ( scrollableContent = null;
<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>
);
} }
if (pinned) { this.scrollableContent = scrollableContent;
scrollContainer = scrollableArea;
} else {
scrollContainer = (
<ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}>
{scrollableArea}
</ScrollContainer>
);
}
this.scrollableArea = scrollableArea; 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 ( return (
<Column ref={this.setColumnRef}> <Column ref={this.setColumnRef}>