* refactor(components/status_list): Lazy load using IntersectionObserver * refactor(components/status_list): Avoid setState bottleneck * refactor(components/status_list): Update state correctly * fix(components/status): Render if isIntersecting is undefined * refactor(components/status): Recycle timeout * refactor(components/status): Reduce animation duration * refactor(components/status): Use requestIdleCallback * chore: Split polyfill bundles * refactor(components/status_list): Increase rootMargin to 300% * fix(components/status): Check if onRef is not defined * chore: Add note about polyfill bundle splitting * fix(components/status): Reduce animation duration to 0.3 secondsmaster
@@ -32,12 +32,44 @@ class Status extends ImmutablePureComponent { | |||
onOpenMedia: PropTypes.func, | |||
onOpenVideo: PropTypes.func, | |||
onBlock: PropTypes.func, | |||
onRef: PropTypes.func, | |||
isIntersecting: PropTypes.bool, | |||
me: PropTypes.number, | |||
boostModal: PropTypes.bool, | |||
autoPlayGif: PropTypes.bool, | |||
muted: PropTypes.bool, | |||
}; | |||
state = { | |||
isHidden: false, | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) { | |||
requestIdleCallback(() => this.setState({ isHidden: true })); | |||
} else { | |||
this.setState({ isHidden: !nextProps.isIntersecting }); | |||
} | |||
} | |||
shouldComponentUpdate (nextProps, nextState) { | |||
if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) { | |||
return nextState.isHidden; | |||
} | |||
return true; | |||
} | |||
handleRef = (node) => { | |||
if (this.props.onRef) { | |||
this.props.onRef(node); | |||
if (node && node.children.length !== 0) { | |||
this.height = node.clientHeight; | |||
} | |||
} | |||
} | |||
handleClick = () => { | |||
const { status } = this.props; | |||
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); | |||
@@ -52,12 +84,22 @@ class Status extends ImmutablePureComponent { | |||
} | |||
render () { | |||
let media = ''; | |||
let media = null; | |||
let statusAvatar; | |||
const { status, account, ...other } = this.props; | |||
const { status, account, isIntersecting, onRef, ...other } = this.props; | |||
const { isHidden } = this.state; | |||
if (status === null) { | |||
return <div />; | |||
return <div ref={this.handleRef} data-id={status.get('id')} />; | |||
} | |||
if (isIntersecting === false && isHidden) { | |||
return ( | |||
<div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0 }}> | |||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} | |||
{status.get('content')} | |||
</div> | |||
); | |||
} | |||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | |||
@@ -70,7 +112,7 @@ class Status extends ImmutablePureComponent { | |||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; | |||
return ( | |||
<div className='status__wrapper'> | |||
<div className='status__wrapper' ref={this.handleRef} 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={displayNameHTML} /></a> }} /> | |||
@@ -98,7 +140,7 @@ class Status extends ImmutablePureComponent { | |||
} | |||
return ( | |||
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`}> | |||
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}> | |||
<div className='status__info'> | |||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> | |||
@@ -26,6 +26,12 @@ class StatusList extends ImmutablePureComponent { | |||
trackScroll: true, | |||
}; | |||
state = { | |||
isIntersecting: [{ }], | |||
} | |||
statusRefQueue = [] | |||
handleScroll = (e) => { | |||
const { scrollTop, scrollHeight, clientHeight } = e.target; | |||
const offset = scrollHeight - scrollTop - clientHeight; | |||
@@ -42,6 +48,7 @@ class StatusList extends ImmutablePureComponent { | |||
componentDidMount () { | |||
this.attachScrollListener(); | |||
this.attachIntersectionObserver(); | |||
} | |||
componentDidUpdate (prevProps) { | |||
@@ -52,6 +59,39 @@ class StatusList extends ImmutablePureComponent { | |||
componentWillUnmount () { | |||
this.detachScrollListener(); | |||
this.detachIntersectionObserver(); | |||
} | |||
attachIntersectionObserver () { | |||
const onIntersection = (entries) => { | |||
this.setState(state => { | |||
const isIntersecting = { }; | |||
entries.forEach(entry => { | |||
const statusId = entry.target.getAttribute('data-id'); | |||
state.isIntersecting[0][statusId] = entry.isIntersecting; | |||
}); | |||
return { isIntersecting: [state.isIntersecting[0]] }; | |||
}); | |||
}; | |||
const options = { | |||
root: this.node, | |||
rootMargin: '300% 0px', | |||
}; | |||
this.intersectionObserver = new IntersectionObserver(onIntersection, options); | |||
if (this.statusRefQueue.length) { | |||
this.statusRefQueue.forEach(node => this.intersectionObserver.observe(node)); | |||
this.statusRefQueue = []; | |||
} | |||
} | |||
detachIntersectionObserver () { | |||
this.intersectionObserver.disconnect(); | |||
} | |||
attachScrollListener () { | |||
@@ -66,6 +106,15 @@ class StatusList extends ImmutablePureComponent { | |||
this.node = c; | |||
} | |||
handleStatusRef = (node) => { | |||
if (node && this.intersectionObserver) { | |||
const statusId = node.getAttribute('data-id'); | |||
this.intersectionObserver.observe(node); | |||
} else { | |||
this.statusRefQueue.push(node); | |||
} | |||
} | |||
handleLoadMore = (e) => { | |||
e.preventDefault(); | |||
this.props.onScrollToBottom(); | |||
@@ -73,10 +122,11 @@ class StatusList extends ImmutablePureComponent { | |||
render () { | |||
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props; | |||
const isIntersecting = this.state.isIntersecting[0]; | |||
let loadMore = ''; | |||
let scrollableArea = ''; | |||
let unread = ''; | |||
let loadMore = null; | |||
let scrollableArea = null; | |||
let unread = null; | |||
if (!isLoading && statusIds.size > 0 && hasMore) { | |||
loadMore = <LoadMore onClick={this.handleLoadMore} />; | |||
@@ -95,7 +145,7 @@ class StatusList extends ImmutablePureComponent { | |||
{prepend} | |||
{statusIds.map((statusId) => { | |||
return <StatusContainer key={statusId} id={statusId} />; | |||
return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />; | |||
})} | |||
{loadMore} | |||
@@ -0,0 +1,2 @@ | |||
import 'intersection-observer'; | |||
import 'requestidlecallback'; |
@@ -1,9 +1,30 @@ | |||
import main from '../mastodon/main'; | |||
if (!window.Intl || !Object.assign || !Number.isNaN || | |||
!window.Symbol || !Array.prototype.includes) { | |||
// load polyfills dynamically | |||
import('../mastodon/polyfills').then(main).catch(e => { | |||
const needsBasePolyfills = !( | |||
window.Intl && | |||
Object.assign && | |||
Number.isNaN && | |||
window.Symbol && | |||
Array.prototype.includes | |||
); | |||
const needsExtraPolyfills = !( | |||
window.IntersectionObserver && | |||
window.requestIdleCallback | |||
); | |||
// Latest version of Firefox and Safari do not have IntersectionObserver. | |||
// Edge does not have requestIdleCallback. | |||
// This avoids shipping them all the polyfills. | |||
if (needsBasePolyfills) { | |||
Promise.all([ | |||
import('../mastodon/base_polyfills'), | |||
import('../mastodon/extra_polyfills'), | |||
]).then(main).catch(e => { | |||
console.error(e); // eslint-disable-line no-console | |||
}); | |||
} else if (needsExtraPolyfills) { | |||
import('../mastodon/extra_polyfills').then(main).catch(e => { | |||
console.error(e); // eslint-disable-line no-console | |||
}); | |||
} else { | |||
@@ -554,6 +554,14 @@ | |||
border-bottom: 1px solid lighten($ui-base-color, 8%); | |||
cursor: default; | |||
@keyframes fade { | |||
0% { opacity: 0; } | |||
100% { opacity: 1; } | |||
} | |||
opacity: 1; | |||
animation: fade 0.3s linear; | |||
&.status-direct { | |||
background: lighten($ui-base-color, 8%); | |||
@@ -55,6 +55,7 @@ | |||
"glob": "^7.1.1", | |||
"http-link-header": "^0.8.0", | |||
"immutable": "^3.8.1", | |||
"intersection-observer": "^0.2.1", | |||
"intl": "^1.2.5", | |||
"is-nan": "^1.2.1", | |||
"js-yaml": "^3.8.3", | |||
@@ -92,6 +93,7 @@ | |||
"redux": "^3.6.0", | |||
"redux-immutable": "^3.1.0", | |||
"redux-thunk": "^2.2.0", | |||
"requestidlecallback": "^0.3.0", | |||
"reselect": "^2.5.4", | |||
"rimraf": "^2.6.1", | |||
"sass-loader": "^6.0.3", | |||
@@ -3341,6 +3341,10 @@ interpret@^1.0.0: | |||
version "1.0.1" | |||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" | |||
intersection-observer@^0.2.1: | |||
version "0.2.1" | |||
resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.2.1.tgz#cb55175f4eebef6436d957a7d1774d39a9248e5e" | |||
intl: | |||
version "1.2.5" | |||
resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde" | |||
@@ -5832,6 +5836,10 @@ request@2, request@2.x, request@^2.74.0, request@^2.79.0: | |||
tunnel-agent "~0.4.1" | |||
uuid "^3.0.0" | |||
requestidlecallback@^0.3.0: | |||
version "0.3.0" | |||
resolved "https://registry.yarnpkg.com/requestidlecallback/-/requestidlecallback-0.3.0.tgz#6fb74e0733f90df3faa4838f9f6a2a5f9b742ac5" | |||
require-directory@^2.1.1: | |||
version "2.1.1" | |||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" | |||