Generalized the infinite scrollable list (#4697)
parent
938cd2875b
commit
0827c09c44
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 { statusIds, ...other } = this.props;
|
||||
const { isLoading } = other;
|
||||
|
||||
const loadMore = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />;
|
||||
let scrollableArea = null;
|
||||
const scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||
statusIds.map((statusId) => (
|
||||
<StatusContainer key={statusId} id={statusId} />
|
||||
))
|
||||
) : 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>
|
||||
<ScrollableList {...other}>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
} else {
|
||||
return scrollableArea;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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.scrollableContent = scrollableContent;
|
||||
|
||||
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 (
|
||||
<Column ref={this.setColumnRef}>
|
||||
|
|
Reference in New Issue