logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: 8e4d1cba00b48bc52dc406956a245856c489e48a
parent: 676ba50601d04dcd15930bb92aea918cb6cdf041
Author: Sorin Davidoi <sorin.davidoi@gmail.com>
Date:   Wed, 24 May 2017 17:55:00 +0200

Lazy load toots using IntersectionObserver (#3191)

* 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 seconds

Diffstat:

Rapp/javascript/mastodon/polyfills.js -> app/javascript/mastodon/base_polyfills.js0
Mapp/javascript/mastodon/components/status.js52+++++++++++++++++++++++++++++++++++++++++++++++-----
Mapp/javascript/mastodon/components/status_list.js58++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Aapp/javascript/mastodon/extra_polyfills.js2++
Mapp/javascript/packs/application.js29+++++++++++++++++++++++++----
Mapp/javascript/styles/components.scss8++++++++
Mpackage.json2++
Myarn.lock8++++++++
8 files changed, 146 insertions(+), 13 deletions(-)

diff --git a/app/javascript/mastodon/polyfills.js b/app/javascript/mastodon/base_polyfills.js diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js @@ -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> diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js @@ -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} diff --git a/app/javascript/mastodon/extra_polyfills.js b/app/javascript/mastodon/extra_polyfills.js @@ -0,0 +1,2 @@ +import 'intersection-observer'; +import 'requestidlecallback'; diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js @@ -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 { diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss @@ -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%); diff --git a/package.json b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock @@ -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"