about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorabcang <abcang1015@gmail.com>2017-08-29 05:23:44 +0900
committerEugen Rochko <eugen@zeonfederated.com>2017-08-28 22:23:44 +0200
commit0827c09c448ea8d61e62534dd3547719e148a4ae (patch)
treecc2b90e924f3500815bcd7dfae702256c01d8b38 /app
parent938cd2875b14db3655a6c9f82f672f4baf7720a3 (diff)
Generalized the infinite scrollable list (#4697)
Diffstat (limited to 'app')
-rw-r--r--app/javascript/mastodon/components/account.js12
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js122
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js179
-rw-r--r--app/javascript/mastodon/components/status.js112
-rw-r--r--app/javascript/mastodon/components/status_list.js157
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js5
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js10
-rw-r--r--app/javascript/mastodon/features/notifications/index.js105
8 files changed, 379 insertions, 323 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/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 b4f523f72..4ab40d6bf 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -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>
     );
   }
 
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index ca443c286..cbae28afe 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -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 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>
+    );
   }
 
 }
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
index d9ad9bc1f..82b16b369 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -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>
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 2992185fd..a608a5223 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index c5853d3ba..c23560a43 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -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.scrollableArea = scrollableArea;
+    this.scrollableContent = scrollableContent;
+
+    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}>