about summary refs log tree commit diff
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2019-09-05 11:36:41 +0200
committerThibaut Girka <thib@sitedethib.com>2019-09-05 11:36:41 +0200
commit5088eb8388fbfcb210a518f918ae5332e6d3979e (patch)
tree20b9394200b79e7eefc234cc50b0f3650e9afc1d
parent0128509605ed90ee5a29d6af2347ab32bd46aeb9 (diff)
parente265b8887dbd883bc7ca04832dc67ffe46966889 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
-rw-r--r--.circleci/config.yml20
-rw-r--r--.env.production.sample1
-rw-r--r--Dockerfile6
-rw-r--r--Gemfile8
-rw-r--r--Gemfile.lock44
-rw-r--r--app/controllers/admin/tags_controller.rb5
-rw-r--r--app/controllers/auth/confirmations_controller.rb23
-rw-r--r--app/helpers/instance_helper.rb12
-rw-r--r--app/javascript/core/admin.js10
-rw-r--r--app/javascript/mastodon/components/hashtag.js4
-rw-r--r--app/javascript/mastodon/components/media_gallery.js17
-rw-r--r--app/javascript/mastodon/features/directory/components/account_card.js43
-rw-r--r--app/javascript/mastodon/features/ui/components/navigation_panel.js2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json119
-rw-r--r--app/javascript/mastodon/locales/en.json13
-rw-r--r--app/javascript/mastodon/rtl.js1
-rw-r--r--app/javascript/styles/mastodon/components.scss15
-rw-r--r--app/javascript/styles/mastodon/dashboard.scss2
-rw-r--r--app/javascript/styles/mastodon/footer.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss9
-rw-r--r--app/lib/activitypub/adapter.rb13
-rw-r--r--app/lib/activitypub/serializer.rb8
-rw-r--r--app/lib/feed_manager.rb2
-rw-r--r--app/lib/request.rb52
-rw-r--r--app/models/tag.rb4
-rw-r--r--app/models/trending_tags.rb102
-rw-r--r--app/serializers/activitypub/actor_serializer.rb4
-rw-r--r--app/serializers/activitypub/note_serializer.rb7
-rw-r--r--app/services/suspend_account_service.rb1
-rw-r--r--app/views/admin/instances/index.html.haml19
-rw-r--r--app/views/admin/tags/show.html.haml4
-rw-r--r--app/views/application/_sidebar.html.haml2
-rw-r--r--app/views/auth/registrations/new.html.haml2
-rw-r--r--app/views/auth/setup/show.html.haml5
-rw-r--r--app/views/auth/shared/_links.html.haml22
-rw-r--r--app/views/directories/index.html.haml2
-rw-r--r--app/views/notification_mailer/_status.html.haml5
-rw-r--r--app/views/settings/deletes/show.html.haml24
-rw-r--r--app/views/shared/_og.html.haml4
-rw-r--r--app/views/user_mailer/warning.html.haml4
-rw-r--r--app/views/user_mailer/warning.text.erb2
-rw-r--r--app/workers/scheduler/trending_tags_scheduler.rb11
-rw-r--r--config/deploy.rb2
-rw-r--r--config/environments/production.rb5
-rw-r--r--config/initializers/active_model_serializers.rb19
-rw-r--r--config/locales/en.yml21
-rw-r--r--config/puma.rb2
-rw-r--r--config/sidekiq.yml3
-rw-r--r--db/migrate/20190901035623_add_max_score_to_tags.rb6
-rw-r--r--db/post_migrate/20190901040524_remove_score_from_tags.rb12
-rw-r--r--db/schema.rb6
-rw-r--r--package.json4
-rw-r--r--spec/lib/activitypub/activity/update_spec.rb2
-rw-r--r--spec/models/trending_tags_spec.rb68
-rw-r--r--yarn.lock67
55 files changed, 635 insertions, 237 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8c8b411df..529b645aa 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -3,7 +3,7 @@ version: 2
 aliases:
   - &defaults
     docker:
-      - image: circleci/ruby:2.6.0-stretch-node
+      - image: circleci/ruby:2.6-stretch-node
         environment: &ruby_environment
           BUNDLE_APP_CONFIG: ./.bundle/
           DB_HOST: localhost
@@ -105,14 +105,14 @@ jobs:
   install-ruby2.5:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.5.3-stretch-node
+      - image: circleci/ruby:2.5-stretch-node
         environment: *ruby_environment
     <<: *install_ruby_dependencies
 
   install-ruby2.4:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.4.5-stretch-node
+      - image: circleci/ruby:2.4-stretch-node
         environment: *ruby_environment
     <<: *install_ruby_dependencies
 
@@ -134,40 +134,40 @@ jobs:
   test-ruby2.6:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.6.0-stretch-node
+      - image: circleci/ruby:2.6-stretch-node
         environment: *ruby_environment
       - image: circleci/postgres:10.6-alpine
         environment:
           POSTGRES_USER: root
-      - image: circleci/redis:5.0.3-alpine3.8
+      - image: circleci/redis:5-alpine
     <<: *test_steps
 
   test-ruby2.5:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.5.3-stretch-node
+      - image: circleci/ruby:2.5-stretch-node
         environment: *ruby_environment
       - image: circleci/postgres:10.6-alpine
         environment:
           POSTGRES_USER: root
-      - image: circleci/redis:4.0.12-alpine
+      - image: circleci/redis:5-alpine
     <<: *test_steps
 
   test-ruby2.4:
     <<: *defaults
     docker:
-      - image: circleci/ruby:2.4.5-stretch-node
+      - image: circleci/ruby:2.4-stretch-node
         environment: *ruby_environment
       - image: circleci/postgres:10.6-alpine
         environment:
           POSTGRES_USER: root
-      - image: circleci/redis:4.0.12-alpine
+      - image: circleci/redis:5-alpine
     <<: *test_steps
 
   test-webui:
     <<: *defaults
     docker:
-      - image: circleci/node:8.15.0-stretch
+      - image: circleci/node:12.9-stretch
     steps:
       - *attach_workspace
       - run: ./bin/retry yarn test:jest
diff --git a/.env.production.sample b/.env.production.sample
index a2a9246d4..2fbecc91a 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -69,6 +69,7 @@ SMTP_PORT=587
 SMTP_LOGIN=
 SMTP_PASSWORD=
 SMTP_FROM_ADDRESS=notifications@example.com
+#SMTP_REPLY_TO=
 #SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
 #SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
 #SMTP_AUTH_METHOD=plain
diff --git a/Dockerfile b/Dockerfile
index d8c7e0f0c..b5904ad95 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,7 +4,7 @@ FROM ubuntu:18.04 as build-dep
 SHELL ["bash", "-c"]
 
 # Install Node
-ENV NODE_VER="8.15.0"
+ENV NODE_VER="12.9.1"
 RUN	echo "Etc/UTC" > /etc/localtime && \
 	apt update && \
 	apt -y install wget make gcc g++ python && \
@@ -17,7 +17,7 @@ RUN	echo "Etc/UTC" > /etc/localtime && \
 	make install
 
 # Install jemalloc
-ENV JE_VER="5.1.0"
+ENV JE_VER="5.2.1"
 RUN apt update && \
 	apt -y install autoconf && \
 	cd ~ && \
@@ -30,7 +30,7 @@ RUN apt update && \
 	make install_bin install_include install_lib
 
 # Install ruby
-ENV RUBY_VER="2.6.1"
+ENV RUBY_VER="2.6.4"
 ENV CPPFLAGS="-I/opt/jemalloc/include"
 ENV LDFLAGS="-L/opt/jemalloc/lib/"
 RUN apt update && \
diff --git a/Gemfile b/Gemfile
index 246450c1b..cfaa6e444 100644
--- a/Gemfile
+++ b/Gemfile
@@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
 gem 'pghero', '~> 2.3'
 gem 'dotenv-rails', '~> 2.7'
 
-gem 'aws-sdk-s3', '~> 1.46', require: false
+gem 'aws-sdk-s3', '~> 1.48', require: false
 gem 'fog-core', '<= 2.1.0'
 gem 'fog-openstack', '~> 0.3', require: false
 gem 'paperclip', '~> 6.0'
@@ -24,7 +24,7 @@ gem 'streamio-ffmpeg', '~> 3.0'
 gem 'blurhash', '~> 0.1'
 
 gem 'active_model_serializers', '~> 0.10'
-gem 'addressable', '~> 2.6'
+gem 'addressable', '~> 2.7'
 gem 'bootsnap', '~> 1.4', require: false
 gem 'browser'
 gem 'charlock_holmes', '~> 0.7.6'
@@ -116,12 +116,12 @@ end
 group :test do
   gem 'capybara', '~> 3.28'
   gem 'climate_control', '~> 0.2'
-  gem 'faker', '~> 2.1'
+  gem 'faker', '~> 2.2'
   gem 'microformats', '~> 4.1'
   gem 'rails-controller-testing', '~> 1.0'
   gem 'rspec-sidekiq', '~> 3.0'
   gem 'simplecov', '~> 0.17', require: false
-  gem 'webmock', '~> 3.6'
+  gem 'webmock', '~> 3.7'
   gem 'parallel_tests', '~> 2.29'
 end
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 95b65d644..68a68c848 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -83,9 +83,9 @@ GEM
       i18n (>= 0.7, < 2)
       minitest (~> 5.1)
       tzinfo (~> 1.1)
-    addressable (2.6.0)
-      public_suffix (>= 2.0.2, < 4.0)
-    airbrussh (1.3.0)
+    addressable (2.7.0)
+      public_suffix (>= 2.0.2, < 5.0)
+    airbrussh (1.3.3)
       sshkit (>= 1.6.1, != 1.7.0)
     annotate (2.7.5)
       activerecord (>= 3.2, < 7.0)
@@ -97,8 +97,8 @@ GEM
     av (0.9.0)
       cocaine (~> 0.5.3)
     aws-eventstream (1.0.3)
-    aws-partitions (1.193.0)
-    aws-sdk-core (3.61.1)
+    aws-partitions (1.207.0)
+    aws-sdk-core (3.65.1)
       aws-eventstream (~> 1.0, >= 1.0.2)
       aws-partitions (~> 1.0)
       aws-sigv4 (~> 1.1)
@@ -106,7 +106,7 @@ GEM
     aws-sdk-kms (1.24.0)
       aws-sdk-core (~> 3, >= 3.61.1)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.46.0)
+    aws-sdk-s3 (1.48.0)
       aws-sdk-core (~> 3, >= 3.61.1)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.1)
@@ -122,7 +122,7 @@ GEM
       debug_inspector (>= 0.0.1)
     blurhash (0.1.3)
       ffi (~> 1.10.0)
-    bootsnap (1.4.4)
+    bootsnap (1.4.5)
       msgpack (~> 1.0)
     brakeman (4.6.1)
     browser (2.6.1)
@@ -134,7 +134,7 @@ GEM
       bundler (>= 1.2.0, < 3)
       thor (~> 0.18)
     byebug (11.0.0)
-    capistrano (3.11.0)
+    capistrano (3.11.1)
       airbrussh (>= 1.0.0)
       i18n
       rake (>= 10.0.0)
@@ -231,7 +231,7 @@ GEM
       tzinfo
     excon (0.62.0)
     fabrication (2.20.2)
-    faker (2.1.2)
+    faker (2.2.1)
       i18n (>= 0.8)
     faraday (0.15.0)
       multipart-post (>= 1.2, < 3)
@@ -371,14 +371,14 @@ GEM
     mini_mime (1.0.2)
     mini_portile2 (2.4.0)
     minitest (5.11.3)
-    msgpack (1.2.10)
+    msgpack (1.3.1)
     multi_json (1.13.1)
     multipart-post (2.0.0)
     necromancer (0.5.0)
     net-ldap (0.16.1)
-    net-scp (1.2.1)
-      net-ssh (>= 2.6.5)
-    net-ssh (5.0.2)
+    net-scp (2.0.0)
+      net-ssh (>= 2.6.5, < 6.0.0)
+    net-ssh (5.2.0)
     nio4r (2.4.0)
     nokogiri (1.10.4)
       mini_portile2 (~> 2.4.0)
@@ -418,7 +418,7 @@ GEM
     parallel (1.17.0)
     parallel_tests (2.29.2)
       parallel
-    parser (2.6.3.0)
+    parser (2.6.4.0)
       ast (~> 2.4.0)
     parslet (1.8.2)
     pastel (0.7.2)
@@ -444,7 +444,7 @@ GEM
       pry (~> 0.10)
     pry-rails (0.3.9)
       pry (>= 0.10.4)
-    public_suffix (3.1.1)
+    public_suffix (4.0.1)
     puma (4.1.0)
       nio4r (~> 2.0)
     pundit (2.1.0)
@@ -557,7 +557,7 @@ GEM
       rainbow (>= 2.2.2, < 4.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 1.4.0, < 1.7)
-    rubocop-rails (2.3.1)
+    rubocop-rails (2.3.2)
       rack (>= 1.1)
       rubocop (>= 0.72.0)
     ruby-progressbar (1.10.1)
@@ -603,7 +603,7 @@ GEM
       actionpack (>= 4.0)
       activesupport (>= 4.0)
       sprockets (>= 3.0.0)
-    sshkit (1.17.0)
+    sshkit (1.20.0)
       net-scp (>= 1.1.2)
       net-ssh (>= 2.8.0)
     stackprof (0.2.12)
@@ -647,7 +647,7 @@ GEM
     uniform_notifier (1.12.1)
     warden (1.2.8)
       rack (>= 2.0.6)
-    webmock (3.6.2)
+    webmock (3.7.1)
       addressable (>= 2.3.6)
       crack (>= 0.3.2)
       hashdiff (>= 0.4.0, < 2.0.0)
@@ -671,9 +671,9 @@ PLATFORMS
 DEPENDENCIES
   active_model_serializers (~> 0.10)
   active_record_query_trace (~> 1.6)
-  addressable (~> 2.6)
+  addressable (~> 2.7)
   annotate (~> 2.7)
-  aws-sdk-s3 (~> 1.46)
+  aws-sdk-s3 (~> 1.48)
   better_errors (~> 2.5)
   binding_of_caller (~> 0.7)
   blurhash (~> 0.1)
@@ -701,7 +701,7 @@ DEPENDENCIES
   doorkeeper (~> 5.1)
   dotenv-rails (~> 2.7)
   fabrication (~> 2.20)
-  faker (~> 2.1)
+  faker (~> 2.2)
   fast_blank (~> 1.0)
   fastimage
   fog-core (<= 2.1.0)
@@ -789,7 +789,7 @@ DEPENDENCIES
   tty-prompt (~> 0.19)
   twitter-text (~> 1.14)
   tzinfo-data (~> 1.2019)
-  webmock (~> 3.6)
+  webmock (~> 3.7)
   webpacker (~> 4.0)
   webpush
 
diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
index 39aca2a4b..8bd4e5f8b 100644
--- a/app/controllers/admin/tags_controller.rb
+++ b/app/controllers/admin/tags_controller.rb
@@ -37,7 +37,8 @@ module Admin
 
     def set_usage_by_domain
       @usage_by_domain = @tag.statuses
-                             .where(visibility: :public)
+                             .with_public_visibility
+                             .excluding_silenced_accounts
                              .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
                              .joins(:account)
                              .group('accounts.domain')
@@ -56,7 +57,7 @@ module Admin
       scope = scope.unreviewed if filter_params[:review] == 'unreviewed'
       scope = scope.reviewed.order(reviewed_at: :desc) if filter_params[:review] == 'reviewed'
       scope = scope.pending_review.order(requested_review_at: :desc) if filter_params[:review] == 'pending_review'
-      scope.order(score: :desc)
+      scope.order(max_score: :desc)
     end
 
     def filter_params
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 1d6e4ec19..4e89446c7 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -5,19 +5,42 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
 
   before_action :set_body_classes
   before_action :set_pack
+  before_action :require_unconfirmed!
 
   skip_before_action :require_functional!
 
+  def new
+    super
+
+    resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
+  end
+
   private
 
   def set_pack
     use_pack 'auth'
   end
 
+  def require_unconfirmed!
+    redirect_to edit_user_registration_path if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
+  end
+
   def set_body_classes
     @body_classes = 'lighter'
   end
 
+  def after_resending_confirmation_instructions_path_for(_resource_name)
+    if user_signed_in?
+      if current_user.confirmed? && current_user.approved?
+        edit_user_registration_path
+      else
+        auth_setup_path
+      end
+    else
+      new_user_session_path
+    end
+  end
+
   def after_confirmation_path_for(_resource_name, user)
     if user.created_by_application && truthy_param?(:redirect_to_app)
       user.created_by_application.redirect_uri
diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb
index dd0b25f3e..daacb535b 100644
--- a/app/helpers/instance_helper.rb
+++ b/app/helpers/instance_helper.rb
@@ -8,4 +8,16 @@ module InstanceHelper
   def site_hostname
     @site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host
   end
+
+  def description_for_sign_up
+    prefix = begin
+      if @invite.present?
+        I18n.t('auth.description.prefix_invited_by_user', name: @invite.user.account.username)
+      else
+        I18n.t('auth.description.prefix_sign_up')
+      end
+    end
+
+    safe_join([prefix, I18n.t('auth.description.suffix')], ' ')
+  end
 end
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
index 3f6f187bc..ffdabe674 100644
--- a/app/javascript/core/admin.js
+++ b/app/javascript/core/admin.js
@@ -1,6 +1,7 @@
 //  This file will be loaded on admin pages, regardless of theme.
 
 import { delegate } from 'rails-ujs';
+import ready from '../mastodon/ready';
 
 const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
 
@@ -31,7 +32,7 @@ delegate(document, '.media-spoiler-hide-button', 'click', () => {
   });
 });
 
-delegate(document, '#domain_block_severity', 'change', ({ target }) => {
+const onDomainBlockSeverityChange = (target) => {
   const rejectMediaDiv   = document.querySelector('.input.with_label.domain_block_reject_media');
   const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');
 
@@ -42,4 +43,11 @@ delegate(document, '#domain_block_severity', 'change', ({ target }) => {
   if (rejectReportsDiv) {
     rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
   }
+};
+
+delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
+
+ready(() => {
+  const input = document.getElementById('domain_block_severity');
+  if (input) onDomainBlockSeverityChange(input);
 });
diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js
index f091d7893..62d613262 100644
--- a/app/javascript/mastodon/components/hashtag.js
+++ b/app/javascript/mastodon/components/hashtag.js
@@ -12,11 +12,11 @@ const Hashtag = ({ hashtag }) => (
         #<span>{hashtag.get('name')}</span>
       </Permalink>
 
-      <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> }} />
+      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
     </div>
 
     <div className='trends__item__current'>
-      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
+      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
     </div>
 
     <div className='trends__item__sparkline'>
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 9cd71b7c9..e8dd79af9 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -159,7 +159,7 @@ class Item extends React.PureComponent {
     if (attachment.get('type') === 'unknown') {
       return (
         <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
-          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
+          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
             <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
           </a>
         </div>
@@ -315,15 +315,22 @@ class MediaGallery extends React.PureComponent {
       style.height = height;
     }
 
-    const size = media.take(4).size;
+    const size     = media.take(4).size;
+    const uncached = media.every(attachment => attachment.get('type') === 'unknown');
 
     if (this.isStandaloneEligible()) {
       children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
     } else {
-      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible || uncached} />);
     }
 
-    if (visible) {
+    if (uncached) {
+      spoilerButton = (
+        <button type='button' disabled className='spoiler-button__overlay'>
+          <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
+        </button>
+      );
+    } else if (visible) {
       spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
     } else {
       spoilerButton = (
@@ -335,7 +342,7 @@ class MediaGallery extends React.PureComponent {
 
     return (
       <div className='media-gallery' style={style} ref={this.handleRef}>
-        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
+        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
           {spoilerButton}
         </div>
 
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
index cb23a02ba..50ad74450 100644
--- a/app/javascript/mastodon/features/directory/components/account_card.js
+++ b/app/javascript/mastodon/features/directory/components/account_card.js
@@ -82,6 +82,43 @@ class AccountCard extends ImmutablePureComponent {
     onMute: PropTypes.func.isRequired,
   };
 
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  componentDidMount () {
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateEmojis();
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
   handleFollow = () => {
     this.props.onFollow(this.props.account);
   }
@@ -94,6 +131,10 @@ class AccountCard extends ImmutablePureComponent {
     this.props.onMute(this.props.account);
   }
 
+  setRef = (c) => {
+    this.node = c;
+  }
+
   render () {
     const { account, intl } = this.props;
 
@@ -133,7 +174,7 @@ class AccountCard extends ImmutablePureComponent {
           </div>
         </div>
 
-        <div className='directory__card__extra'>
+        <div className='directory__card__extra' ref={this.setRef}>
           <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
         </div>
 
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js
index 6f07778f2..51e3ec037 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.js
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js
@@ -18,7 +18,7 @@ const NavigationPanel = () => (
     <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
-    {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.profile_directory' defaultMessage='Profile directory' /></NavLink>}
+    {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
 
     <ListPanel />
 
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 617328613..9cb4b74a7 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -8,6 +8,14 @@
       {
         "defaultMessage": "An unexpected error occurred.",
         "id": "alert.unexpected.message"
+      },
+      {
+        "defaultMessage": "Rate limited",
+        "id": "alert.rate_limited.title"
+      },
+      {
+        "defaultMessage": "Please retry after {retry_time, time, medium}.",
+        "id": "alert.rate_limited.message"
       }
     ],
     "path": "app/javascript/mastodon/actions/alerts.json"
@@ -192,6 +200,10 @@
         "id": "media_gallery.toggle_visible"
       },
       {
+        "defaultMessage": "Not available",
+        "id": "status.uncached_media_warning"
+      },
+      {
         "defaultMessage": "Sensitive content",
         "id": "status.sensitive_warning"
       },
@@ -1133,6 +1145,19 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Are you sure you want to log out?",
+        "id": "confirmations.logout.message"
+      },
+      {
+        "defaultMessage": "Log out",
+        "id": "confirmations.logout.confirm"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/containers/navigation_container.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Media is marked as sensitive",
         "id": "compose_form.sensitive.marked"
       },
@@ -1218,6 +1243,14 @@
       {
         "defaultMessage": "Compose new toot",
         "id": "navigation_bar.compose"
+      },
+      {
+        "defaultMessage": "Are you sure you want to log out?",
+        "id": "confirmations.logout.message"
+      },
+      {
+        "defaultMessage": "Log out",
+        "id": "confirmations.logout.confirm"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/index.json"
@@ -1238,6 +1271,76 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Follow",
+        "id": "account.follow"
+      },
+      {
+        "defaultMessage": "Unfollow",
+        "id": "account.unfollow"
+      },
+      {
+        "defaultMessage": "Awaiting approval",
+        "id": "account.requested"
+      },
+      {
+        "defaultMessage": "Unblock @{name}",
+        "id": "account.unblock"
+      },
+      {
+        "defaultMessage": "Unmute @{name}",
+        "id": "account.unmute"
+      },
+      {
+        "defaultMessage": "Are you sure you want to unfollow {name}?",
+        "id": "confirmations.unfollow.message"
+      },
+      {
+        "defaultMessage": "Toots",
+        "id": "account.posts"
+      },
+      {
+        "defaultMessage": "Followers",
+        "id": "account.followers"
+      },
+      {
+        "defaultMessage": "Never",
+        "id": "account.never_active"
+      },
+      {
+        "defaultMessage": "Last active",
+        "id": "account.last_status"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/directory/components/account_card.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Browse profiles",
+        "id": "column.directory"
+      },
+      {
+        "defaultMessage": "Recently active",
+        "id": "directory.recently_active"
+      },
+      {
+        "defaultMessage": "New arrivals",
+        "id": "directory.new_arrivals"
+      },
+      {
+        "defaultMessage": "From {domain} only",
+        "id": "directory.local"
+      },
+      {
+        "defaultMessage": "From known fediverse",
+        "id": "directory.federated"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/directory/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Hidden domains",
         "id": "column.domain_blocks"
       },
@@ -2326,6 +2429,14 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Are you sure you want to log out?",
+        "id": "confirmations.logout.message"
+      },
+      {
+        "defaultMessage": "Log out",
+        "id": "confirmations.logout.confirm"
+      },
+      {
         "defaultMessage": "Invite people",
         "id": "getting_started.invite"
       },
@@ -2441,16 +2552,16 @@
         "id": "navigation_bar.lists"
       },
       {
+        "defaultMessage": "Profile directory",
+        "id": "getting_started.directory"
+      },
+      {
         "defaultMessage": "Preferences",
         "id": "navigation_bar.preferences"
       },
       {
         "defaultMessage": "Follows and followers",
         "id": "navigation_bar.follows_and_followers"
-      },
-      {
-        "defaultMessage": "Profile directory",
-        "id": "navigation_bar.profile_directory"
       }
     ],
     "path": "app/javascript/mastodon/features/ui/components/navigation_panel.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 28ea713a3..260b43c53 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -16,6 +16,7 @@
   "account.follows.empty": "This user doesn't follow anyone yet.",
   "account.follows_you": "Follows you",
   "account.hide_reblogs": "Hide boosts from @{name}",
+  "account.last_status": "Last active",
   "account.link_verified_on": "Ownership of this link was checked on {date}",
   "account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
   "account.media": "Media",
@@ -24,6 +25,7 @@
   "account.mute": "Mute @{name}",
   "account.mute_notifications": "Mute notifications from @{name}",
   "account.muted": "Muted",
+  "account.never_active": "Never",
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots and replies",
   "account.report": "Report @{name}",
@@ -36,6 +38,8 @@
   "account.unfollow": "Unfollow",
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
+  "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
+  "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
   "autosuggest_hashtag.per_week": "{count} per week",
@@ -49,6 +53,7 @@
   "column.blocks": "Blocked users",
   "column.community": "Local timeline",
   "column.direct": "Direct messages",
+  "column.directory": "Browse profiles",
   "column.domain_blocks": "Hidden domains",
   "column.favourites": "Favourites",
   "column.follow_requests": "Follow requests",
@@ -99,6 +104,8 @@
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
   "confirmations.domain_block.confirm": "Hide entire domain",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
+  "confirmations.logout.confirm": "Log out",
+  "confirmations.logout.message": "Are you sure you want to log out?",
   "confirmations.mute.confirm": "Mute",
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.redraft.confirm": "Delete & redraft",
@@ -107,6 +114,10 @@
   "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "directory.federated": "From known fediverse",
+  "directory.local": "From {domain} only",
+  "directory.new_arrivals": "New arrivals",
+  "directory.recently_active": "Recently active",
   "embed.instructions": "Embed this status on your website by copying the code below.",
   "embed.preview": "Here is what it will look like:",
   "emoji_button.activity": "Activity",
@@ -254,7 +265,6 @@
   "navigation_bar.personal": "Personal",
   "navigation_bar.pins": "Pinned toots",
   "navigation_bar.preferences": "Preferences",
-  "navigation_bar.profile_directory": "Profile directory",
   "navigation_bar.public_timeline": "Federated timeline",
   "navigation_bar.security": "Security",
   "notification.favourite": "{name} favourited your status",
@@ -361,6 +371,7 @@
   "status.show_more": "Show more",
   "status.show_more_all": "Show more for all",
   "status.show_thread": "Show thread",
+  "status.uncached_media_warning": "Not available",
   "status.unmute_conversation": "Unmute conversation",
   "status.unpin": "Unpin from profile",
   "suggestions.dismiss": "Dismiss suggestion",
diff --git a/app/javascript/mastodon/rtl.js b/app/javascript/mastodon/rtl.js
index 00870a15d..89bed6de8 100644
--- a/app/javascript/mastodon/rtl.js
+++ b/app/javascript/mastodon/rtl.js
@@ -20,6 +20,7 @@ export function isRtl(text) {
   text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
   text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
   text = text.replace(/\s+/g, '');
+  text = text.replace(/(\w\S+\.\w{2,}\S*)/g, '');
 
   const matches = text.match(rtlChars);
 
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index fd2180d6f..dee3c3439 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -507,6 +507,7 @@
       flex: 1 1 auto;
       overflow: hidden;
       text-overflow: ellipsis;
+      white-space: nowrap;
     }
 
     strong {
@@ -515,8 +516,10 @@
 
     &__uses {
       flex: 0 0 auto;
-      width: 80px;
       text-align: right;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
     }
   }
 
@@ -3449,6 +3452,10 @@ a.status-card.compact:hover {
     height: auto;
   }
 
+  &--click-thru {
+    pointer-events: none;
+  }
+
   &--hidden {
     display: none;
   }
@@ -3477,6 +3484,12 @@ a.status-card.compact:hover {
         background: rgba($base-overlay-background, 0.8);
       }
     }
+
+    &:disabled {
+      .spoiler-button__overlay__label {
+        background: rgba($base-overlay-background, 0.5);
+      }
+    }
   }
 }
 
diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss
index e4564f062..c0944d417 100644
--- a/app/javascript/styles/mastodon/dashboard.scss
+++ b/app/javascript/styles/mastodon/dashboard.scss
@@ -15,6 +15,8 @@
       padding: 20px;
       background: lighten($ui-base-color, 4%);
       border-radius: 4px;
+      box-sizing: border-box;
+      height: 100%;
     }
 
     & > a {
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
index f74c004e9..00d290883 100644
--- a/app/javascript/styles/mastodon/footer.scss
+++ b/app/javascript/styles/mastodon/footer.scss
@@ -128,7 +128,7 @@
       &:hover,
       &:focus,
       &:active {
-        svg path {
+        svg {
           fill: lighten($ui-base-color, 38%);
         }
       }
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index ac99124ea..16352340b 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -112,6 +112,15 @@ code {
       padding: 0.2em 0.4em;
       background: darken($ui-base-color, 12%);
     }
+
+    li {
+      list-style: disc;
+      margin-left: 18px;
+    }
+  }
+
+  ul.hint {
+    margin-bottom: 15px;
   }
 
   span.hint {
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 1c58be8c0..cb2ac72d4 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -32,22 +32,23 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
   end
 
   def serializable_hash(options = nil)
+    named_contexts     = {}
+    context_extensions = {}
     options         = serialization_options(options)
-    serialized_hash = serializer.serializable_hash(options)
+    serialized_hash = serializer.serializable_hash(options.merge(named_contexts: named_contexts, context_extensions: context_extensions))
     serialized_hash = serialized_hash.select { |k, _| options[:fields].include?(k) } if options[:fields]
     serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options)
 
-    { '@context' => serialized_context }.merge(serialized_hash)
+    { '@context' => serialized_context(named_contexts, context_extensions) }.merge(serialized_hash)
   end
 
   private
 
-  def serialized_context
+  def serialized_context(named_contexts_map, context_extensions_map)
     context_array = []
 
-    serializer_options = serializer.send(:instance_options) || {}
-    named_contexts     = [:activitystreams] + serializer._named_contexts.keys + serializer_options.fetch(:named_contexts, {}).keys
-    context_extensions = serializer._context_extensions.keys + serializer_options.fetch(:context_extensions, {}).keys
+    named_contexts     = [:activitystreams] + named_contexts_map.keys
+    context_extensions = context_extensions_map.keys
 
     named_contexts.each do |key|
       context_array << NAMED_CONTEXT_MAP[key]
diff --git a/app/lib/activitypub/serializer.rb b/app/lib/activitypub/serializer.rb
index 07bd8c494..1fdc79310 100644
--- a/app/lib/activitypub/serializer.rb
+++ b/app/lib/activitypub/serializer.rb
@@ -27,4 +27,12 @@ class ActivityPub::Serializer < ActiveModel::Serializer
       _context_extensions[extension_name] = true
     end
   end
+
+  def serializable_hash(adapter_options = nil, options = {}, adapter_instance = self.class.serialization_adapter_instance)
+    unless adapter_options&.fetch(:named_contexts, nil).nil?
+      adapter_options[:named_contexts].merge!(_named_contexts)
+      adapter_options[:context_extensions].merge!(_context_extensions)
+    end
+    super(adapter_options, options, adapter_instance)
+  end
 end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 224d90660..4587664b8 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -78,7 +78,7 @@ class FeedManager
     reblog_key   = key(type, account_id, 'reblogs')
 
     # Remove any items past the MAX_ITEMS'th entry in our feed
-    redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s)
+    redis.zremrangebyrank(timeline_key, 0, -(FeedManager::MAX_ITEMS + 1))
 
     # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
     # tracking anything after it for deduplication purposes.
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 9d874fe2c..42ccc6513 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -191,6 +191,9 @@ class Request
           end
         end
 
+        socks = []
+        addr_by_socket = {}
+
         addresses.each do |address|
           begin
             check_private_address(address)
@@ -200,30 +203,45 @@ class Request
 
             sock.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1)
 
-            begin
-              sock.connect_nonblock(sockaddr)
-            rescue IO::WaitWritable
-              if IO.select(nil, [sock], nil, Request::TIMEOUT[:connect])
-                begin
-                  sock.connect_nonblock(sockaddr)
-                rescue Errno::EISCONN
-                  # Yippee!
-                rescue
-                  sock.close
-                  raise
-                end
-              else
-                sock.close
-                raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect]} seconds"
-              end
-            end
+            sock.connect_nonblock(sockaddr)
 
+            # If that hasn't raised an exception, we somehow managed to connect
+            # immediately, close pending sockets and return immediately
+            socks.each(&:close)
             return sock
+          rescue IO::WaitWritable
+            socks << sock
+            addr_by_socket[sock] = sockaddr
           rescue => e
             outer_e = e
           end
         end
 
+        until socks.empty?
+          _, available_socks, = IO.select(nil, socks, nil, Request::TIMEOUT[:connect])
+
+          if available_socks.nil?
+            socks.each(&:close)
+            raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect]} seconds"
+          end
+
+          available_socks.each do |sock|
+            socks.delete(sock)
+
+            begin
+              sock.connect_nonblock(addr_by_socket[sock])
+            rescue Errno::EISCONN
+            rescue => e
+              sock.close
+              outer_e = e
+              next
+            end
+
+            socks.each(&:close)
+            return sock
+          end
+        end
+
         if outer_e
           raise outer_e
         else
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 945e3a3c6..135e0a030 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -7,14 +7,14 @@
 #  name                :string           default(""), not null
 #  created_at          :datetime         not null
 #  updated_at          :datetime         not null
-#  score               :integer
 #  usable              :boolean
 #  trendable           :boolean
 #  listable            :boolean
 #  reviewed_at         :datetime
 #  requested_review_at :datetime
 #  last_status_at      :datetime
-#  last_trend_at       :datetime
+#  max_score           :float
+#  max_score_at        :datetime
 #
 
 class Tag < ApplicationRecord
diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb
index e4ce988c1..e1b92b175 100644
--- a/app/models/trending_tags.rb
+++ b/app/models/trending_tags.rb
@@ -7,6 +7,8 @@ class TrendingTags
   THRESHOLD            = 5
   LIMIT                = 10
   REVIEW_THRESHOLD     = 3
+  MAX_SCORE_COOLDOWN   = 3.days.freeze
+  MAX_SCORE_HALFLIFE   = 6.hours.freeze
 
   class << self
     include Redisable
@@ -16,14 +18,75 @@ class TrendingTags
 
       increment_historical_use!(tag.id, at_time)
       increment_unique_use!(tag.id, account.id, at_time)
-      increment_vote!(tag, at_time)
+      increment_use!(tag.id, at_time)
 
       tag.update(last_status_at: Time.now.utc) if tag.last_status_at.nil? || tag.last_status_at < 12.hours.ago
-      tag.update(last_trend_at: Time.now.utc)  if trending?(tag) && (tag.last_trend_at.nil? || tag.last_trend_at < 12.hours.ago)
+    end
+
+    def update!(at_time = Time.now.utc)
+      tag_ids = redis.smembers("#{KEY}:used:#{at_time.beginning_of_day.to_i}") + redis.zrange(KEY, 0, -1)
+      tags    = Tag.where(id: tag_ids.uniq)
+
+      # First pass to calculate scores and update the set
+
+      tags.each do |tag|
+        expected  = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
+        expected  = 1.0 if expected.zero?
+        observed  = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
+        max_time  = tag.max_score_at
+        max_score = tag.max_score
+        max_score = 0 if max_time.nil? || max_time < (at_time - MAX_SCORE_COOLDOWN)
+
+        score = begin
+          if expected > observed || observed < THRESHOLD
+            0
+          else
+            ((observed - expected)**2) / expected
+          end
+        end
+
+        if score > max_score
+          max_score = score
+          max_time  = at_time
+
+          # Not interested in triggering any callbacks for this
+          tag.update_columns(max_score: max_score, max_score_at: max_time)
+        end
+
+        decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / MAX_SCORE_HALFLIFE.to_f))
+
+        if decaying_score.zero?
+          redis.zrem(KEY, tag.id)
+        else
+          redis.zadd(KEY, decaying_score, tag.id)
+        end
+      end
+
+      users_for_review = User.staff.includes(:account).to_a.select(&:allows_trending_tag_emails?)
+
+      # Second pass to notify about previously unreviewed trends
+
+      tags.each do |tag|
+        current_rank              = redis.zrevrank(KEY, tag.id)
+        needs_review_notification = tag.requires_review? && !tag.requested_review?
+        rank_passes_threshold     = current_rank.present? && current_rank <= REVIEW_THRESHOLD
+
+        next unless !tag.trendable? && rank_passes_threshold && needs_review_notification
+
+        tag.touch(:requested_review_at)
+
+        users_for_review.each do |user|
+          AdminMailer.new_trending_tag(user.account, tag).deliver_later!
+        end
+      end
+
+      # Trim older items
+
+      redis.zremrangebyrank(KEY, 0, -(LIMIT + 1))
     end
 
     def get(limit, filtered: true)
-      tag_ids = redis.zrevrange("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", 0, LIMIT - 1).map(&:to_i)
+      tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i)
 
       tags = Tag.where(id: tag_ids)
       tags = tags.where(trendable: true) if filtered
@@ -33,8 +96,8 @@ class TrendingTags
     end
 
     def trending?(tag)
-      rank = redis.zrevrank("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", tag.id)
-      rank.present? && rank <= LIMIT
+      rank = redis.zrevrank(KEY, tag.id)
+      rank.present? && rank < LIMIT
     end
 
     private
@@ -51,31 +114,10 @@ class TrendingTags
       redis.expire(key, EXPIRE_HISTORY_AFTER)
     end
 
-    def increment_vote!(tag, at_time)
-      key      = "#{KEY}:#{at_time.beginning_of_day.to_i}"
-      expected = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
-      expected = 1.0 if expected.zero?
-      observed = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
-
-      if expected > observed || observed < THRESHOLD
-        redis.zrem(key, tag.id)
-      else
-        score    = ((observed - expected)**2) / expected
-        old_rank = redis.zrevrank(key, tag.id)
-
-        redis.zadd(key, score, tag.id)
-        request_review!(tag) if (old_rank.nil? || old_rank > REVIEW_THRESHOLD) && redis.zrevrank(key, tag.id) <= REVIEW_THRESHOLD && !tag.trendable? && tag.requires_review? && !tag.requested_review?
-      end
-
-      redis.expire(key, EXPIRE_TRENDS_AFTER)
-    end
-
-    def request_review!(tag)
-      return unless Setting.trends
-
-      tag.touch(:requested_review_at)
-
-      User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? }
+    def increment_use!(tag_id, at_time)
+      key = "#{KEY}:used:#{at_time.beginning_of_day.to_i}"
+      redis.sadd(key, tag_id)
+      redis.expire(key, EXPIRE_HISTORY_AFTER)
     end
   end
 end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index 222e17c99..17df85de3 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -6,7 +6,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   context :security
 
   context_extensions :manually_approves_followers, :featured, :also_known_as,
-                     :moved_to, :property_value, :hashtag, :emoji, :identity_proof,
+                     :moved_to, :property_value, :identity_proof,
                      :discoverable
 
   attributes :id, :type, :following, :followers,
@@ -138,6 +138,8 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   end
 
   class TagSerializer < ActivityPub::Serializer
+    context_extensions :hashtag
+
     include RoutingHelper
 
     attributes :type, :href, :name
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 067ba5c32..f1cebbcd4 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -1,8 +1,7 @@
 # frozen_string_literal: true
 
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
-  context_extensions :atom_uri, :conversation, :sensitive,
-                     :hashtag, :emoji, :focal_point, :blurhash
+  context_extensions :atom_uri, :conversation, :sensitive
 
   attributes :id, :type, :summary,
              :in_reply_to, :published, :url,
@@ -152,6 +151,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   class MediaAttachmentSerializer < ActivityPub::Serializer
+    context_extensions :blurhash, :focal_point
+
     include RoutingHelper
 
     attributes :type, :media_type, :url, :name, :blurhash
@@ -199,6 +200,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   class TagSerializer < ActivityPub::Serializer
+    context_extensions :hashtag
+
     include RoutingHelper
 
     attributes :type, :href, :name
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 902af376c..85da7e921 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -61,6 +61,7 @@ class SuspendAccountService < BaseService
     return if !@account.local? || @account.user.nil?
 
     if @options[:including_user]
+      @options[:destroy] = true if !@account.user_confirmed? || @account.user_pending?
       @account.user.destroy
     else
       @account.user.disable!
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index 982dc5035..1d85aa75e 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -44,15 +44,16 @@
             - if !instance.domain_block.noop?
               = t("admin.domain_blocks.severity.#{instance.domain_block.severity}")
               - first_item = false
-            - if instance.domain_block.reject_media?
-              - unless first_item
-                &bull;
-              = t('admin.domain_blocks.rejecting_media')
-              - first_item = false
-            - if instance.domain_block.reject_reports?
-              - unless first_item
-                &bull;
-              = t('admin.domain_blocks.rejecting_reports')
+            - unless instance.domain_block.suspend?
+              - if instance.domain_block.reject_media?
+                - unless first_item
+                  &bull;
+                = t('admin.domain_blocks.rejecting_media')
+                - first_item = false
+              - if instance.domain_block.reject_reports?
+                - unless first_item
+                  &bull;
+                = t('admin.domain_blocks.rejecting_reports')
           - elsif whitelist_mode?
             = t('admin.accounts.whitelisted')
           - else
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index c3779d48c..d54a43c1e 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -38,8 +38,10 @@
 .table-wrapper
   %table.table
     %tbody
+      - total = @usage_by_domain.sum(&:last).to_f
+
       - @usage_by_domain.each do |(domain, count)|
         %tr
           %th= domain || site_hostname
-          %td= number_to_percentage((count / @tag.history[0][:uses].to_f) * 100)
+          %td= number_to_percentage((count / total) * 100, precision: 1)
           %td= number_with_delimiter count
diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml
index 90c8f9dd1..33e7c96fe 100644
--- a/app/views/application/_sidebar.html.haml
+++ b/app/views/application/_sidebar.html.haml
@@ -5,7 +5,7 @@
   .hero-widget__text
     %p= @instance_presenter.site_short_description.html_safe.presence || @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
 
-- if Setting.trends
+- if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
   - trends = TrendingTags.get(3)
 
   - unless trends.empty?
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index 83384d737..e807c8d86 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -2,7 +2,7 @@
   = t('auth.register')
 
 - content_for :header_tags do
-  = render partial: 'shared/og'
+  = render partial: 'shared/og', locals: { description: description_for_sign_up }
 
 = simple_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f|
   = render 'shared/error_messages', object: resource
diff --git a/app/views/auth/setup/show.html.haml b/app/views/auth/setup/show.html.haml
index 8bb44ca7f..c14fed56f 100644
--- a/app/views/auth/setup/show.html.haml
+++ b/app/views/auth/setup/show.html.haml
@@ -17,7 +17,4 @@
   .simple_form
     %p.hint= t('auth.setup.email_settings_hint_html', email: content_tag(:strong, @user.email))
 
-.form-footer
-  %ul.no-list
-    %li= link_to t('settings.account_settings'), edit_user_registration_path
-    %li= link_to t('auth.logout'), destroy_user_session_path, data: { method: :delete }
+.form-footer= render 'auth/shared/links'
diff --git a/app/views/auth/shared/_links.html.haml b/app/views/auth/shared/_links.html.haml
index 3c68ccd22..e6c3f7cca 100644
--- a/app/views/auth/shared/_links.html.haml
+++ b/app/views/auth/shared/_links.html.haml
@@ -1,12 +1,18 @@
 %ul.no-list
-  - if controller_name != 'sessions'
-    %li= link_to t('auth.login'), new_session_path(resource_name)
+  - if user_signed_in?
+    %li= link_to t('settings.account_settings'), edit_user_registration_path
+  - else
+    - if controller_name != 'sessions'
+      %li= link_to t('auth.login'), new_user_session_path
 
-  - if devise_mapping.registerable? && controller_name != 'registrations'
-    %li= link_to t('auth.register'), available_sign_up_path
+    - if controller_name != 'registrations'
+      %li= link_to t('auth.register'), available_sign_up_path
 
-  - if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations'
-    %li= link_to t('auth.forgot_password'), new_password_path(resource_name)
+    - if controller_name != 'passwords' && controller_name != 'registrations'
+      %li= link_to t('auth.forgot_password'), new_user_password_path
 
-  - if devise_mapping.confirmable? && controller_name != 'confirmations'
-    %li= link_to t('auth.didnt_get_confirmation'), new_confirmation_path(resource_name)
+  - if controller_name != 'confirmations'
+    %li= link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
+
+  - if user_signed_in? && controller_name != 'setup'
+    %li= link_to t('auth.logout'), destroy_user_session_path, data: { method: :delete }
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
index 811080eb4..dee99475a 100644
--- a/app/views/directories/index.html.haml
+++ b/app/views/directories/index.html.haml
@@ -49,7 +49,7 @@
             - if account.last_status_at.present?
               %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
             - else
-              = t('invites.expires_in_prompt')
+              = t('accounts.never_active')
 
             %small= t('accounts.last_active')
 
diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml
index 40f3aa88a..e992e5563 100644
--- a/app/views/notification_mailer/_status.html.haml
+++ b/app/views/notification_mailer/_status.html.haml
@@ -36,7 +36,10 @@
                                 - if status.media_attachments.size > 0
                                   %p
                                     - status.media_attachments.each do |a|
-                                      = link_to medium_url(a), medium_url(a)
+                                      - if status.local?
+                                        = link_to medium_url(a), medium_url(a)
+                                      - else
+                                        = link_to a.remote_url, a.remote_url
 
                               %p.status-footer
                                 = link_to l(status.created_at), web_url("statuses/#{status.id}")
diff --git a/app/views/settings/deletes/show.html.haml b/app/views/settings/deletes/show.html.haml
index b246f83a1..6e2ff31c5 100644
--- a/app/views/settings/deletes/show.html.haml
+++ b/app/views/settings/deletes/show.html.haml
@@ -2,15 +2,25 @@
   = t('settings.delete')
 
 = simple_form_for @confirmation, url: settings_delete_path, method: :delete do |f|
-  .warning
-    %strong
-      = fa_icon('warning')
-      = t('deletes.warning_title')
-    = t('deletes.warning_html')
+  %p.hint= t('deletes.warning.before')
 
-  %p.hint= t('deletes.description_html')
+  %ul.hint
+    - if current_user.confirmed? && current_user.approved?
+      %li.warning-hint= t('deletes.warning.irreversible')
+      %li.warning-hint= t('deletes.warning.username_unavailable')
+      %li.warning-hint= t('deletes.warning.data_removal')
+      %li.warning-hint= t('deletes.warning.caches')
+    - else
+      %li.positive-hint= t('deletes.warning.email_change_html', path: edit_user_registration_path)
+      %li.positive-hint= t('deletes.warning.email_reconfirmation_html', path: new_user_confirmation_path)
+      %li.positive-hint= t('deletes.warning.email_contact_html', email: Setting.site_contact_email)
+      %li.positive-hint= t('deletes.warning.username_available')
 
-  = f.input :password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, hint: t('deletes.confirm_password')
+  %p.hint= t('deletes.warning.more_details_html', terms_path: terms_path)
+
+  %hr.spacer/
+
+  = f.input :password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_password')
 
   .actions
     = f.button :button, t('deletes.proceed'), type: :submit, class: 'negative'
diff --git a/app/views/shared/_og.html.haml b/app/views/shared/_og.html.haml
index 67238fc8b..576f47a67 100644
--- a/app/views/shared/_og.html.haml
+++ b/app/views/shared/_og.html.haml
@@ -1,5 +1,5 @@
-- thumbnail = @instance_presenter.thumbnail
-- description = strip_tags(@instance_presenter.site_short_description.presence || @instance_presenter.site_description.presence || t('about.about_mastodon_html'))
+- thumbnail     = @instance_presenter.thumbnail
+- description ||= strip_tags(@instance_presenter.site_short_description.presence || @instance_presenter.site_description.presence || t('about.about_mastodon_html'))
 
 %meta{ name: 'description', content: description }/
 
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index 1105f2062..89dc2a75d 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -42,11 +42,11 @@
                               - unless @warning.text.blank?
                                 = Formatter.instance.linkify(@warning.text)
 
-                              - unless @statuses&.empty?
+                              - if !@statuses.nil? && !@statuses.empty?
                                 %p
                                   %strong= t('user_mailer.warning.statuses')
 
-- unless @statuses&.empty?
+- if !@statuses.nil? && !@statuses.empty?
   - @statuses.each_with_index do |status, i|
     = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true
 
diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb
index 45ad3b64d..bb6610c79 100644
--- a/app/views/user_mailer/warning.text.erb
+++ b/app/views/user_mailer/warning.text.erb
@@ -7,7 +7,7 @@
 
 <% end %>
 <%= @warning.text %>
-<% unless @statuses&.empty? %>
+<% if !@statuses.nil? && !@statuses.empty? %>
 <%= t('user_mailer.warning.statuses') %>
 
 <% @statuses.each do |status| %>
diff --git a/app/workers/scheduler/trending_tags_scheduler.rb b/app/workers/scheduler/trending_tags_scheduler.rb
new file mode 100644
index 000000000..77f0d5747
--- /dev/null
+++ b/app/workers/scheduler/trending_tags_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Scheduler::TrendingTagsScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options unique: :until_executed, retry: 0
+
+  def perform
+    TrendingTags.update! if Setting.trends
+  end
+end
diff --git a/config/deploy.rb b/config/deploy.rb
index f0db50788..c4133e794 100644
--- a/config/deploy.rb
+++ b/config/deploy.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-lock '3.11.0'
+lock '3.11.1'
 
 set :repo_url, ENV.fetch('REPO', 'https://github.com/tootsuite/mastodon.git')
 set :branch, ENV.fetch('BRANCH', 'master')
diff --git a/config/environments/production.rb b/config/environments/production.rb
index ccf325bf2..00571a35a 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -83,7 +83,10 @@ Rails.application.configure do
   config.action_mailer.perform_caching = false
 
   # E-mails
-  config.action_mailer.default_options = { from: ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost') }
+  config.action_mailer.default_options = {
+    from: ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost'),
+    reply_to: ENV['SMTP_REPLY_TO']
+  }
 
   config.action_mailer.smtp_settings = {
     :port                 => ENV['SMTP_PORT'],
diff --git a/config/initializers/active_model_serializers.rb b/config/initializers/active_model_serializers.rb
index 329a5fb2c..0e69e1d96 100644
--- a/config/initializers/active_model_serializers.rb
+++ b/config/initializers/active_model_serializers.rb
@@ -3,22 +3,3 @@ ActiveModelSerializers.config.tap do |config|
 end
 
 ActiveSupport::Notifications.unsubscribe(ActiveModelSerializers::Logging::RENDER_EVENT)
-
-class ActiveModel::Serializer::Reflection
-  # We monkey-patch this method so that when we include associations in a serializer,
-  # the nested serializers can send information about used contexts upwards back to
-  # the root. We do this via instance_options because the nesting can be dynamic.
-  def build_association(parent_serializer, parent_serializer_options, include_slice = {})
-    serializer = options[:serializer]
-
-    parent_serializer_options.merge!(named_contexts: serializer._named_contexts, context_extensions: serializer._context_extensions) if serializer.respond_to?(:_named_contexts)
-
-    association_options = {
-      parent_serializer: parent_serializer,
-      parent_serializer_options: parent_serializer_options,
-      include_slice: include_slice,
-    }
-
-    ActiveModel::Serializer::Association.new(self, association_options)
-  end
-end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 98783da45..56f0fd2cf 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -58,6 +58,7 @@ en:
     media: Media
     moved_html: "%{name} has moved to %{new_profile_link}:"
     network_hidden: This information is not available
+    never_active: Never
     nothing_here: There is nothing here!
     people_followed_by: People whom %{name} follows
     people_who_follow: People who follow %{name}
@@ -581,6 +582,10 @@ en:
     checkbox_agreement_without_rules_html: I agree to the <a href="%{terms_path}" target="_blank">terms of service</a>
     delete_account: Delete account
     delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
+    description:
+      prefix_invited_by_user: "@%{name} invites you to join this server of Mastodon!"
+      prefix_sign_up: Sign up on Mastodon today!
+      suffix: With an account, you will be able to follow people, post updates and exchange messages with users from any Mastodon server and more!
     didnt_get_confirmation: Didn't receive confirmation instructions?
     forgot_password: Forgot your password?
     invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one.
@@ -634,13 +639,21 @@ en:
       x_months: "%{count}mo"
       x_seconds: "%{count}s"
   deletes:
-    bad_password_msg: Nice try, hackers! Incorrect password
+    bad_password_msg: The password you entered was incorrect
     confirm_password: Enter your current password to verify your identity
-    description_html: This will <strong>permanently, irreversibly</strong> remove content from your account and deactivate it. Your username will remain reserved to prevent future impersonations.
     proceed: Delete account
     success_msg: Your account was successfully deleted
-    warning_html: Only deletion of content from this particular server is guaranteed. Content that has been widely shared is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases.
-    warning_title: Disseminated content availability
+    warning:
+      before: 'Before proceeding, please read these notes carefully:'
+      caches: Content that has been cached by other servers may persist
+      data_removal: Your posts and other data will be permanently removed
+      email_change_html: You can <a href="%{path}">change your e-mail address</a> without deleting your account
+      email_contact_html: If it still doesn't arrive, you can e-mail <a href="mailto:%{email}">%{email}</a> for help
+      email_reconfirmation_html: If you are not receiving the confirmation e-mail, you can <a href="%{path}">request it again</a>
+      irreversible: You will not be able to restore or reactivate your account
+      more_details_html: For more details, see the <a href="%{terms_path}">privacy policy</a>.
+      username_available: Your username will become available again
+      username_unavailable: Your username will remain unavailable
   directories:
     directory: Profile directory
     explanation: Discover users based on their interests
diff --git a/config/puma.rb b/config/puma.rb
index 6a96867d5..224be7903 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -1,3 +1,5 @@
+persistent_timeout ENV.fetch('PERSISTENT_TIMEOUT') { 20 }.to_i
+
 threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i
 threads threads_count, threads_count
 
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 6ebe450b0..5de25de23 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -9,6 +9,9 @@
   scheduled_statuses_scheduler:
     every: '5m'
     class: Scheduler::ScheduledStatusesScheduler
+  trending_tags_scheduler:
+    every: '5m'
+    class: Scheduler::TrendingTagsScheduler
   media_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
     class: Scheduler::MediaCleanupScheduler
diff --git a/db/migrate/20190901035623_add_max_score_to_tags.rb b/db/migrate/20190901035623_add_max_score_to_tags.rb
new file mode 100644
index 000000000..f936e9871
--- /dev/null
+++ b/db/migrate/20190901035623_add_max_score_to_tags.rb
@@ -0,0 +1,6 @@
+class AddMaxScoreToTags < ActiveRecord::Migration[5.2]
+  def change
+    add_column :tags, :max_score, :float
+    add_column :tags, :max_score_at, :datetime
+  end
+end
diff --git a/db/post_migrate/20190901040524_remove_score_from_tags.rb b/db/post_migrate/20190901040524_remove_score_from_tags.rb
new file mode 100644
index 000000000..a1112700b
--- /dev/null
+++ b/db/post_migrate/20190901040524_remove_score_from_tags.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class RemoveScoreFromTags < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured do
+      remove_column :tags, :score, :int
+      remove_column :tags, :last_trend_at, :datetime
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 328506b50..f15f33bea 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2019_08_23_221802) do
+ActiveRecord::Schema.define(version: 2019_09_01_040524) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -677,14 +677,14 @@ ActiveRecord::Schema.define(version: 2019_08_23_221802) do
     t.string "name", default: "", null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
-    t.integer "score"
     t.boolean "usable"
     t.boolean "trendable"
     t.boolean "listable"
     t.datetime "reviewed_at"
     t.datetime "requested_review_at"
     t.datetime "last_status_at"
-    t.datetime "last_trend_at"
+    t.float "max_score"
+    t.datetime "max_score_at"
     t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true
   end
 
diff --git a/package.json b/package.json
index 11dbc57a7..895245eed 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,7 @@
     "@babel/plugin-transform-react-inline-elements": "^7.2.0",
     "@babel/plugin-transform-react-jsx-self": "^7.2.0",
     "@babel/plugin-transform-react-jsx-source": "^7.5.0",
-    "@babel/plugin-transform-runtime": "^7.4.4",
+    "@babel/plugin-transform-runtime": "^7.5.5",
     "@babel/preset-env": "^7.5.5",
     "@babel/preset-react": "^7.0.0",
     "@babel/runtime": "^7.5.4",
@@ -172,7 +172,7 @@
     "websocket.js": "^0.1.12"
   },
   "devDependencies": {
-    "babel-eslint": "^10.0.2",
+    "babel-eslint": "^10.0.3",
     "babel-jest": "^24.8.0",
     "enzyme": "^3.10.0",
     "enzyme-adapter-react-16": "^1.14.0",
diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb
index fbfc585cf..42da29860 100644
--- a/spec/lib/activitypub/activity/update_spec.rb
+++ b/spec/lib/activitypub/activity/update_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe ActivityPub::Activity::Update do
   end
 
   let(:actor_json) do
-    ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, key_transform: :camel_lower).as_json
+    ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter).as_json
   end
 
   let(:json) do
diff --git a/spec/models/trending_tags_spec.rb b/spec/models/trending_tags_spec.rb
new file mode 100644
index 000000000..b6122c994
--- /dev/null
+++ b/spec/models/trending_tags_spec.rb
@@ -0,0 +1,68 @@
+require 'rails_helper'
+
+RSpec.describe TrendingTags do
+  describe '.record_use!' do
+    pending
+  end
+
+  describe '.update!' do
+    let!(:at_time) { Time.now.utc }
+    let!(:tag1) { Fabricate(:tag, name: 'Catstodon') }
+    let!(:tag2) { Fabricate(:tag, name: 'DogsOfMastodon') }
+    let!(:tag3) { Fabricate(:tag, name: 'OCs') }
+
+    before do
+      allow(Redis.current).to receive(:pfcount) do |key|
+        case key
+        when "activity:tags:#{tag1.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts"
+          2
+        when "activity:tags:#{tag1.id}:#{at_time.beginning_of_day.to_i}:accounts"
+          16
+        when "activity:tags:#{tag2.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts"
+          0
+        when "activity:tags:#{tag2.id}:#{at_time.beginning_of_day.to_i}:accounts"
+          4
+        when "activity:tags:#{tag3.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts"
+          13
+        end
+      end
+
+      Redis.current.zadd('trending_tags', 0.9, tag3.id)
+      Redis.current.sadd("trending_tags:used:#{at_time.beginning_of_day.to_i}", [tag1.id, tag2.id])
+
+      tag3.update(max_score: 0.9, max_score_at: (at_time - 1.day).beginning_of_day + 12.hours)
+
+      described_class.update!(at_time)
+    end
+
+    it 'calculates and re-calculates scores' do
+      expect(described_class.get(10, filtered: false)).to eq [tag1, tag3]
+    end
+
+    it 'omits hashtags below threshold' do
+      expect(described_class.get(10, filtered: false)).to_not include(tag2)
+    end
+
+    it 'decays scores' do
+      expect(Redis.current.zscore('trending_tags', tag3.id)).to be < 0.9
+    end
+  end
+
+  describe '.trending?' do
+    let(:tag) { Fabricate(:tag) }
+
+    before do
+      10.times { |i| Redis.current.zadd('trending_tags', i + 1, Fabricate(:tag).id) }
+    end
+
+    it 'returns true if the hashtag is within limit' do
+      Redis.current.zadd('trending_tags', 11, tag.id)
+      expect(described_class.trending?(tag)).to be true
+    end
+
+    it 'returns false if the hashtag is outside the limit' do
+      Redis.current.zadd('trending_tags', 0, tag.id)
+      expect(described_class.trending?(tag)).to be false
+    end
+  end
+end
diff --git a/yarn.lock b/yarn.lock
index ab20731ff..4d601731d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,14 +2,7 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
-  integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==
-  dependencies:
-    "@babel/highlight" "^7.0.0"
-
-"@babel/code-frame@^7.5.5":
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d"
   integrity sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==
@@ -291,12 +284,7 @@
     esutils "^2.0.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.2", "@babel/parser@^7.3.4", "@babel/parser@^7.4.4", "@babel/parser@^7.4.5":
-  version "7.4.5"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.4.5.tgz#04af8d5d5a2b044a2a1bffacc1e5e6673544e872"
-  integrity sha512-9mUqkL1FF5T7f0WDFfAoDdiMVPWsdD1gZYzSnaXsxUCUqzuch/8of9G3VUSNiZmMBoRxT3neyVsqeiL/ZPcjew==
-
-"@babel/parser@^7.5.5":
+"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.2", "@babel/parser@^7.3.4", "@babel/parser@^7.4.4", "@babel/parser@^7.4.5", "@babel/parser@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.5.tgz#02f077ac8817d3df4a832ef59de67565e71cca4b"
   integrity sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==
@@ -657,10 +645,10 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
 
-"@babel/plugin-transform-runtime@^7.4.4":
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.4.4.tgz#a50f5d16e9c3a4ac18a1a9f9803c107c380bce08"
-  integrity sha512-aMVojEjPszvau3NRg+TIH14ynZLvPewH4xhlCW1w6A3rkxTS1m4uwzRclYR9oS+rl/dr+kT+pzbfHuAWP/lc7Q==
+"@babel/plugin-transform-runtime@^7.5.5":
+  version "7.5.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.5.5.tgz#a6331afbfc59189d2135b2e09474457a8e3d28bc"
+  integrity sha512-6Xmeidsun5rkwnGfMOp6/z9nSzWpHFNVr2Jx7kwoq4mVatQfQx5S56drBgEHF+XQbKOdIaOiMIINvp/kAwMN+w==
   dependencies:
     "@babel/helper-module-imports" "^7.0.0"
     "@babel/helper-plugin-utils" "^7.0.0"
@@ -819,22 +807,7 @@
     "@babel/parser" "^7.4.4"
     "@babel/types" "^7.4.4"
 
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.5", "@babel/traverse@^7.3.4", "@babel/traverse@^7.4.4", "@babel/traverse@^7.4.5":
-  version "7.4.5"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.4.5.tgz#4e92d1728fd2f1897dafdd321efbff92156c3216"
-  integrity sha512-Vc+qjynwkjRmIFGxy0KYoPj4FdVDxLej89kMHFsWScq999uX+pwcX4v9mWRjW0KcAYTPAuVQl2LKP1wEVLsp+A==
-  dependencies:
-    "@babel/code-frame" "^7.0.0"
-    "@babel/generator" "^7.4.4"
-    "@babel/helper-function-name" "^7.1.0"
-    "@babel/helper-split-export-declaration" "^7.4.4"
-    "@babel/parser" "^7.4.5"
-    "@babel/types" "^7.4.4"
-    debug "^4.1.0"
-    globals "^11.1.0"
-    lodash "^4.17.11"
-
-"@babel/traverse@^7.5.5":
+"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.5", "@babel/traverse@^7.3.4", "@babel/traverse@^7.4.4", "@babel/traverse@^7.4.5", "@babel/traverse@^7.5.5":
   version "7.5.5"
   resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.5.5.tgz#f664f8f368ed32988cd648da9f72d5ca70f165bb"
   integrity sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ==
@@ -1737,17 +1710,17 @@ axobject-query@^2.0.2:
   dependencies:
     ast-types-flow "0.0.7"
 
-babel-eslint@^10.0.2:
-  version "10.0.2"
-  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.2.tgz#182d5ac204579ff0881684b040560fdcc1558456"
-  integrity sha512-UdsurWPtgiPgpJ06ryUnuaSXC2s0WoSZnQmEpbAH65XZSdwowgN5MvyP7e88nW07FYXv72erVtpBkxyDVKhH1Q==
+babel-eslint@^10.0.3:
+  version "10.0.3"
+  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.3.tgz#81a2c669be0f205e19462fed2482d33e4687a88a"
+  integrity sha512-z3U7eMY6r/3f3/JB9mTsLjyxrv0Yb1zb8PCWCLpguxfCzBIZUwy23R1t/XKewP+8mEN2Ck8Dtr4q20z6ce6SoA==
   dependencies:
     "@babel/code-frame" "^7.0.0"
     "@babel/parser" "^7.0.0"
     "@babel/traverse" "^7.0.0"
     "@babel/types" "^7.0.0"
-    eslint-scope "3.7.1"
     eslint-visitor-keys "^1.0.0"
+    resolve "^1.12.0"
 
 babel-jest@^24.8.0:
   version "24.8.0"
@@ -3816,14 +3789,6 @@ eslint-plugin-react@~7.14.3:
     prop-types "^15.7.2"
     resolve "^1.10.1"
 
-eslint-scope@3.7.1:
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8"
-  integrity sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=
-  dependencies:
-    esrecurse "^4.1.0"
-    estraverse "^4.1.1"
-
 eslint-scope@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848"
@@ -9027,10 +8992,10 @@ resolve@1.1.7:
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
   integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
 
-resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1:
-  version "1.11.1"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e"
-  integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==
+resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1:
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6"
+  integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==
   dependencies:
     path-parse "^1.0.6"