From 9bd23dc4e51ba47283a8e3a66cd94b4e624a5235 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Sun, 27 May 2018 21:45:30 +0200
Subject: [PATCH 01/14] Track trending tags (#7638)

* Track trending tags

- Half-life of 1 day
- Historical usage in daily buckets (last 7 days stored)
- GET /api/v1/trends

Fix #271

* Add trends to web UI

* Don't render compose form on search route, adjust search results header

* Disqualify tag from trends if it's in disallowed hashtags setting

* Count distinct accounts using tag, ignore silenced accounts
---
 app/controllers/api/v1/trends_controller.rb   | 17 ++++
 app/javascript/mastodon/actions/trends.js     | 32 +++++++
 .../compose/components/search_results.js      | 59 ++++++++++++-
 .../containers/search_results_container.js    |  8 +-
 .../mastodon/features/compose/index.js        |  4 +-
 app/javascript/mastodon/reducers/index.js     |  2 +
 app/javascript/mastodon/reducers/trends.js    | 13 +++
 .../styles/mastodon/components.scss           | 83 ++++++++++++++++++-
 app/models/tag.rb                             | 16 ++++
 app/models/trending_tags.rb                   | 61 ++++++++++++++
 app/serializers/rest/tag_serializer.rb        | 11 +++
 app/services/process_hashtags_service.rb      |  6 +-
 config/routes.rb                              |  1 +
 package.json                                  |  1 +
 yarn.lock                                     |  6 ++
 15 files changed, 310 insertions(+), 10 deletions(-)
 create mode 100644 app/controllers/api/v1/trends_controller.rb
 create mode 100644 app/javascript/mastodon/actions/trends.js
 create mode 100644 app/javascript/mastodon/reducers/trends.js
 create mode 100644 app/models/trending_tags.rb
 create mode 100644 app/serializers/rest/tag_serializer.rb

diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb
new file mode 100644
index 0000000000..bcea9857e8
--- /dev/null
+++ b/app/controllers/api/v1/trends_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::V1::TrendsController < Api::BaseController
+  before_action :set_tags
+
+  respond_to :json
+
+  def index
+    render json: @tags, each_serializer: REST::TagSerializer
+  end
+
+  private
+
+  def set_tags
+    @tags = TrendingTags.get(limit_param(10))
+  end
+end
diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js
new file mode 100644
index 0000000000..853e4f60ae
--- /dev/null
+++ b/app/javascript/mastodon/actions/trends.js
@@ -0,0 +1,32 @@
+import api from '../api';
+
+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 fetchTrends = () => (dispatch, getState) => {
+  dispatch(fetchTrendsRequest());
+
+  api(getState)
+    .get('/api/v1/trends')
+    .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
+    .catch(err => dispatch(fetchTrendsFail(err)));
+};
+
+export const fetchTrendsRequest = () => ({
+  type: TRENDS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendsSuccess = trends => ({
+  type: TRENDS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendsFail = error => ({
+  type: TRENDS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index 84455563c4..f2655c14d2 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -1,23 +1,75 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, FormattedNumber } from 'react-intl';
 import AccountContainer from '../../../containers/account_container';
 import StatusContainer from '../../../containers/status_container';
 import { Link } from 'react-router-dom';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+
+const shortNumberFormat = number => {
+  if (number < 1000) {
+    return <FormattedNumber value={number} />;
+  } else {
+    return <React.Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</React.Fragment>;
+  }
+};
 
 export default class SearchResults extends ImmutablePureComponent {
 
   static propTypes = {
     results: ImmutablePropTypes.map.isRequired,
+    trends: ImmutablePropTypes.list,
+    fetchTrends: PropTypes.func.isRequired,
   };
 
+  componentDidMount () {
+    const { fetchTrends } = this.props;
+    fetchTrends();
+  }
+
   render () {
-    const { results } = this.props;
+    const { results, trends } = this.props;
 
     let accounts, statuses, hashtags;
     let count = 0;
 
+    if (results.isEmpty()) {
+      return (
+        <div className='search-results'>
+          <div className='trends'>
+            <div className='trends__header'>
+              <i className='fa fa-fire fa-fw' />
+              <FormattedMessage id='trends.header' defaultMessage='Trending now' />
+            </div>
+
+            {trends && trends.map(hashtag => (
+              <div className='trends__item' key={hashtag.get('name')}>
+                <div className='trends__item__name'>
+                  <Link to={`/timelines/tag/${hashtag.get('name')}`}>
+                    #<span>{hashtag.get('name')}</span>
+                  </Link>
+
+                  <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
+                </div>
+
+                <div className='trends__item__current'>
+                  {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
+                </div>
+
+                <div className='trends__item__sparkline'>
+                  <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
+                    <SparklinesCurve style={{ fill: 'none' }} />
+                  </Sparklines>
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      );
+    }
+
     if (results.get('accounts') && results.get('accounts').size > 0) {
       count   += results.get('accounts').size;
       accounts = (
@@ -48,7 +100,7 @@ export default class SearchResults extends ImmutablePureComponent {
 
           {results.get('hashtags').map(hashtag => (
             <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
-              #{hashtag}
+              {hashtag}
             </Link>
           ))}
         </div>
@@ -58,6 +110,7 @@ export default class SearchResults extends ImmutablePureComponent {
     return (
       <div className='search-results'>
         <div className='search-results__header'>
+          <i className='fa fa-search fa-fw' />
           <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
         </div>
 
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
index 16d95d417e..7273460e28 100644
--- a/app/javascript/mastodon/features/compose/containers/search_results_container.js
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -1,8 +1,14 @@
 import { connect } from 'react-redux';
 import SearchResults from '../components/search_results';
+import { fetchTrends } from '../../../actions/trends';
 
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
+  trends: state.get('trends'),
 });
 
-export default connect(mapStateToProps)(SearchResults);
+const mapDispatchToProps = dispatch => ({
+  fetchTrends: () => dispatch(fetchTrends()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 19aae03322..d8e9ad9ee0 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -101,7 +101,7 @@ export default class Compose extends React.PureComponent {
         {(multiColumn || isSearchPage) && <SearchContainer /> }
 
         <div className='drawer__pager'>
-          <div className='drawer__inner' onFocus={this.onFocus}>
+          {!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
             <NavigationContainer onClose={this.onBlur} />
             <ComposeFormContainer />
             {multiColumn && (
@@ -109,7 +109,7 @@ export default class Compose extends React.PureComponent {
                 <img alt='' draggable='false' src={elephantUIPlane} />
               </div>
             )}
-          </div>
+          </div>}
 
           <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
             {({ x }) => (
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3d9a6a1329..019c1f466b 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -26,6 +26,7 @@ import height_cache from './height_cache';
 import custom_emojis from './custom_emojis';
 import lists from './lists';
 import listEditor from './list_editor';
+import trends from './trends';
 
 const reducers = {
   dropdown_menu,
@@ -55,6 +56,7 @@ const reducers = {
   custom_emojis,
   lists,
   listEditor,
+  trends,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js
new file mode 100644
index 0000000000..95cf8f2841
--- /dev/null
+++ b/app/javascript/mastodon/reducers/trends.js
@@ -0,0 +1,13 @@
+import { TRENDS_FETCH_SUCCESS } from '../actions/trends';
+import { fromJS } from 'immutable';
+
+const initialState = null;
+
+export default function trendsReducer(state = initialState, action) {
+  switch(action.type) {
+  case TRENDS_FETCH_SUCCESS:
+    return fromJS(action.trends);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 2724454fbd..c66bc427c0 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3334,9 +3334,15 @@ a.status-card {
   color: $dark-text-color;
   background: lighten($ui-base-color, 2%);
   border-bottom: 1px solid darken($ui-base-color, 4%);
-  padding: 15px 10px;
-  font-size: 14px;
+  padding: 15px;
   font-weight: 500;
+  font-size: 16px;
+  cursor: default;
+
+  .fa {
+    display: inline-block;
+    margin-right: 5px;
+  }
 }
 
 .search-results__section {
@@ -5209,3 +5215,76 @@ noscript {
     background: $ui-base-color;
   }
 }
+
+.trends {
+  &__header {
+    color: $dark-text-color;
+    background: lighten($ui-base-color, 2%);
+    border-bottom: 1px solid darken($ui-base-color, 4%);
+    font-weight: 500;
+    padding: 15px;
+    font-size: 16px;
+    cursor: default;
+
+    .fa {
+      display: inline-block;
+      margin-right: 5px;
+    }
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    padding: 15px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__name {
+      flex: 1 1 auto;
+      color: $dark-text-color;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+
+      strong {
+        font-weight: 500;
+      }
+
+      a {
+        color: $darker-text-color;
+        text-decoration: none;
+        font-size: 14px;
+        font-weight: 500;
+        display: block;
+
+        &:hover,
+        &:focus,
+        &:active {
+          span {
+            text-decoration: underline;
+          }
+        }
+      }
+    }
+
+    &__current {
+      width: 100px;
+      font-size: 24px;
+      line-height: 36px;
+      font-weight: 500;
+      text-align: center;
+      color: $secondary-text-color;
+    }
+
+    &__sparkline {
+      width: 50px;
+
+      path {
+        stroke: lighten($highlight-text-color, 6%) !important;
+      }
+    }
+  }
+}
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 8b1b024120..4f31f796e6 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -21,6 +21,22 @@ class Tag < ApplicationRecord
     name
   end
 
+  def history
+    days = []
+
+    7.times do |i|
+      day = i.days.ago.beginning_of_day.to_i
+
+      days << {
+        day: day.to_s,
+        uses: Redis.current.get("activity:tags:#{id}:#{day}") || '0',
+        accounts: Redis.current.pfcount("activity:tags:#{id}:#{day}:accounts").to_s,
+      }
+    end
+
+    days
+  end
+
   class << self
     def search_for(term, limit = 5)
       pattern = sanitize_sql_like(term.strip) + '%'
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
new file mode 100644
index 0000000000..eedd92644c
--- /dev/null
+++ b/app/models/trending_tags.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class TrendingTags
+  KEY                  = 'trending_tags'
+  HALF_LIFE            = 1.day.to_i
+  MAX_ITEMS            = 500
+  EXPIRE_HISTORY_AFTER = 7.days.seconds
+
+  class << self
+    def record_use!(tag, account, at_time = Time.now.utc)
+      return if disallowed_hashtags.include?(tag.name) || account.silenced?
+
+      increment_vote!(tag.id, at_time)
+      increment_historical_use!(tag.id, at_time)
+      increment_unique_use!(tag.id, account.id, at_time)
+    end
+
+    def get(limit)
+      tag_ids = redis.zrevrange(KEY, 0, limit).map(&:to_i)
+      tags    = Tag.where(id: tag_ids).to_a.map { |tag| [tag.id, tag] }.to_h
+      tag_ids.map { |tag_id| tags[tag_id] }.compact
+    end
+
+    private
+
+    def increment_vote!(tag_id, at_time)
+      redis.zincrby(KEY, (2**((at_time.to_i - epoch) / HALF_LIFE)).to_f, tag_id.to_s)
+      redis.zremrangebyrank(KEY, 0, -MAX_ITEMS) if rand < (2.to_f / MAX_ITEMS)
+    end
+
+    def increment_historical_use!(tag_id, at_time)
+      key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}"
+      redis.incrby(key, 1)
+      redis.expire(key, EXPIRE_HISTORY_AFTER)
+    end
+
+    def increment_unique_use!(tag_id, account_id, at_time)
+      key = "activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts"
+      redis.pfadd(key, account_id)
+      redis.expire(key, EXPIRE_HISTORY_AFTER)
+    end
+
+    # The epoch needs to be 2.5 years in the future if the half-life is one day
+    # While dynamic, it will always be the same within one year
+    def epoch
+      @epoch ||= Date.new(Date.current.year + 2.5, 10, 1).to_datetime.to_i
+    end
+
+    def disallowed_hashtags
+      return @disallowed_hashtags if defined?(@disallowed_hashtags)
+
+      @disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
+      @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
+      @disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
+    end
+
+    def redis
+      Redis.current
+    end
+  end
+end
diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb
new file mode 100644
index 0000000000..74aa571a4c
--- /dev/null
+++ b/app/serializers/rest/tag_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class REST::TagSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :name, :url, :history
+
+  def url
+    tag_url(object)
+  end
+end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 5b45c865ff..0695922b86 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -4,8 +4,10 @@ class ProcessHashtagsService < BaseService
   def call(status, tags = [])
     tags = Extractor.extract_hashtags(status.text) if status.local?
 
-    tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag|
-      status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
+    tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
+      tag = Tag.where(name: name).first_or_create(name: name)
+      status.tags << tag
+      TrendingTags.record_use!(tag, status.account, status.created_at)
     end
   end
 end
diff --git a/config/routes.rb b/config/routes.rb
index 3042b5ea0a..2fcb885ed8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -254,6 +254,7 @@ Rails.application.routes.draw do
       resources :mutes,      only: [:index]
       resources :favourites, only: [:index]
       resources :reports,    only: [:index, :create]
+      resources :trends,     only: [:index]
 
       namespace :apps do
         get :verify_credentials, to: 'credentials#show'
diff --git a/package.json b/package.json
index 61f38409c0..6ee6f98d34 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,7 @@
     "react-redux-loading-bar": "^2.9.3",
     "react-router-dom": "^4.1.1",
     "react-router-scroll-4": "^1.0.0-beta.1",
+    "react-sparklines": "^1.7.0",
     "react-swipeable-views": "^0.12.3",
     "react-textarea-autosize": "^5.2.1",
     "react-toggle": "^4.0.1",
diff --git a/yarn.lock b/yarn.lock
index 50c88557d2..de48c995a6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6124,6 +6124,12 @@ react-router@^4.2.0:
     prop-types "^15.5.4"
     warning "^3.0.0"
 
+react-sparklines@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60"
+  dependencies:
+    prop-types "^15.5.10"
+
 react-swipeable-views-core@^0.12.11:
   version "0.12.11"
   resolved "https://registry.yarnpkg.com/react-swipeable-views-core/-/react-swipeable-views-core-0.12.11.tgz#3cf2b4daffbb36f9d69bd19bf5b2d5370b6b2c1b"

From dfbadd68374ab5ddcaa75907261bd91da688fc6b Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Mon, 28 May 2018 02:42:06 +0200
Subject: [PATCH 02/14] Replace recursion in status mapStateToProps (#7645)

---
 .../mastodon/features/status/index.js         | 21 ++++++++-----------
 1 file changed, 9 insertions(+), 12 deletions(-)

diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 2e53dfa7e7..505a88a3ff 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -62,31 +62,28 @@ const makeMapStateToProps = () => {
 
     if (status) {
       ancestorsIds = ancestorsIds.withMutations(mutable => {
-        function addAncestor(id) {
-          if (id) {
-            const inReplyTo = state.getIn(['contexts', 'inReplyTos', id]);
+        let id = status.get('in_reply_to_id');
 
-            mutable.unshift(id);
-            addAncestor(inReplyTo);
-          }
+        while (id) {
+          mutable.unshift(id);
+          id = state.getIn(['contexts', 'inReplyTos', id]);
         }
-
-        addAncestor(status.get('in_reply_to_id'));
       });
 
       descendantsIds = descendantsIds.withMutations(mutable => {
-        function addDescendantOf(id) {
+        const ids = [status.get('id')];
+
+        while (ids.length > 0) {
+          let id        = ids.shift();
           const replies = state.getIn(['contexts', 'replies', id]);
 
           if (replies) {
             replies.forEach(reply => {
               mutable.push(reply);
-              addDescendantOf(reply);
+              ids.unshift(reply);
             });
           }
         }
-
-        addDescendantOf(status.get('id'));
       });
     }
 

From ab36e0ef72a52e5cc184d63cda0fe411c8b4086a Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Mon, 28 May 2018 05:21:04 +0200
Subject: [PATCH 03/14] Record trending tags from ActivityPub, too (#7647)

---
 app/lib/activitypub/activity/create.rb | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 010ed1bb5f..869749f1ec 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -78,9 +78,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     return if tag['name'].blank?
 
     hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
-    hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+    hashtag = Tag.where(name: hashtag).first_or_create(name: hashtag)
 
-    status.tags << hashtag unless status.tags.include?(hashtag)
+    return if status.tags.include?(hashtag)
+
+    status.tags << hashtag
+    TrendingTags.record_use!(hashtag, status.account, status.created_at)
   rescue ActiveRecord::RecordInvalid
     nil
   end

From b87a1229c74aebec02cd08091f25613f6dff4428 Mon Sep 17 00:00:00 2001
From: tateisu <tateisu@gmail.com>
Date: Mon, 28 May 2018 18:04:06 +0900
Subject: [PATCH 04/14] optimize direct timeline (#7614)

* optimize direct timeline

* fix typo in class name

* change filter condition for direct timeline

* fix codestyle issue

* revoke index_accounts_not_silenced because direct timeline does not use it.

* revoke index_accounts_not_silenced because direct timeline does not use it.

* fix rspec test condition.

* fix rspec test condition.

* fix rspec test condition.

* revoke adding column and partial index

* (direct timeline) move merging logic to model

* fix pagination parameter

* add method arguments that switches return array of status or cache_ids

* fix order by

* returns ActiveRecord.Relation in default behavor

* fix codestyle issue
---
 .../api/v1/timelines/direct_controller.rb     | 15 ++++---
 app/models/status.rb                          | 43 ++++++++++++++++---
 spec/models/status_spec.rb                    | 23 +++++-----
 3 files changed, 59 insertions(+), 22 deletions(-)

diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb
index d455227eb5..ef64078be8 100644
--- a/app/controllers/api/v1/timelines/direct_controller.rb
+++ b/app/controllers/api/v1/timelines/direct_controller.rb
@@ -23,15 +23,18 @@ class Api::V1::Timelines::DirectController < Api::BaseController
   end
 
   def direct_statuses
-    direct_timeline_statuses.paginate_by_max_id(
-      limit_param(DEFAULT_STATUSES_LIMIT),
-      params[:max_id],
-      params[:since_id]
-    )
+    direct_timeline_statuses
   end
 
   def direct_timeline_statuses
-    Status.as_direct_timeline(current_account)
+    # this query requires built in pagination.
+    Status.as_direct_timeline(
+      current_account,
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id],
+      true # returns array of cache_ids object
+    )
   end
 
   def insert_pagination_headers
diff --git a/app/models/status.rb b/app/models/status.rb
index 853e75b43f..54f3f68f54 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -188,12 +188,45 @@ class Status < ApplicationRecord
       where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
     end
 
-    def as_direct_timeline(account)
-      query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
-              .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
-              .where(visibility: [:direct])
+    def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
+      # direct timeline is mix of direct message from_me and to_me.
+      # 2 querys are executed with pagination.
+      # constant expression using arel_table is required for partial index
 
-      apply_timeline_filters(query, account, false)
+      # _from_me part does not require any timeline filters
+      query_from_me = where(account_id: account.id)
+                      .where(Status.arel_table[:visibility].eq(3))
+                      .limit(limit)
+                      .order('statuses.id DESC')
+
+      # _to_me part requires mute and block filter.
+      # FIXME: may we check mutes.hide_notifications?
+      query_to_me = Status
+                    .joins(:mentions)
+                    .merge(Mention.where(account_id: account.id))
+                    .where(Status.arel_table[:visibility].eq(3))
+                    .limit(limit)
+                    .order('mentions.status_id DESC')
+                    .not_excluded_by_account(account)
+
+      if max_id.present?
+        query_from_me = query_from_me.where('statuses.id < ?', max_id)
+        query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
+      end
+
+      if since_id.present?
+        query_from_me = query_from_me.where('statuses.id > ?', since_id)
+        query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
+      end
+
+      if cache_ids
+        # returns array of cache_ids object that have id and updated_at
+        (query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+      else
+        # returns ActiveRecord.Relation
+        items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+        Status.where(id: items.map(&:id))
+      end
     end
 
     def as_public_timeline(account = nil, local_only = false)
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index c6701018e5..aee4f49b47 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -154,7 +154,7 @@ RSpec.describe Status, type: :model do
 
   describe '#target' do
     it 'returns nil if the status is self-contained' do
-      expect(subject.target).to be_nil
+     expect(subject.target).to be_nil
     end
 
     it 'returns nil if the status is a reply' do
@@ -333,24 +333,25 @@ RSpec.describe Status, type: :model do
       expect(@results).to_not include(@followed_public_status)
     end
 
-    it 'includes direct statuses mentioning recipient from followed' do
-      Fabricate(:mention, account: account, status: @followed_direct_status)
-      expect(@results).to include(@followed_direct_status)
-    end
-
     it 'does not include direct statuses not mentioning recipient from followed' do
       expect(@results).to_not include(@followed_direct_status)
     end
 
-    it 'includes direct statuses mentioning recipient from non-followed' do
-      Fabricate(:mention, account: account, status: @not_followed_direct_status)
-      expect(@results).to include(@not_followed_direct_status)
-    end
-
     it 'does not include direct statuses not mentioning recipient from non-followed' do
       expect(@results).to_not include(@not_followed_direct_status)
     end
 
+    it 'includes direct statuses mentioning recipient from followed' do
+      Fabricate(:mention, account: account, status: @followed_direct_status)
+      results2 = Status.as_direct_timeline(account)
+      expect(results2).to include(@followed_direct_status)
+    end
+
+    it 'includes direct statuses mentioning recipient from non-followed' do
+      Fabricate(:mention, account: account, status: @not_followed_direct_status)
+      results2 = Status.as_direct_timeline(account)
+      expect(results2).to include(@not_followed_direct_status)
+    end
   end
 
   describe '.as_public_timeline' do

From b0b34a5e381c8dd97abd69d817a8b82d3019085b Mon Sep 17 00:00:00 2001
From: Shuhei Kitagawa <shuheiktgw@users.noreply.github.com>
Date: Mon, 28 May 2018 22:56:58 +0900
Subject: [PATCH 05/14] Add a test for emojis_controller (#7652)

---
 spec/controllers/emojis_controller_spec.rb | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)
 create mode 100644 spec/controllers/emojis_controller_spec.rb

diff --git a/spec/controllers/emojis_controller_spec.rb b/spec/controllers/emojis_controller_spec.rb
new file mode 100644
index 0000000000..68bae256d7
--- /dev/null
+++ b/spec/controllers/emojis_controller_spec.rb
@@ -0,0 +1,17 @@
+require 'rails_helper'
+
+describe EmojisController do
+  render_views
+
+  let(:emoji) { Fabricate(:custom_emoji) }
+
+  describe 'GET #show' do
+    subject(:responce) { get :show, params: { id: emoji.id, format: :json } }
+    subject(:body) { JSON.parse(response.body, symbolize_names: true) }
+
+    it 'returns the right response' do
+      expect(responce).to have_http_status 200
+      expect(body[:name]).to eq ':coolcat:'
+    end
+  end
+end

From 04a2cf8bcc7ab5878b29ee60af41dd661a6e19eb Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Mon, 28 May 2018 19:12:53 +0200
Subject: [PATCH 06/14] Fix incomplete flex style on trends items (#7655)

---
 app/javascript/styles/mastodon/components.scss | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index c66bc427c0..a2a18b5a0a 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -5259,6 +5259,9 @@ noscript {
         font-size: 14px;
         font-weight: 500;
         display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
 
         &:hover,
         &:focus,
@@ -5271,6 +5274,7 @@ noscript {
     }
 
     &__current {
+      flex: 0 0 auto;
       width: 100px;
       font-size: 24px;
       line-height: 36px;
@@ -5280,6 +5284,7 @@ noscript {
     }
 
     &__sparkline {
+      flex: 0 0 auto;
       width: 50px;
 
       path {

From c0355878ba00808d1d8710cb27b1fbb36bcb54e8 Mon Sep 17 00:00:00 2001
From: Lynx Kotoura <admin@sanin.link>
Date: Tue, 29 May 2018 02:13:20 +0900
Subject: [PATCH 07/14] Fix embed, error and onboarding modals in light theme
 (#7656)

---
 .../styles/mastodon-light/diff.scss           | 38 ++++++++++++++++++-
 1 file changed, 36 insertions(+), 2 deletions(-)

diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 2b88b830cf..fe304317d9 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -132,20 +132,54 @@
 .boost-modal,
 .confirmation-modal,
 .mute-modal,
-.report-modal {
+.report-modal,
+.embed-modal,
+.error-modal,
+.onboarding-modal {
   background: $ui-base-color;
 }
 
 .boost-modal__action-bar,
 .confirmation-modal__action-bar,
-.mute-modal__action-bar {
+.mute-modal__action-bar,
+.onboarding-modal__paginator,
+.error-modal__footer {
   background: darken($ui-base-color, 6%);
+
+  .onboarding-modal__nav,
+  .error-modal__nav {
+    &:hover,
+    &:focus,
+    &:active {
+      background-color: darken($ui-base-color, 12%);
+    }
+  }
+}
+
+.display-case__case {
+  background: $white;
+}
+
+.embed-modal .embed-modal__container .embed-modal__html {
+  background: $white;
+
+  &:focus {
+    background: darken($ui-base-color, 6%);
+  }
 }
 
 .react-toggle-track {
   background: $ui-secondary-color;
 }
 
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: darken($ui-secondary-color, 10%);
+}
+
+.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: lighten($ui-highlight-color, 10%);
+}
+
 // Change the default color used for the text in an empty column or on the error column
 .empty-column-indicator,
 .error-column {

From 03f4c214b459a241a1aab08095a986475bd6f8f0 Mon Sep 17 00:00:00 2001
From: takayamaki <fsgiko@gmail.com>
Date: Tue, 29 May 2018 02:14:24 +0900
Subject: [PATCH 08/14] fix: Don't validate MX record in development (#7654)

---
 app/validators/email_mx_validator.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
index d4c7cc252a..3cc5853c6e 100644
--- a/app/validators/email_mx_validator.rb
+++ b/app/validators/email_mx_validator.rb
@@ -4,7 +4,7 @@ require 'resolv'
 
 class EmailMxValidator < ActiveModel::Validator
   def validate(user)
-    return if Rails.env.test?
+    return if Rails.env.test? || Rails.env.development?
     user.errors.add(:email, I18n.t('users.invalid_email')) if invalid_mx?(user.email)
   end
 

From d95642f6d913a99fc44f0ac0695d53534afb7962 Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <akihiko.odaki.4i@stu.hosei.ac.jp>
Date: Tue, 29 May 2018 07:43:47 +0900
Subject: [PATCH 09/14] Cache attachments on external host with service worker
 (#7493)

---
 .env.production.sample                        |  8 ++++++++
 .eslintrc.yml                                 |  3 +++
 .../mastodon/service_worker/entry.js          |  2 +-
 config/webpack/production.js                  | 20 +++++++++++++++++--
 4 files changed, 30 insertions(+), 3 deletions(-)

diff --git a/.env.production.sample b/.env.production.sample
index 24b6b01439..3047f75950 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -88,6 +88,10 @@ SMTP_FROM_ADDRESS=notifications@example.com
 # CDN_HOST=https://assets.example.com
 
 # S3 (optional)
+# The attachment host must allow cross origin request from WEB_DOMAIN or
+# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the
+# following header field:
+# Access-Control-Allow-Origin: https://192.168.1.123:9000/
 # S3_ENABLED=true
 # S3_BUCKET=
 # AWS_ACCESS_KEY_ID=
@@ -97,6 +101,8 @@ SMTP_FROM_ADDRESS=notifications@example.com
 # S3_HOSTNAME=192.168.1.123:9000
 
 # S3 (Minio Config (optional) Please check Minio instance for details)
+# The attachment host must allow cross origin request - see the description
+# above.
 # S3_ENABLED=true
 # S3_BUCKET=
 # AWS_ACCESS_KEY_ID=
@@ -108,6 +114,8 @@ SMTP_FROM_ADDRESS=notifications@example.com
 # S3_SIGNATURE_VERSION=
 
 # Swift (optional)
+# The attachment host must allow cross origin request - see the description
+# above.
 # SWIFT_ENABLED=true
 # SWIFT_USERNAME=
 # For Keystone V3, the value for SWIFT_TENANT should be the project name
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 576e5b70ac..205c9460ae 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -7,6 +7,9 @@ env:
   es6: true
   jest: true
 
+globals:
+  ATTACHMENT_HOST: false
+
 parser: babel-eslint
 
 plugins:
diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js
index ce42271a9a..c1854c1cd2 100644
--- a/app/javascript/mastodon/service_worker/entry.js
+++ b/app/javascript/mastodon/service_worker/entry.js
@@ -49,7 +49,7 @@ self.addEventListener('fetch', function(event) {
 
       return response;
     }));
-  } else if (storageFreeable && process.env.CDN_HOST ? url.host === process.env.CDN_HOST : url.pathname.startsWith('/system/')) {
+  } else if (storageFreeable && (ATTACHMENT_HOST ? url.host === ATTACHMENT_HOST : url.pathname.startsWith('/system/'))) {
     event.respondWith(openSystemCache().then(cache => {
       return cache.match(event.request.url).then(cached => {
         if (cached === undefined) {
diff --git a/config/webpack/production.js b/config/webpack/production.js
index a823307916..408c56930c 100644
--- a/config/webpack/production.js
+++ b/config/webpack/production.js
@@ -6,8 +6,9 @@ const CompressionPlugin = require('compression-webpack-plugin');
 const sharedConfig = require('./shared.js');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 const OfflinePlugin = require('offline-plugin');
-const { env, publicPath } = require('./configuration.js');
+const { publicPath } = require('./configuration.js');
 const path = require('path');
+const { URL } = require('url');
 
 let compressionAlgorithm;
 try {
@@ -19,6 +20,21 @@ try {
   compressionAlgorithm = 'gzip';
 }
 
+let attachmentHost;
+
+if (process.env.S3_ENABLED === 'true') {
+  if (process.env.S3_CLOUDFRONT_HOST) {
+    attachmentHost = process.env.S3_CLOUDFRONT_HOST;
+  } else {
+    attachmentHost = process.env.S3_HOSTNAME || `s3-${process.env.S3_REGION || 'us-east-1'}.amazonaws.com`;
+  }
+} else if (process.env.SWIFT_ENABLED === 'true') {
+  const { host } = new URL(process.env.SWIFT_OBJECT_URL);
+  attachmentHost = host;
+} else {
+  attachmentHost = null;
+}
+
 module.exports = merge(sharedConfig, {
   output: {
     filename: '[name]-[chunkhash].js',
@@ -90,7 +106,7 @@ module.exports = merge(sharedConfig, {
         '**/*.woff',
       ],
       ServiceWorker: {
-        entry: `imports-loader?process.env=>${encodeURIComponent(JSON.stringify(env))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`,
+        entry: `imports-loader?ATTACHMENT_HOST=>${encodeURIComponent(JSON.stringify(attachmentHost))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`,
         cacheName: 'mastodon',
         output: '../assets/sw.js',
         publicPath: '/sw.js',

From e599d7caf2642c7143616e8402b7d730d32c349d Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Tue, 29 May 2018 01:39:02 +0200
Subject: [PATCH 10/14] Rescue Mastodon::DimensionsValidationError in
 Remoteable (#7662)

Fix #7660
---
 app/models/concerns/remotable.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 20ddbca53c..c17f047760 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -41,7 +41,7 @@ module Remotable
         rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
           Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
           nil
-        rescue Paperclip::Error => e
+        rescue Paperclip::Error, Mastodon::DimensionsValidationError => e
           Rails.logger.debug "Error processing remote #{attachment_name}: #{e}"
           nil
         end

From 90b64c006998ec3bae365007781c61e8a79eeeef Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Tue, 29 May 2018 02:01:04 +0200
Subject: [PATCH 11/14] Always display tab navigation on local/federated
 timeline even when empty (#7663)

Fix #7659
---
 app/javascript/mastodon/components/scrollable_list.js | 11 ++++++++---
 app/javascript/mastodon/components/status_list.js     |  1 +
 .../mastodon/features/community_timeline/index.js     |  1 +
 .../mastodon/features/public_timeline/index.js        |  1 +
 4 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index fd6858d05b..4b433f32c3 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -25,6 +25,7 @@ export default class ScrollableList extends PureComponent {
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
     emptyMessage: PropTypes.node,
     children: PropTypes.node,
   };
@@ -140,7 +141,7 @@ export default class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -172,8 +173,12 @@ export default class ScrollableList extends PureComponent {
       );
     } else {
       scrollableArea = (
-        <div className='empty-column-indicator' ref={this.setRef}>
-          {emptyMessage}
+        <div style={{ flex: '1 1 auto', display: 'flex', flexDirection: 'column' }}>
+          {alwaysPrepend && prepend}
+
+          <div className='empty-column-indicator' ref={this.setRef}>
+            {emptyMessage}
+          </div>
         </div>
       );
     }
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 0c971ceb00..1c34d06408 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 {
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
     emptyMessage: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
   };
 
   static defaultProps = {
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index f9ee835bc6..d375edbd51 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -127,6 +127,7 @@ export default class CommunityTimeline extends React.PureComponent {
 
         <StatusListContainer
           prepend={headline}
+          alwaysPrepend
           trackScroll={!pinned}
           scrollKey={`community_timeline-${columnId}`}
           timelineId={`community${onlyMedia ? ':media' : ''}`}
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index 15cc3e31d8..341af582ae 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -127,6 +127,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           prepend={headline}
+          alwaysPrepend
           timelineId={`public${onlyMedia ? ':media' : ''}`}
           onLoadMore={this.handleLoadMore}
           trackScroll={!pinned}

From 8bb74e50beb3e6602c183e59086ccebe87f96fb2 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Tue, 29 May 2018 02:01:24 +0200
Subject: [PATCH 12/14] Add GET /api/v2/search which returns rich tag objects,
 adjust web UI (#7661)

---
 app/controllers/api/v2/search_controller.rb   |  8 +++
 app/javascript/mastodon/actions/search.js     |  2 +-
 .../compose/components/search_results.js      | 56 +++++++++----------
 app/javascript/mastodon/reducers/search.js    |  4 +-
 .../styles/mastodon/components.scss           | 44 +++++++--------
 app/serializers/rest/v2/search_serializer.rb  |  7 +++
 config/routes.rb                              |  4 ++
 7 files changed, 69 insertions(+), 56 deletions(-)
 create mode 100644 app/controllers/api/v2/search_controller.rb
 create mode 100644 app/serializers/rest/v2/search_serializer.rb

diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
new file mode 100644
index 0000000000..2e91d68ee3
--- /dev/null
+++ b/app/controllers/api/v2/search_controller.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class Api::V2::SearchController < Api::V1::SearchController
+  def index
+    @search = Search.new(search)
+    render json: @search, serializer: REST::V2::SearchSerializer
+  end
+end
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 882c1709e9..b670d25c3c 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -33,7 +33,7 @@ export function submitSearch() {
 
     dispatch(fetchSearchRequest());
 
-    api(getState).get('/api/v1/search', {
+    api(getState).get('/api/v2/search', {
       params: {
         q: value,
         resolve: true,
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index f2655c14d2..445bf27bb1 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -16,6 +16,28 @@ const shortNumberFormat = number => {
   }
 };
 
+const renderHashtag = hashtag => (
+  <div className='trends__item' key={hashtag.get('name')}>
+    <div className='trends__item__name'>
+      <Link to={`/timelines/tag/${hashtag.get('name')}`}>
+        #<span>{hashtag.get('name')}</span>
+      </Link>
+
+      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
+    </div>
+
+    <div className='trends__item__current'>
+      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
+    </div>
+
+    <div className='trends__item__sparkline'>
+      <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
+        <SparklinesCurve style={{ fill: 'none' }} />
+      </Sparklines>
+    </div>
+  </div>
+);
+
 export default class SearchResults extends ImmutablePureComponent {
 
   static propTypes = {
@@ -44,27 +66,7 @@ export default class SearchResults extends ImmutablePureComponent {
               <FormattedMessage id='trends.header' defaultMessage='Trending now' />
             </div>
 
-            {trends && trends.map(hashtag => (
-              <div className='trends__item' key={hashtag.get('name')}>
-                <div className='trends__item__name'>
-                  <Link to={`/timelines/tag/${hashtag.get('name')}`}>
-                    #<span>{hashtag.get('name')}</span>
-                  </Link>
-
-                  <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
-                </div>
-
-                <div className='trends__item__current'>
-                  {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
-                </div>
-
-                <div className='trends__item__sparkline'>
-                  <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
-                    <SparklinesCurve style={{ fill: 'none' }} />
-                  </Sparklines>
-                </div>
-              </div>
-            ))}
+            {trends && trends.map(hashtag => renderHashtag(hashtag))}
           </div>
         </div>
       );
@@ -74,7 +76,7 @@ export default class SearchResults extends ImmutablePureComponent {
       count   += results.get('accounts').size;
       accounts = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+          <h5><i className='fa fa-fw fa-users' /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
 
           {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
         </div>
@@ -85,7 +87,7 @@ export default class SearchResults extends ImmutablePureComponent {
       count   += results.get('statuses').size;
       statuses = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+          <h5><i className='fa fa-fw fa-quote-right' /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
         </div>
@@ -96,13 +98,9 @@ export default class SearchResults extends ImmutablePureComponent {
       count += results.get('hashtags').size;
       hashtags = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+          <h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
 
-          {results.get('hashtags').map(hashtag => (
-            <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
-              {hashtag}
-            </Link>
-          ))}
+          {results.get('hashtags').map(hashtag => renderHashtag(hashtag))}
         </div>
       );
     }
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 56fd7226b4..4758defb1b 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -9,7 +9,7 @@ import {
   COMPOSE_REPLY,
   COMPOSE_DIRECT,
 } from '../actions/compose';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 const initialState = ImmutableMap({
   value: '',
@@ -39,7 +39,7 @@ export default function search(state = initialState, action) {
     return state.set('results', ImmutableMap({
       accounts: ImmutableList(action.results.accounts.map(item => item.id)),
       statuses: ImmutableList(action.results.statuses.map(item => item.id)),
-      hashtags: ImmutableList(action.results.hashtags),
+      hashtags: fromJS(action.results.hashtags),
     })).set('submitted', true);
   default:
     return state;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index a2a18b5a0a..c93d8e86a4 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3284,6 +3284,15 @@ a.status-card {
 }
 
 .search__icon {
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus {
+    outline: 0 !important;
+  }
+
   .fa {
     position: absolute;
     top: 10px;
@@ -3333,7 +3342,6 @@ a.status-card {
 .search-results__header {
   color: $dark-text-color;
   background: lighten($ui-base-color, 2%);
-  border-bottom: 1px solid darken($ui-base-color, 4%);
   padding: 15px;
   font-weight: 500;
   font-size: 16px;
@@ -3346,33 +3354,21 @@ a.status-card {
 }
 
 .search-results__section {
-  margin-bottom: 20px;
+  margin-bottom: 5px;
 
   h5 {
-    position: relative;
+    background: darken($ui-base-color, 4%);
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+    cursor: default;
+    display: flex;
+    padding: 15px;
+    font-weight: 500;
+    font-size: 16px;
+    color: $dark-text-color;
 
-    &::before {
-      content: "";
-      display: block;
-      position: absolute;
-      left: 0;
-      right: 0;
-      top: 50%;
-      width: 100%;
-      height: 0;
-      border-top: 1px solid lighten($ui-base-color, 8%);
-    }
-
-    span {
+    .fa {
       display: inline-block;
-      background: $ui-base-color;
-      color: $darker-text-color;
-      font-size: 14px;
-      font-weight: 500;
-      padding: 10px;
-      position: relative;
-      z-index: 1;
-      cursor: default;
+      margin-right: 5px;
     }
   }
 
diff --git a/app/serializers/rest/v2/search_serializer.rb b/app/serializers/rest/v2/search_serializer.rb
new file mode 100644
index 0000000000..cdb6b3a530
--- /dev/null
+++ b/app/serializers/rest/v2/search_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class REST::V2::SearchSerializer < ActiveModel::Serializer
+  has_many :accounts, serializer: REST::AccountSerializer
+  has_many :statuses, serializer: REST::StatusSerializer
+  has_many :hashtags, serializer: REST::TagSerializer
+end
diff --git a/config/routes.rb b/config/routes.rb
index 2fcb885ed8..31e90e2ff1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -315,6 +315,10 @@ Rails.application.routes.draw do
       end
     end
 
+    namespace :v2 do
+      get '/search', to: 'search#index', as: :search
+    end
+
     namespace :web do
       resource :settings, only: [:update]
       resource :embed, only: [:create]

From 13b60e6a146bb4717586573d63fa27f93d03a8fd Mon Sep 17 00:00:00 2001
From: Yamagishi Kazutoshi <ykzts@desire.sh>
Date: Tue, 29 May 2018 20:33:20 +0900
Subject: [PATCH 13/14] Use URL polyfill (#7664)

---
 config/webpack/production.js |  2 +-
 package.json                 |  3 ++-
 yarn.lock                    | 20 +++++++++++++++++++-
 3 files changed, 22 insertions(+), 3 deletions(-)

diff --git a/config/webpack/production.js b/config/webpack/production.js
index 408c56930c..1469a948f2 100644
--- a/config/webpack/production.js
+++ b/config/webpack/production.js
@@ -8,7 +8,7 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl
 const OfflinePlugin = require('offline-plugin');
 const { publicPath } = require('./configuration.js');
 const path = require('path');
-const { URL } = require('url');
+const { URL } = require('whatwg-url');
 
 let compressionAlgorithm;
 try {
diff --git a/package.json b/package.json
index 6ee6f98d34..893be30177 100644
--- a/package.json
+++ b/package.json
@@ -121,7 +121,8 @@
     "webpack-bundle-analyzer": "^2.9.1",
     "webpack-manifest-plugin": "^1.2.1",
     "webpack-merge": "^4.1.1",
-    "websocket.js": "^0.1.12"
+    "websocket.js": "^0.1.12",
+    "whatwg-url": "^6.4.1"
   },
   "devDependencies": {
     "babel-eslint": "^8.2.1",
diff --git a/yarn.lock b/yarn.lock
index de48c995a6..e0f167490c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4373,6 +4373,10 @@ lodash.restparam@^3.0.0:
   version "3.6.1"
   resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
 
+lodash.sortby@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
+
 lodash.tail@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
@@ -7257,6 +7261,12 @@ tough-cookie@^2.3.2, tough-cookie@~2.3.0, tough-cookie@~2.3.3:
   dependencies:
     punycode "^1.4.1"
 
+tr46@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
+  dependencies:
+    punycode "^2.1.0"
+
 tr46@~0.0.3:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
@@ -7494,7 +7504,7 @@ webidl-conversions@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
 
-webidl-conversions@^4.0.0:
+webidl-conversions@^4.0.0, webidl-conversions@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
 
@@ -7637,6 +7647,14 @@ whatwg-url@^4.3.0:
     tr46 "~0.0.3"
     webidl-conversions "^3.0.0"
 
+whatwg-url@^6.4.1:
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.4.1.tgz#fdb94b440fd4ad836202c16e9737d511f012fd67"
+  dependencies:
+    lodash.sortby "^4.7.0"
+    tr46 "^1.0.1"
+    webidl-conversions "^4.0.2"
+
 whet.extend@~0.9.9:
   version "0.9.9"
   resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"

From 90908fc24ba57c877de75fe117b8cc234e29d4f0 Mon Sep 17 00:00:00 2001
From: abcang <abcang1015@gmail.com>
Date: Tue, 29 May 2018 20:34:02 +0900
Subject: [PATCH 14/14] Fix N+1 on AtomSerializer (#7669)

---
 app/lib/ostatus/atom_serializer.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 698f2ee229..5c6ff4f9b9 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -354,7 +354,7 @@ class OStatus::AtomSerializer
     append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
     append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
 
-    status.mentions.order(:id).each do |mentioned|
+    status.mentions.sort_by(&:id).each do |mentioned|
       append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
     end