about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSorin Davidoi <sorin.davidoi@gmail.com>2017-05-24 17:55:00 +0200
committerEugen Rochko <eugen@zeonfederated.com>2017-05-24 17:55:00 +0200
commit8e4d1cba00b48bc52dc406956a245856c489e48a (patch)
tree80793edff7a146d5afc0b7df09da696d623111a6
parent676ba50601d04dcd15930bb92aea918cb6cdf041 (diff)
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
-rw-r--r--app/javascript/mastodon/base_polyfills.js (renamed from app/javascript/mastodon/polyfills.js)0
-rw-r--r--app/javascript/mastodon/components/status.js52
-rw-r--r--app/javascript/mastodon/components/status_list.js58
-rw-r--r--app/javascript/mastodon/extra_polyfills.js2
-rw-r--r--app/javascript/packs/application.js29
-rw-r--r--app/javascript/styles/components.scss8
-rw-r--r--package.json2
-rw-r--r--yarn.lock8
8 files changed, 146 insertions, 13 deletions
diff --git a/app/javascript/mastodon/polyfills.js b/app/javascript/mastodon/base_polyfills.js
index 266a0020c..266a0020c 100644
--- 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
index ec83a5331..549592c6b 100644
--- 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
index 87a95fd12..b82c63a84 100644
--- 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
new file mode 100644
index 000000000..546b693b1
--- /dev/null
+++ 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
index ca6b476e1..01c28e768 100644
--- 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
index 880459761..3971a85bc 100644
--- 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
index 993570ce0..995ad6094 100644
--- 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
index 3c19450b6..e84c045da 100644
--- 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"