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 {