about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-02-25 00:34:33 +0100
committerGitHub <noreply@github.com>2022-02-25 00:34:33 +0100
commitd4592bbfcd091c4eaef8c8f24c47d5c2ce1bacd3 (patch)
tree237a385de76d112d120b7a35119067e1fd4947ef
parent27965ce5edff20db2de1dd233c88f8393bb0da0b (diff)
Add explore page to web UI (#17123)
* Add explore page to web UI

* Fix not removing loaded statuses from trends on mute/block action
-rw-r--r--app/javascript/mastodon/actions/trends.js91
-rw-r--r--app/javascript/mastodon/components/hashtag.js2
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js7
-rw-r--r--app/javascript/mastodon/components/status_list.js3
-rw-r--r--app/javascript/mastodon/features/explore/components/story.js51
-rw-r--r--app/javascript/mastodon/features/explore/index.js91
-rw-r--r--app/javascript/mastodon/features/explore/links.js48
-rw-r--r--app/javascript/mastodon/features/explore/results.js113
-rw-r--r--app/javascript/mastodon/features/explore/statuses.js48
-rw-r--r--app/javascript/mastodon/features/explore/suggestions.js40
-rw-r--r--app/javascript/mastodon/features/explore/tags.js40
-rw-r--r--app/javascript/mastodon/features/getting_started/containers/trends_container.js6
-rw-r--r--app/javascript/mastodon/features/search/index.js17
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/navigation_panel.js1
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js6
-rw-r--r--app/javascript/mastodon/features/ui/index.js4
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js8
-rw-r--r--app/javascript/mastodon/reducers/search.js23
-rw-r--r--app/javascript/mastodon/reducers/status_lists.js23
-rw-r--r--app/javascript/mastodon/reducers/trends.js43
-rw-r--r--app/javascript/styles/mastodon/components.scss123
22 files changed, 727 insertions, 63 deletions
diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js
index 853e4f60a..304bbebef 100644
--- a/app/javascript/mastodon/actions/trends.js
+++ b/app/javascript/mastodon/actions/trends.js
@@ -1,31 +1,94 @@
 import api from '../api';
+import { importFetchedStatuses } from './importer';
 
-export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
-export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
-export const TRENDS_FETCH_FAIL    = 'TRENDS_FETCH_FAIL';
+export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
+export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
+export const TRENDS_TAGS_FETCH_FAIL    = 'TRENDS_TAGS_FETCH_FAIL';
 
-export const fetchTrends = () => (dispatch, getState) => {
-  dispatch(fetchTrendsRequest());
+export const TRENDS_LINKS_FETCH_REQUEST = 'TRENDS_LINKS_FETCH_REQUEST';
+export const TRENDS_LINKS_FETCH_SUCCESS = 'TRENDS_LINKS_FETCH_SUCCESS';
+export const TRENDS_LINKS_FETCH_FAIL    = 'TRENDS_LINKS_FETCH_FAIL';
+
+export const TRENDS_STATUSES_FETCH_REQUEST = 'TRENDS_STATUSES_FETCH_REQUEST';
+export const TRENDS_STATUSES_FETCH_SUCCESS = 'TRENDS_STATUSES_FETCH_SUCCESS';
+export const TRENDS_STATUSES_FETCH_FAIL    = 'TRENDS_STATUSES_FETCH_FAIL';
+
+export const fetchTrendingHashtags = () => (dispatch, getState) => {
+  dispatch(fetchTrendingHashtagsRequest());
+
+  api(getState)
+    .get('/api/v1/trends/tags')
+    .then(({ data }) => dispatch(fetchTrendingHashtagsSuccess(data)))
+    .catch(err => dispatch(fetchTrendingHashtagsFail(err)));
+};
+
+export const fetchTrendingHashtagsRequest = () => ({
+  type: TRENDS_TAGS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendingHashtagsSuccess = trends => ({
+  type: TRENDS_TAGS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendingHashtagsFail = error => ({
+  type: TRENDS_TAGS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const fetchTrendingLinks = () => (dispatch, getState) => {
+  dispatch(fetchTrendingLinksRequest());
 
   api(getState)
-    .get('/api/v1/trends')
-    .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
-    .catch(err => dispatch(fetchTrendsFail(err)));
+    .get('/api/v1/trends/links')
+    .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data)))
+    .catch(err => dispatch(fetchTrendingLinksFail(err)));
 };
 
-export const fetchTrendsRequest = () => ({
-  type: TRENDS_FETCH_REQUEST,
+export const fetchTrendingLinksRequest = () => ({
+  type: TRENDS_LINKS_FETCH_REQUEST,
   skipLoading: true,
 });
 
-export const fetchTrendsSuccess = trends => ({
-  type: TRENDS_FETCH_SUCCESS,
+export const fetchTrendingLinksSuccess = trends => ({
+  type: TRENDS_LINKS_FETCH_SUCCESS,
   trends,
   skipLoading: true,
 });
 
-export const fetchTrendsFail = error => ({
-  type: TRENDS_FETCH_FAIL,
+export const fetchTrendingLinksFail = error => ({
+  type: TRENDS_LINKS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const fetchTrendingStatuses = () => (dispatch, getState) => {
+  dispatch(fetchTrendingStatusesRequest());
+
+  api(getState).get('/api/v1/trends/statuses').then(({ data }) => {
+    dispatch(importFetchedStatuses(data));
+    dispatch(fetchTrendingStatusesSuccess(data));
+  }).catch(err => dispatch(fetchTrendingStatusesFail(err)));
+};
+
+export const fetchTrendingStatusesRequest = () => ({
+  type: TRENDS_STATUSES_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendingStatusesSuccess = statuses => ({
+  type: TRENDS_STATUSES_FETCH_SUCCESS,
+  statuses,
+  skipLoading: true,
+});
+
+export const fetchTrendingStatusesFail = error => ({
+  type: TRENDS_STATUSES_FETCH_FAIL,
   error,
   skipLoading: true,
   skipAlert: true,
diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js
index a793a32f5..7f442d189 100644
--- a/app/javascript/mastodon/components/hashtag.js
+++ b/app/javascript/mastodon/components/hashtag.js
@@ -38,7 +38,7 @@ class SilentErrorBoundary extends React.Component {
  *
  * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
  */
-const accountsCountRenderer = (displayNumber, pluralReady) => (
+export const accountsCountRenderer = (displayNumber, pluralReady) => (
   <FormattedMessage
     id='trends.counter_by_accounts'
     defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e3a7d763f..1d8fe23da 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -77,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
     onPin: PropTypes.func,
     onBookmark: PropTypes.func,
     withDismiss: PropTypes.bool,
+    withCounters: PropTypes.bool,
     scrollKey: PropTypes.string,
     intl: PropTypes.object.isRequired,
   };
@@ -226,7 +227,7 @@ class StatusActionBar extends ImmutablePureComponent {
   }
 
   render () {
-    const { status, relationship, intl, withDismiss, scrollKey } = this.props;
+    const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
 
     const anonymousAccess    = !me;
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
@@ -331,8 +332,8 @@ class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
-        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
-        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
 
         {shareButton}
 
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index eaaffcc3a..35e5749a3 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent {
     prepend: PropTypes.node,
     emptyMessage: PropTypes.node,
     alwaysPrepend: PropTypes.bool,
+    withCounters: PropTypes.bool,
     timelineId: PropTypes.string,
   };
 
@@ -100,6 +101,7 @@ export default class StatusList extends ImmutablePureComponent {
           contextType={timelineId}
           scrollKey={this.props.scrollKey}
           showThread
+          withCounters={this.props.withCounters}
         />
       ))
     ) : null;
@@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent {
           onMoveDown={this.handleMoveDown}
           contextType={timelineId}
           showThread
+          withCounters={this.props.withCounters}
         />
       )).concat(scrollableContent);
     }
diff --git a/app/javascript/mastodon/features/explore/components/story.js b/app/javascript/mastodon/features/explore/components/story.js
new file mode 100644
index 000000000..563128029
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/components/story.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Blurhash from 'mastodon/components/blurhash';
+import { accountsCountRenderer } from 'mastodon/components/hashtag';
+import ShortNumber from 'mastodon/components/short_number';
+import Skeleton from 'mastodon/components/skeleton';
+import classNames from 'classnames';
+
+export default class Story extends React.PureComponent {
+
+  static propTypes = {
+    url: PropTypes.string,
+    title: PropTypes.string,
+    publisher: PropTypes.string,
+    sharedTimes: PropTypes.number,
+    thumbnail: PropTypes.string,
+    blurhash: PropTypes.string,
+  };
+
+  state = {
+    thumbnailLoaded: false,
+  };
+
+  handleImageLoad = () => this.setState({ thumbnailLoaded: true });
+
+  render () {
+    const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
+
+    const { thumbnailLoaded } = this.state;
+
+    return (
+      <a className='story' href={url} target='blank' rel='noopener'>
+        <div className='story__details'>
+          <div className='story__details__publisher'>{publisher ? publisher : <Skeleton width={50} />}</div>
+          <div className='story__details__title'>{title ? title : <Skeleton />}</div>
+          <div className='story__details__shared'>{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
+        </div>
+
+        <div className='story__thumbnail'>
+          {thumbnail ? (
+            <React.Fragment>
+              <div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
+              <img src={thumbnail} onLoad={this.handleImageLoad} alt='' role='presentation' />
+            </React.Fragment>
+          ) : <Skeleton />}
+        </div>
+      </a>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/index.js b/app/javascript/mastodon/features/explore/index.js
new file mode 100644
index 000000000..ddacf5812
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/index.js
@@ -0,0 +1,91 @@
+import React from 'react';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Column from 'mastodon/components/column';
+import ColumnHeader from 'mastodon/components/column_header';
+import { NavLink, Switch, Route } from 'react-router-dom';
+import Links from './links';
+import Tags from './tags';
+import Statuses from './statuses';
+import Suggestions from './suggestions';
+import Search from 'mastodon/features/compose/containers/search_container';
+import SearchResults from './results';
+
+const messages = defineMessages({
+  title: { id: 'explore.title', defaultMessage: 'Explore' },
+  searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
+});
+
+const mapStateToProps = state => ({
+  layout: state.getIn(['meta', 'layout']),
+  isSearching: state.getIn(['search', 'submitted']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Explore extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    isSearching: PropTypes.bool,
+    layout: PropTypes.string,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { intl, multiColumn, isSearching, layout } = this.props;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        {layout === 'mobile' ? (
+          <div className='explore__search-header'>
+            <Search />
+          </div>
+        ) : (
+          <ColumnHeader
+            icon={isSearching ? 'search' : 'globe'}
+            title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
+            onClick={this.handleHeaderClick}
+            multiColumn={multiColumn}
+          />
+        )}
+
+        <div className='scrollable scrollable--flex'>
+          {isSearching ? (
+            <SearchResults />
+          ) : (
+            <React.Fragment>
+              <div className='account__section-headline'>
+                <NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
+                <NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
+                <NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
+                <NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>
+              </div>
+
+              <Switch>
+                <Route path='/explore/tags' component={Tags} />
+                <Route path='/explore/links' component={Links} />
+                <Route path='/explore/suggestions' component={Suggestions} />
+                <Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} />
+              </Switch>
+            </React.Fragment>
+          )}
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/links.js b/app/javascript/mastodon/features/explore/links.js
new file mode 100644
index 000000000..6649fb6e4
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/links.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Story from './components/story';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingLinks } from 'mastodon/actions/trends';
+
+const mapStateToProps = state => ({
+  links: state.getIn(['trends', 'links', 'items']),
+  isLoading: state.getIn(['trends', 'links', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Links extends React.PureComponent {
+
+  static propTypes = {
+    links: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingLinks());
+  }
+
+  render () {
+    const { isLoading, links } = this.props;
+
+    return (
+      <div className='explore__links'>
+        {isLoading ? (<LoadingIndicator />) : links.map(link => (
+          <Story
+            key={link.get('id')}
+            url={link.get('url')}
+            title={link.get('title')}
+            publisher={link.get('provider_name')}
+            sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
+            thumbnail={link.get('image')}
+            blurhash={link.get('blurhash')}
+          />
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/results.js b/app/javascript/mastodon/features/explore/results.js
new file mode 100644
index 000000000..27e8aaa4f
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/results.js
@@ -0,0 +1,113 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { expandSearch } from 'mastodon/actions/search';
+import Account from 'mastodon/containers/account_container';
+import Status from 'mastodon/containers/status_container';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import { List as ImmutableList } from 'immutable';
+import LoadMore from 'mastodon/components/load_more';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+
+const mapStateToProps = state => ({
+  isLoading: state.getIn(['search', 'isLoading']),
+  results: state.getIn(['search', 'results']),
+});
+
+const appendLoadMore = (id, list, onLoadMore) => {
+  if (list.size >= 5) {
+    return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />);
+  } else {
+    return list;
+  }
+};
+
+const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts').map(item => (
+  <Account key={`account-${item}`} id={item} />
+)), onLoadMore);
+
+const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags').map(item => (
+  <Hashtag key={`tag-${item.get('name')}`} hashtag={item} />
+)), onLoadMore);
+
+const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses').map(item => (
+  <Status key={`status-${item}`} id={item} />
+)), onLoadMore);
+
+export default @connect(mapStateToProps)
+class Results extends React.PureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map,
+    isLoading: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  state = {
+    type: 'all',
+  };
+
+  handleSelectAll = () => this.setState({ type: 'all' });
+  handleSelectAccounts = () => this.setState({ type: 'accounts' });
+  handleSelectHashtags = () => this.setState({ type: 'hashtags' });
+  handleSelectStatuses = () => this.setState({ type: 'statuses' });
+  handleLoadMoreAccounts = () => this.loadMore('accounts');
+  handleLoadMoreStatuses = () => this.loadMore('statuses');
+  handleLoadMoreHashtags = () => this.loadMore('hashtags');
+
+  loadMore (type) {
+    const { dispatch } = this.props;
+    dispatch(expandSearch(type));
+  }
+
+  render () {
+    const { isLoading, results } = this.props;
+    const { type } = this.state;
+
+    let filteredResults = ImmutableList();
+
+    if (!isLoading) {
+      switch(type) {
+      case 'all':
+        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
+        break;
+      case 'accounts':
+        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
+        break;
+      case 'hashtags':
+        filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
+        break;
+      case 'statuses':
+        filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
+        break;
+      }
+
+      if (filteredResults.size === 0) {
+        filteredResults = (
+          <div className='empty-column-indicator'>
+            <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
+          </div>
+        );
+      }
+    }
+
+    return (
+      <React.Fragment>
+        <div className='account__section-headline'>
+          <button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
+          <button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
+          <button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
+          <button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></button>
+        </div>
+
+        <div className='explore__search-results'>
+          {isLoading ? (<LoadingIndicator />) : filteredResults}
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/statuses.js b/app/javascript/mastodon/features/explore/statuses.js
new file mode 100644
index 000000000..4e5530d84
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/statuses.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusList from 'mastodon/components/status_list';
+import { FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { fetchTrendingStatuses } from 'mastodon/actions/trends';
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'trending', 'items']),
+  isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
+});
+
+export default @connect(mapStateToProps)
+class Statuses extends React.PureComponent {
+
+  static propTypes = {
+    statusIds: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingStatuses());
+  }
+
+  render () {
+    const { isLoading, statusIds, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
+
+    return (
+      <StatusList
+        trackScroll
+        statusIds={statusIds}
+        scrollKey='explore-statuses'
+        hasMore={false}
+        isLoading={isLoading}
+        emptyMessage={emptyMessage}
+        bindToDocument={!multiColumn}
+        withCounters
+      />
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/suggestions.js b/app/javascript/mastodon/features/explore/suggestions.js
new file mode 100644
index 000000000..c094a8d93
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/suggestions.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Account from 'mastodon/containers/account_container';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchSuggestions } from 'mastodon/actions/suggestions';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['suggestions', 'items']),
+  isLoading: state.getIn(['suggestions', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Suggestions extends React.PureComponent {
+
+  static propTypes = {
+    isLoading: PropTypes.bool,
+    suggestions: ImmutablePropTypes.list,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchSuggestions(true));
+  }
+
+  render () {
+    const { isLoading, suggestions } = this.props;
+
+    return (
+      <div className='explore__links'>
+        {isLoading ? (<LoadingIndicator />) : suggestions.map(suggestion => (
+          <Account key={suggestion.get('account')} id={suggestion.get('account')} />
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/explore/tags.js b/app/javascript/mastodon/features/explore/tags.js
new file mode 100644
index 000000000..c0ad9fc6e
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/tags.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+import { connect } from 'react-redux';
+import { fetchTrendingHashtags } from 'mastodon/actions/trends';
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['trends', 'tags', 'items']),
+  isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class Tags extends React.PureComponent {
+
+  static propTypes = {
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchTrendingHashtags());
+  }
+
+  render () {
+    const { isLoading, hashtags } = this.props;
+
+    return (
+      <div className='explore__links'>
+        {isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => (
+          <Hashtag key={hashtag.get('name')} hashtag={hashtag} />
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
index 7a5268780..a73832db7 100644
--- a/app/javascript/mastodon/features/getting_started/containers/trends_container.js
+++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
@@ -1,13 +1,13 @@
 import { connect } from 'react-redux';
-import { fetchTrends } from 'mastodon/actions/trends';
+import { fetchTrendingHashtags } from 'mastodon/actions/trends';
 import Trends from '../components/trends';
 
 const mapStateToProps = state => ({
-  trends: state.getIn(['trends', 'items']),
+  trends: state.getIn(['trends', 'tags', 'items']),
 });
 
 const mapDispatchToProps = dispatch => ({
-  fetchTrends: () => dispatch(fetchTrends()),
+  fetchTrends: () => dispatch(fetchTrendingHashtags()),
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(Trends);
diff --git a/app/javascript/mastodon/features/search/index.js b/app/javascript/mastodon/features/search/index.js
deleted file mode 100644
index 76bf70d4b..000000000
--- a/app/javascript/mastodon/features/search/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import SearchContainer from 'mastodon/features/compose/containers/search_container';
-import SearchResultsContainer from 'mastodon/features/compose/containers/search_results_container';
-
-const Search = () => (
-  <div className='column search-page'>
-    <SearchContainer />
-
-    <div className='drawer__pager'>
-      <div className='drawer__inner darker'>
-        <SearchResultsContainer />
-      </div>
-    </div>
-  </div>
-);
-
-export default Search;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 193637113..db047f5f0 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -53,7 +53,7 @@ const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
 });
 
-const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/search|^\/getting-started|^\/start/);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/explore|^\/getting-started|^\/start/);
 
 export default @(component => injectIntl(component, { withRef: true }))
 class ColumnsArea extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js
index 901dbdfcb..a70e5ab61 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.js
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js
@@ -13,6 +13,7 @@ const NavigationPanel = () => (
     <NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
     <FollowRequestsNavLink />
+    <NavLink className='column-link column-link--transparent' to='/explore' data-preview-title-id='explore.title' data-preview-icon='globe'><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='explore.title' defaultMessage='Explore' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
     <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index a023bcf34..195403fd3 100644
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -10,9 +10,9 @@ import NotificationsCounterIcon from './notifications_counter_icon';
 export const links = [
   <NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
   <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
-  <NavLink className='tabs-bar__link' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
-  <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
+  <NavLink className='tabs-bar__link optional' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link optional' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/explore' data-preview-title-id='tabs_bar.search' data-preview-icon='search' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
   <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
 ];
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 3feffa656..2d0136992 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -49,8 +49,8 @@ import {
   Mutes,
   PinnedStatuses,
   Lists,
-  Search,
   Directory,
+  Explore,
   FollowRecommendations,
 } from './util/async-components';
 import { me } from '../../initial_state';
@@ -167,8 +167,8 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
           <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
-          <WrappedRoute path='/search' component={Search} content={children} />
           <WrappedRoute path='/directory' component={Directory} content={children} />
+          <WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
           <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
 
           <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 5349bd656..92c683e2f 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -138,10 +138,6 @@ export function ListAdder () {
   return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
 }
 
-export function Search () {
-  return import(/*webpackChunkName: "features/search" */'../../search');
-}
-
 export function Tesseract () {
   return import(/*webpackChunkName: "tesseract" */'tesseract.js');
 }
@@ -161,3 +157,7 @@ export function FollowRecommendations () {
 export function CompareHistoryModal () {
   return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
 }
+
+export function Explore () {
+  return import(/* webpackChunkName: "features/explore" */'../../explore');
+}
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 875b2d92b..23bbe4d99 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -1,6 +1,8 @@
 import {
   SEARCH_CHANGE,
   SEARCH_CLEAR,
+  SEARCH_FETCH_REQUEST,
+  SEARCH_FETCH_FAIL,
   SEARCH_FETCH_SUCCESS,
   SEARCH_SHOW,
   SEARCH_EXPAND_SUCCESS,
@@ -17,6 +19,7 @@ const initialState = ImmutableMap({
   submitted: false,
   hidden: false,
   results: ImmutableMap(),
+  isLoading: false,
   searchTerm: '',
 });
 
@@ -37,12 +40,22 @@ export default function search(state = initialState, action) {
   case COMPOSE_MENTION:
   case COMPOSE_DIRECT:
     return state.set('hidden', true);
+  case SEARCH_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case SEARCH_FETCH_FAIL:
+    return state.set('isLoading', false);
   case SEARCH_FETCH_SUCCESS:
-    return state.set('results', ImmutableMap({
-      accounts: ImmutableList(action.results.accounts.map(item => item.id)),
-      statuses: ImmutableList(action.results.statuses.map(item => item.id)),
-      hashtags: fromJS(action.results.hashtags),
-    })).set('submitted', true).set('searchTerm', action.searchTerm);
+    return state.withMutations(map => {
+      map.set('results', ImmutableMap({
+        accounts: ImmutableList(action.results.accounts.map(item => item.id)),
+        statuses: ImmutableList(action.results.statuses.map(item => item.id)),
+        hashtags: fromJS(action.results.hashtags),
+      }));
+
+      map.set('submitted', true);
+      map.set('searchTerm', action.searchTerm);
+      map.set('isLoading', false);
+    });
   case SEARCH_EXPAND_SUCCESS:
     const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
     return state.updateIn(['results', action.searchType], list => list.concat(results));
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
index 9f8f28dee..49bc94a40 100644
--- a/app/javascript/mastodon/reducers/status_lists.js
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -17,6 +17,11 @@ import {
 import {
   PINNED_STATUSES_FETCH_SUCCESS,
 } from '../actions/pin_statuses';
+import {
+  TRENDS_STATUSES_FETCH_REQUEST,
+  TRENDS_STATUSES_FETCH_SUCCESS,
+  TRENDS_STATUSES_FETCH_FAIL,
+} from '../actions/trends';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 import {
   FAVOURITE_SUCCESS,
@@ -26,6 +31,10 @@ import {
   PIN_SUCCESS,
   UNPIN_SUCCESS,
 } from '../actions/interactions';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from '../actions/accounts';
 
 const initialState = ImmutableMap({
   favourites: ImmutableMap({
@@ -43,6 +52,11 @@ const initialState = ImmutableMap({
     loaded: false,
     items: ImmutableList(),
   }),
+  trending: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
 });
 
 const normalizeList = (state, listType, statuses, next) => {
@@ -96,6 +110,12 @@ export default function statusLists(state = initialState, action) {
     return normalizeList(state, 'bookmarks', action.statuses, action.next);
   case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
     return appendToList(state, 'bookmarks', action.statuses, action.next);
+  case TRENDS_STATUSES_FETCH_REQUEST:
+    return state.setIn(['trending', 'isLoading'], true);
+  case TRENDS_STATUSES_FETCH_FAIL:
+    return state.setIn(['trending', 'isLoading'], false);
+  case TRENDS_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'trending', action.statuses, action.next);
   case FAVOURITE_SUCCESS:
     return prependOneToList(state, 'favourites', action.status);
   case UNFAVOURITE_SUCCESS:
@@ -110,6 +130,9 @@ export default function statusLists(state = initialState, action) {
     return prependOneToList(state, 'pins', action.status);
   case UNPIN_SUCCESS:
     return removeOneFromList(state, 'pins', action.status);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return state.updateIn(['trending', 'items'], ImmutableList(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js
index 5cecc8fca..3e01bd07d 100644
--- a/app/javascript/mastodon/reducers/trends.js
+++ b/app/javascript/mastodon/reducers/trends.js
@@ -1,22 +1,45 @@
-import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends';
+import {
+  TRENDS_TAGS_FETCH_REQUEST,
+  TRENDS_TAGS_FETCH_SUCCESS,
+  TRENDS_TAGS_FETCH_FAIL,
+  TRENDS_LINKS_FETCH_REQUEST,
+  TRENDS_LINKS_FETCH_SUCCESS,
+  TRENDS_LINKS_FETCH_FAIL,
+} from 'mastodon/actions/trends';
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 const initialState = ImmutableMap({
-  items: ImmutableList(),
-  isLoading: false,
+  tags: ImmutableMap({
+    items: ImmutableList(),
+    isLoading: false,
+  }),
+
+  links: ImmutableMap({
+    items: ImmutableList(),
+    isLoading: false,
+  }),
 });
 
 export default function trendsReducer(state = initialState, action) {
   switch(action.type) {
-  case TRENDS_FETCH_REQUEST:
-    return state.set('isLoading', true);
-  case TRENDS_FETCH_SUCCESS:
+  case TRENDS_TAGS_FETCH_REQUEST:
+    return state.setIn(['tags', 'isLoading'], true);
+  case TRENDS_TAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['tags', 'items'], fromJS(action.trends));
+      map.setIn(['tags', 'isLoading'], false);
+    });
+  case TRENDS_TAGS_FETCH_FAIL:
+    return state.setIn(['tags', 'isLoading'], false);
+  case TRENDS_LINKS_FETCH_REQUEST:
+    return state.setIn(['links', 'isLoading'], true);
+  case TRENDS_LINKS_FETCH_SUCCESS:
     return state.withMutations(map => {
-      map.set('items', fromJS(action.trends));
-      map.set('isLoading', false);
+      map.setIn(['links', 'items'], fromJS(action.trends));
+      map.setIn(['links', 'isLoading'], false);
     });
-  case TRENDS_FETCH_FAIL:
-    return state.set('isLoading', false);
+  case TRENDS_LINKS_FETCH_FAIL:
+    return state.setIn(['links', 'isLoading'], false);
   default:
     return state;
   }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 6d30bea83..647e7ea31 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -2797,6 +2797,10 @@ a.account__display-name {
     position: relative;
     min-height: 120px;
   }
+
+  .scrollable {
+    flex: 1 1 auto;
+  }
 }
 
 .scrollable.fullscreen {
@@ -7724,3 +7728,122 @@ noscript {
     text-align: center;
   }
 }
+
+.explore__search-header {
+  background: $ui-base-color;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 15px;
+
+  .search {
+    width: 100%;
+    margin-bottom: 0;
+  }
+
+  .search__input {
+    border-radius: 4px;
+    color: $inverted-text-color;
+    background: $simple-background-color;
+    padding: 10px;
+
+    &::placeholder {
+      color: $dark-text-color;
+    }
+  }
+
+  .search .fa {
+    top: 10px;
+    right: 10px;
+    color: $dark-text-color;
+  }
+
+  .search .fa-times-circle {
+    top: 12px;
+  }
+}
+
+.explore__search-results {
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+}
+
+.story {
+  display: flex;
+  align-items: center;
+  color: $primary-text-color;
+  text-decoration: none;
+  padding: 15px 0;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &:hover,
+  &:active,
+  &:focus {
+    background-color: lighten($ui-base-color, 4%);
+  }
+
+  &__details {
+    padding: 0 15px;
+    flex: 1 1 auto;
+
+    &__publisher {
+      color: $darker-text-color;
+      margin-bottom: 4px;
+    }
+
+    &__title {
+      font-size: 19px;
+      line-height: 24px;
+      font-weight: 500;
+      margin-bottom: 4px;
+    }
+
+    &__shared {
+      color: $darker-text-color;
+    }
+  }
+
+  &__thumbnail {
+    flex: 0 0 auto;
+    margin: 0 15px;
+    position: relative;
+    width: 120px;
+    height: 120px;
+
+    .skeleton {
+      width: 100%;
+      height: 100%;
+    }
+
+    img {
+      border-radius: 4px;
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+
+    &__preview {
+      border-radius: 4px;
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: fill;
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 0;
+
+      &--hidden {
+        display: none;
+      }
+    }
+  }
+}