From cf845fed3824d3e3587ce9b2ad752c2b3f0a2a76 Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <akihiko.odaki.4i@stu.hosei.ac.jp>
Date: Mon, 24 Apr 2017 11:49:08 +0900
Subject: [PATCH] Hide some components rather than unmounting (#2271)

Hide some components rather than unmounting them to allow to show again
quickly and keep the view state such as the scrolled offset.
---
 .../components/components/status_list.jsx     |  19 ++-
 .../components/containers/mastodon.jsx        | 127 +++++++++++++++++-
 .../features/account_timeline/index.jsx       |   1 +
 .../features/community_timeline/index.jsx     |   2 +-
 .../features/favourited_statuses/index.jsx    |   2 +-
 .../features/hashtag_timeline/index.jsx       |   2 +-
 .../features/home_timeline/index.jsx          |   2 +-
 .../features/notifications/index.jsx          |  30 ++---
 .../features/public_timeline/index.jsx        |   2 +-
 .../ui/containers/status_list_container.jsx   |   2 +
 .../components/features/ui/index.jsx          |   6 +-
 app/assets/stylesheets/components.scss        |  13 +-
 app/assets/stylesheets/containers.scss        |  12 +-
 13 files changed, 167 insertions(+), 53 deletions(-)

diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx
index dc2a9509d1..517c8fe5de 100644
--- a/app/assets/javascripts/components/components/status_list.jsx
+++ b/app/assets/javascripts/components/components/status_list.jsx
@@ -60,7 +60,7 @@ class StatusList extends React.PureComponent {
   }
 
   render () {
-    const { statusIds, onScrollToBottom, trackScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
+    const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
 
     let loadMore       = '';
     let scrollableArea = '';
@@ -98,25 +98,22 @@ class StatusList extends React.PureComponent {
       );
     }
 
-    if (trackScroll) {
-      return (
-        <ScrollContainer scrollKey='status-list'>
-          {scrollableArea}
-        </ScrollContainer>
-      );
-    } else {
-      return scrollableArea;
-    }
+    return (
+      <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+        {scrollableArea}
+      </ScrollContainer>
+    );
   }
 
 }
 
 StatusList.propTypes = {
+  scrollKey: PropTypes.string.isRequired,
   statusIds: ImmutablePropTypes.list.isRequired,
   onScrollToBottom: PropTypes.func,
   onScrollToTop: PropTypes.func,
   onScroll: PropTypes.func,
-  trackScroll: PropTypes.bool,
+  shouldUpdateScroll: PropTypes.func,
   isLoading: PropTypes.bool,
   isUnread: PropTypes.bool,
   hasMore: PropTypes.bool,
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index e2b91e5dd9..c85a353eed 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -99,6 +99,125 @@ addLocaleData([
   ...id,
 ]);
 
+const getTopWhenReplacing = (previous, { location }) => location && location.action === 'REPLACE' && [0, 0];
+
+const hiddenColumnContainerStyle = {
+  position: 'absolute',
+  left: '0',
+  top:  '0',
+  visibility: 'hidden'
+};
+
+class Container extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      renderedPersistents: [],
+      unrenderedPersistents: [],
+    };
+  }
+
+  componentWillMount () {
+    this.unlistenHistory = null;
+
+    this.setState(() => {
+      return {
+        mountImpersistent: false,
+        renderedPersistents: [],
+        unrenderedPersistents: [
+          {pathname: '/timelines/home', component: HomeTimeline},
+          {pathname: '/timelines/public', component: PublicTimeline},
+          {pathname: '/timelines/public/local', component: CommunityTimeline},
+
+          {pathname: '/notifications', component: Notifications},
+          {pathname: '/favourites', component: FavouritedStatuses}
+        ],
+      };
+    }, () => {
+      if (this.unlistenHistory) {
+        return;
+      }
+
+      this.unlistenHistory = browserHistory.listen(location => {
+        const pathname = location.pathname.replace(/\/$/, '').toLowerCase();
+
+        this.setState(oldState => {
+          let persistentMatched = false;
+
+          const newState = {
+            renderedPersistents: oldState.renderedPersistents.map(persistent => {
+              const givenMatched = persistent.pathname === pathname;
+
+              if (givenMatched) {
+                persistentMatched = true;
+              }
+
+              return {
+                hidden: !givenMatched,
+                pathname: persistent.pathname,
+                component: persistent.component
+              };
+            }),
+          };
+
+          if (!persistentMatched) {
+            newState.unrenderedPersistents = [];
+
+            oldState.unrenderedPersistents.forEach(persistent => {
+              if (persistent.pathname === pathname) {
+                persistentMatched = true;
+
+                newState.renderedPersistents.push({
+                  hidden: false,
+                  pathname: persistent.pathname,
+                  component: persistent.component
+                });
+              } else {
+                newState.unrenderedPersistents.push(persistent);
+              }
+            });
+          }
+
+          newState.mountImpersistent = !persistentMatched;
+
+          return newState;
+        });
+      });
+    });
+  }
+
+  componentWillUnmount () {
+    if (this.unlistenHistory) {
+      this.unlistenHistory();
+    }
+
+    this.unlistenHistory = "done";
+  }
+
+  render () {
+    // Hide some components rather than unmounting them to allow to show again
+    // quickly and keep the view state such as the scrolled offset.
+    const persistentsView = this.state.renderedPersistents.map((persistent) =>
+      <div aria-hidden={persistent.hidden} key={persistent.pathname} className='mastodon-column-container' style={persistent.hidden ? hiddenColumnContainerStyle : null}>
+        <persistent.component shouldUpdateScroll={persistent.hidden ? Function.prototype : getTopWhenReplacing} />
+      </div>
+    );
+
+    return (
+      <UI>
+        {this.state.mountImpersistent && this.props.children}
+        {persistentsView}
+      </UI>
+    );
+  }
+}
+
+Container.propTypes = {
+  children: PropTypes.node,
+};
+
 class Mastodon extends React.Component {
 
   componentDidMount() {
@@ -160,18 +279,12 @@ class Mastodon extends React.Component {
       <IntlProvider locale={locale} messages={getMessagesForLocale(locale)}>
         <Provider store={store}>
           <Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
-            <Route path='/' component={UI}>
+            <Route path='/' component={Container}>
               <IndexRedirect to="/getting-started" />
 
               <Route path='getting-started' component={GettingStarted} />
-              <Route path='timelines/home' component={HomeTimeline} />
-              <Route path='timelines/public' component={PublicTimeline} />
-              <Route path='timelines/public/local' component={CommunityTimeline} />
               <Route path='timelines/tag/:id' component={HashtagTimeline} />
 
-              <Route path='notifications' component={Notifications} />
-              <Route path='favourites' component={FavouritedStatuses} />
-
               <Route path='statuses/new' component={Compose} />
               <Route path='statuses/:statusId' component={Status} />
               <Route path='statuses/:statusId/reblogs' component={Reblogs} />
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 4987a23649..a06de3d21e 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -62,6 +62,7 @@ class AccountTimeline extends React.PureComponent {
 
         <StatusList
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          scrollKey='account_timeline'
           statusIds={statusIds}
           isLoading={isLoading}
           hasMore={hasMore}
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
index c2d8bf2ed2..3877888ba7 100644
--- a/app/assets/javascripts/components/features/community_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -77,7 +77,7 @@ class CommunityTimeline extends React.PureComponent {
     return (
       <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} />
+        <StatusListContainer {...this.props} scrollKey='community_timeline' type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} />
       </Column>
     );
   }
diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
index d6f53bf33c..bc45ace513 100644
--- a/app/assets/javascripts/components/features/favourited_statuses/index.jsx
+++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
@@ -47,7 +47,7 @@ class Favourites extends React.PureComponent {
     return (
       <Column icon='star' heading={intl.formatMessage(messages.heading)}>
         <ColumnBackButtonSlim />
-        <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
+        <StatusList {...this.props} onScrollToBottom={this.handleScrollToBottom} />
       </Column>
     );
   }
diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
index 5c091e17f5..0575e92147 100644
--- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
@@ -71,7 +71,7 @@ class HashtagTimeline extends React.PureComponent {
     return (
       <Column icon='hashtag' active={hasUnread} heading={id}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} />
+        <StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} />
       </Column>
     );
   }
diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx
index 6b986171ef..52b94690d4 100644
--- a/app/assets/javascripts/components/features/home_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/index.jsx
@@ -22,7 +22,7 @@ class HomeTimeline extends React.PureComponent {
     return (
       <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnSettingsContainer />
-        <StatusListContainer {...this.props} type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
+        <StatusListContainer {...this.props} scrollKey='home_timeline' type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
       </Column>
     );
   }
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 14c00b9ced..da3ce2f62c 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -80,7 +80,7 @@ class Notifications extends React.PureComponent {
   }
 
   render () {
-    const { intl, notifications, trackScroll, isLoading, isUnread } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread } = this.props;
 
     let loadMore       = '';
     let scrollableArea = '';
@@ -113,25 +113,15 @@ class Notifications extends React.PureComponent {
       );
     }
 
-    if (trackScroll) {
-      return (
-        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
-          <ColumnSettingsContainer />
-          <ClearColumnButton onClick={this.handleClear} />
-          <ScrollContainer scrollKey='notifications'>
-            {scrollableArea}
-          </ScrollContainer>
-        </Column>
-      );
-    } else {
-      return (
-        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
-          <ColumnSettingsContainer />
-          <ClearColumnButton onClick={this.handleClear} />
+    return (
+      <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnSettingsContainer />
+        <ClearColumnButton onClick={this.handleClear} />
+        <ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}>
           {scrollableArea}
-        </Column>
-      );
-    }
+        </ScrollContainer>
+      </Column>
+    );
   }
 
 }
@@ -139,7 +129,7 @@ class Notifications extends React.PureComponent {
 Notifications.propTypes = {
   notifications: ImmutablePropTypes.list.isRequired,
   dispatch: PropTypes.func.isRequired,
-  trackScroll: PropTypes.bool,
+  shouldUpdateScroll: PropTypes.func,
   intl: PropTypes.object.isRequired,
   isLoading: PropTypes.bool,
   isUnread: PropTypes.bool
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index fa7a2db8e8..53be13686e 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -77,7 +77,7 @@ class PublicTimeline extends React.PureComponent {
     return (
       <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='public' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} />
+        <StatusListContainer {...this.props} type='public' scrollKey='public_timeline' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} />
       </Column>
     );
   }
diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
index 4c33f2b61b..1599000b5c 100644
--- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
@@ -40,6 +40,8 @@ const makeMapStateToProps = () => {
   const getStatusIds = makeGetStatusIds();
 
   const mapStateToProps = (state, props) => ({
+    scrollKey: props.scrollKey,
+    shouldUpdateScroll: props.shouldUpdateScroll,
     statusIds: getStatusIds(state, props),
     isLoading: state.getIn(['timelines', props.type, 'isLoading'], true),
     isUnread: state.getIn(['timelines', props.type, 'unread']) > 0,
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index 1f35842d98..c92b9751b4 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -127,9 +127,9 @@ class UI extends React.PureComponent {
       mountedColumns = (
         <ColumnsArea>
           <Compose withHeader={true} />
-          <HomeTimeline trackScroll={false} />
-          <Notifications trackScroll={false} />
-          {children}
+          <HomeTimeline shouldUpdateScroll={() => false} />
+          <Notifications shouldUpdateScroll={() => false} />
+          <div style={{display: 'flex', flex: '1 1 auto', position: 'relative'}}>{children}</div>
         </ColumnsArea>
       );
     }
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index feab813664..0c8379be4c 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -89,11 +89,11 @@
   border: none;
   background: transparent;
   cursor: pointer;
-  transition: all 100ms ease-in;
+  transition: color 100ms ease-in;
 
   &:hover, &:active, &:focus {
     color: lighten($color1, 33%);
-    transition: all 200ms ease-out;
+    transition: color 200ms ease-out;
   }
 
   &.disabled {
@@ -152,11 +152,11 @@
   padding: 0 3px;
   line-height: 27px;
   outline: 0;
-  transition: all 100ms ease-in;
+  transition: color 100ms ease-in;
 
   &:hover, &:active, &:focus {
     color: lighten($color1, 26%);
-    transition: all 200ms ease-out;
+    transition: color 200ms ease-out;
   }
 
   &.disabled {
@@ -1100,6 +1100,7 @@ a.status__content__spoiler-link {
   flex-direction: row;
   justify-content: flex-start;
   overflow-x: auto;
+  position:   relative;
 }
 
 @media screen and (min-width: 360px) {
@@ -1257,11 +1258,11 @@ a.status__content__spoiler-link {
   flex-direction: row;
 
   a {
-    transition: all 100ms ease-in;
+    transition: background 100ms ease-in;
 
     &:hover {
       background: lighten($color1, 3%);
-      transition: all 200ms ease-out;
+      transition: background 200ms ease-out;
     }
   }
 }
diff --git a/app/assets/stylesheets/containers.scss b/app/assets/stylesheets/containers.scss
index 43705b19c9..6f339f9981 100644
--- a/app/assets/stylesheets/containers.scss
+++ b/app/assets/stylesheets/containers.scss
@@ -9,6 +9,16 @@
   }
 }
 
+.mastodon-column-container {
+  display: flex;
+  height: 100%;
+  width: 100%;
+
+  // 707568 - height 100% doesn't work on child of a flex item - chromium - Monorail
+  // https://bugs.chromium.org/p/chromium/issues/detail?id=707568
+  flex: 1 1 auto;
+}
+
 .logo-container {
   max-width: 400px;
   margin: 100px auto;
@@ -40,7 +50,7 @@
 
       img {
         opacity: 0.8;
-        transition: all 0.8s ease;
+        transition: opacity 0.8s ease;
       }
 
       &:hover {