diff options
Diffstat (limited to 'app/javascript/mastodon/components')
-rw-r--r-- | app/javascript/mastodon/components/account.js | 12 | ||||
-rw-r--r-- | app/javascript/mastodon/components/column.js | 2 | ||||
-rw-r--r-- | app/javascript/mastodon/components/intersection_observer_article.js | 122 | ||||
-rw-r--r-- | app/javascript/mastodon/components/scrollable_list.js | 179 | ||||
-rw-r--r-- | app/javascript/mastodon/components/status.js | 114 | ||||
-rw-r--r-- | app/javascript/mastodon/components/status_action_bar.js | 29 | ||||
-rw-r--r-- | app/javascript/mastodon/components/status_list.js | 156 |
7 files changed, 368 insertions, 246 deletions
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 69cc63d10..6456c12ba 100644 --- 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/column.js b/app/javascript/mastodon/components/column.js index 103fcd495..168f2da9c 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -33,7 +33,7 @@ export default class Column extends React.PureComponent { } componentDidMount () { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents ? { passive: true } : false); + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); } componentWillUnmount () { diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js new file mode 100644 index 000000000..347767818 --- /dev/null +++ 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 new file mode 100644 index 000000000..1a122dbe5 --- /dev/null +++ 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 index 7468957d3..b8617018d 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -12,13 +12,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 { @@ -29,27 +27,25 @@ 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, onDelete: PropTypes.func, + onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, onBlock: PropTypes.func, + onEmbed: PropTypes.func, onHeightChange: PropTypes.func, me: PropTypes.number, 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 @@ -57,91 +53,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; @@ -175,25 +95,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> ); } @@ -201,14 +115,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> ); } @@ -237,7 +151,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> @@ -255,7 +169,7 @@ export default class Status extends ImmutablePureComponent { {media} <StatusActionBar {...this.props} /> - </article> + </div> ); } diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 81c2a4e23..de99f8850 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -24,6 +24,9 @@ const messages = defineMessages({ report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); @injectIntl @@ -43,7 +46,9 @@ export default class StatusActionBar extends ImmutablePureComponent { onMute: PropTypes.func, onBlock: PropTypes.func, onReport: PropTypes.func, + onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, + onPin: PropTypes.func, me: PropTypes.number, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -80,6 +85,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -96,6 +105,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); } + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + handleReport = () => { this.props.onReport(this.props.status); } @@ -106,9 +119,10 @@ export default class StatusActionBar extends ImmutablePureComponent { render () { const { status, me, intl, withDismiss } = this.props; - const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + const mutingConversation = status.get('muted'); - const anonymousAccess = !me; + const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); let menu = []; let reblogIcon = 'retweet'; @@ -116,6 +130,11 @@ export default class StatusActionBar extends ImmutablePureComponent { let replyTitle; menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + menu.push(null); if (withDismiss) { @@ -124,6 +143,10 @@ export default class StatusActionBar extends ImmutablePureComponent { } if (status.getIn(['account', 'id']) === me) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); @@ -154,7 +177,7 @@ export default class StatusActionBar extends ImmutablePureComponent { return ( <div className='status__action-bar'> <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> - <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> + <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> {shareButton} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 271cf33b7..6bd357754 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,12 +1,10 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { ScrollContainer } from 'react-router-scroll'; import PropTypes from 'prop-types'; import StatusContainer from '../../glitch/components/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 +26,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> + ); } } |