about summary refs log tree commit diff
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2018-12-09 16:38:09 +0100
committerGitHub <noreply@github.com>2018-12-09 16:38:09 +0100
commitc6b7b984891413cb1db673df2cbea12f8e6f0f05 (patch)
tree46e3982da219f7a5ac34c3aecaf32f0990578d1d
parente3682c9c1750e5e7e5d2f817e29f6760a18400ca (diff)
parent4f59d1efd786c08cc63e0c14dceada80c37ab8da (diff)
Merge pull request #849 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
-rw-r--r--Gemfile.lock2
-rw-r--r--app/controllers/admin/tags_controller.rb44
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb2
-rw-r--r--app/controllers/directories_controller.rb53
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/controllers/settings/profiles_controller.rb2
-rw-r--r--app/helpers/admin/filter_helper.rb3
-rw-r--r--app/javascript/core/settings.js8
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss5
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss168
-rw-r--r--app/javascript/styles/mastodon/accounts.scss5
-rw-r--r--app/javascript/styles/mastodon/components.scss3
-rw-r--r--app/javascript/styles/mastodon/widgets.scss168
-rw-r--r--app/lib/feed_manager.rb12
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/models/account.rb40
-rw-r--r--app/models/account_stat.rb12
-rw-r--r--app/models/account_tag_stat.rb24
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/models/concerns/account_counters.rb1
-rw-r--r--app/models/tag.rb31
-rw-r--r--app/models/user.rb6
-rw-r--r--app/policies/tag_policy.rb15
-rw-r--r--app/services/update_account_service.rb5
-rw-r--r--app/views/admin/tags/_tag.html.haml12
-rw-r--r--app/views/admin/tags/index.html.haml19
-rw-r--r--app/views/application/_card.html.haml1
-rw-r--r--app/views/directories/index.html.haml61
-rw-r--r--app/views/layouts/public.html.haml4
-rw-r--r--app/views/settings/preferences/show.html.haml3
-rw-r--r--app/views/settings/profiles/show.html.haml4
-rw-r--r--config/locales/en.yml19
-rw-r--r--config/locales/pl.yml21
-rw-r--r--config/locales/simple_form.en.yml4
-rw-r--r--config/locales/simple_form.pl.yml2
-rw-r--r--config/navigation.rb1
-rw-r--r--config/routes.rb12
-rw-r--r--config/settings.yml1
-rw-r--r--db/migrate/20181203003808_create_accounts_tags_join_table.rb8
-rw-r--r--db/migrate/20181203021853_add_discoverable_to_accounts.rb5
-rw-r--r--db/migrate/20181204193439_add_last_status_at_to_account_stats.rb5
-rw-r--r--db/migrate/20181204215309_create_account_tag_stats.rb11
-rw-r--r--db/schema.rb21
-rw-r--r--spec/controllers/activitypub/inboxes_controller_spec.rb24
-rw-r--r--spec/fabricators/account_tag_stat_fabricator.rb3
-rw-r--r--spec/helpers/admin/account_moderation_notes_helper_spec.rb62
-rw-r--r--spec/models/account_pin_spec.rb5
-rw-r--r--spec/models/account_tag_stat_spec.rb38
-rw-r--r--spec/validators/unique_username_validator_spec.rb8
49 files changed, 929 insertions, 43 deletions
diff --git a/Gemfile.lock b/Gemfile.lock
index f3b07d290..3a6283686 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -527,7 +527,7 @@ GEM
       rspec-core (~> 3.0, >= 3.0.0)
       sidekiq (>= 2.4.0)
     rspec-support (3.8.0)
-    rubocop (0.61.0)
+    rubocop (0.61.1)
       jaro_winkler (~> 1.5.1)
       parallel (~> 1.10)
       parser (>= 2.5, != 2.5.1.1)
diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
new file mode 100644
index 000000000..3f2256566
--- /dev/null
+++ b/app/controllers/admin/tags_controller.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Admin
+  class TagsController < BaseController
+    before_action :set_tags, only: :index
+    before_action :set_tag, except: :index
+    before_action :set_filter_params
+
+    def index
+      authorize :tag, :index?
+    end
+
+    def hide
+      authorize @tag, :hide?
+      @tag.account_tag_stat.update!(hidden: true)
+      redirect_to admin_tags_path(@filter_params)
+    end
+
+    def unhide
+      authorize @tag, :unhide?
+      @tag.account_tag_stat.update!(hidden: true)
+      redirect_to admin_tags_path(@filter_params)
+    end
+
+    private
+
+    def set_tags
+      @tags = Tag.discoverable
+      @tags.merge!(Tag.hidden) if filter_params[:hidden]
+    end
+
+    def set_tag
+      @tag = Tag.find(params[:id])
+    end
+
+    def set_filter_params
+      @filter_params = filter_params.to_hash.symbolize_keys
+    end
+
+    def filter_params
+      params.permit(:hidden)
+    end
+  end
+end
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index dcd41b35c..e77f57910 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -21,7 +21,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   private
 
   def account_params
-    params.permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value])
+    params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value])
   end
 
   def user_settings_params
diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb
new file mode 100644
index 000000000..9d65361a6
--- /dev/null
+++ b/app/controllers/directories_controller.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class DirectoriesController < ApplicationController
+  layout 'public'
+
+  before_action :set_instance_presenter
+  before_action :set_tag, only: :show
+  before_action :set_tags
+  before_action :set_accounts
+  before_action :set_pack
+
+  def index
+    render :index
+  end
+
+  def show
+    render :index
+  end
+
+  private
+
+  def set_pack
+    use_pack 'share'
+  end
+
+  def set_tag
+    @tag = Tag.discoverable.find_by!(name: params[:id].downcase)
+  end
+
+  def set_tags
+    @tags = Tag.discoverable.limit(30)
+  end
+
+  def set_accounts
+    @accounts = Account.searchable.discoverable.page(params[:page]).per(50).tap do |query|
+      query.merge!(Account.tagged_with(@tag.id)) if @tag
+
+      if popular_requested?
+        query.merge!(Account.popular)
+      else
+        query.merge!(Account.by_recent_status)
+      end
+    end
+  end
+
+  def set_instance_presenter
+    @instance_presenter = InstancePresenter.new
+  end
+
+  def popular_requested?
+    request.path.ends_with?('/popular')
+  end
+end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 5c5f31d2b..b70844b65 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -43,6 +43,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_system_font_ui,
       :setting_noindex,
       :setting_hide_network,
+      :setting_aggregate_reblogs,
       notification_emails: %i(follow follow_request reblog favourite mention digest report),
       interactions: %i(must_be_follower must_be_following)
     )
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 918dbc6c6..1a0b73d16 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -25,7 +25,7 @@ class Settings::ProfilesController < Settings::BaseController
   private
 
   def account_params
-    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, fields_attributes: [:name, :value])
+    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value])
   end
 
   def set_account
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index 9a663051c..8807cc784 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -5,8 +5,9 @@ module Admin::FilterHelper
   REPORT_FILTERS       = %i(resolved account_id target_account_id).freeze
   INVITE_FILTER        = %i(available expired).freeze
   CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
+  TAGS_FILTERS         = %i(hidden).freeze
 
-  FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS
+  FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS
 
   def filter_link_to(text, link_to_params, link_class_params = link_to_params)
     new_url = filtered_url_for(link_to_params)
diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js
index 23a303747..af97fb25f 100644
--- a/app/javascript/core/settings.js
+++ b/app/javascript/core/settings.js
@@ -1,13 +1,17 @@
 //  This file will be loaded on settings pages, regardless of theme.
 
+import escapeTextContentForBrowser from 'escape-html';
 const { delegate } = require('rails-ujs');
 import emojify from '../mastodon/features/emoji/emoji';
 
 delegate(document, '#account_display_name', 'input', ({ target }) => {
   const name = document.querySelector('.card .display-name strong');
-
   if (name) {
-    name.innerHTML = emojify(target.value);
+    if (target.value) {
+      name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
+    } else {
+      name.textContent = document.querySelector('#default_account_display_name').textContent;
+    }
   }
 });
 
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index 9568581ec..9c6518bea 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -191,6 +191,11 @@
   &--under-tabs {
     border-radius: 0 0 4px 4px;
   }
+
+  &--flexible {
+    box-sizing: border-box;
+    min-height: 100%;
+  }
 }
 
 .account-role {
diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index f843f0b42..c863e3b4f 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -240,3 +240,171 @@
     border-radius: 0;
   }
 }
+
+.page-header {
+  background: lighten($ui-base-color, 8%);
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  border-radius: 4px;
+  padding: 60px 15px;
+  text-align: center;
+  margin: 10px 0;
+
+  h1 {
+    color: $primary-text-color;
+    font-size: 36px;
+    line-height: 1.1;
+    font-weight: 700;
+    margin-bottom: 10px;
+  }
+
+  p {
+    font-size: 15px;
+    color: $darker-text-color;
+  }
+}
+
+.directory {
+  background: $ui-base-color;
+  border-radius: 0 0 4px 4px;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+  &__tag {
+    box-sizing: border-box;
+    margin-bottom: 10px;
+
+    a {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      background: $ui-base-color;
+      border-radius: 4px;
+      padding: 15px;
+      text-decoration: none;
+      color: inherit;
+      box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+      &:hover,
+      &:active,
+      &:focus {
+        background: lighten($ui-base-color, 8%);
+      }
+    }
+
+    &.active a {
+      background: $ui-highlight-color;
+      cursor: default;
+    }
+
+    h4 {
+      flex: 1 1 auto;
+      font-size: 18px;
+      font-weight: 700;
+      color: $primary-text-color;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+
+      .fa {
+        color: $darker-text-color;
+      }
+
+      small {
+        display: block;
+        font-weight: 400;
+        font-size: 15px;
+        margin-top: 8px;
+        color: $darker-text-color;
+      }
+    }
+
+    &.active h4 {
+      &,
+      .fa,
+      small {
+        color: $primary-text-color;
+      }
+    }
+
+    .avatar-stack {
+      flex: 0 0 auto;
+      width: (36px + 4px) * 3;
+    }
+
+    &.active .avatar-stack .account__avatar {
+      border-color: $ui-highlight-color;
+    }
+  }
+}
+
+.avatar-stack {
+  display: flex;
+  justify-content: flex-end;
+
+  .account__avatar {
+    flex: 0 0 auto;
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    position: relative;
+    margin-left: -10px;
+    border: 2px solid $ui-base-color;
+
+    &:nth-child(1) {
+      z-index: 1;
+    }
+
+    &:nth-child(2) {
+      z-index: 2;
+    }
+
+    &:nth-child(3) {
+      z-index: 3;
+    }
+  }
+}
+
+.accounts-table {
+  width: 100%;
+
+  .account {
+    padding: 0;
+    border: 0;
+  }
+
+  thead th {
+    text-align: center;
+    text-transform: uppercase;
+    color: $darker-text-color;
+    font-weight: 700;
+    padding: 10px;
+
+    &:first-child {
+      text-align: left;
+    }
+  }
+
+  tbody td {
+    padding: 15px 0;
+    vertical-align: middle;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
+  tbody tr:last-child td {
+    border-bottom: 0;
+  }
+
+  &__count {
+    width: 120px;
+    text-align: center;
+    font-size: 15px;
+    font-weight: 500;
+    color: $primary-text-color;
+
+    small {
+      display: block;
+      color: $darker-text-color;
+      font-weight: 400;
+      font-size: 14px;
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index 06effbdb2..63a5c61b8 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -189,6 +189,11 @@
   &--under-tabs {
     border-radius: 0 0 4px 4px;
   }
+
+  &--flexible {
+    box-sizing: border-box;
+    min-height: 100%;
+  }
 }
 
 .account-role {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 6f3f57265..308429573 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -876,7 +876,8 @@
   }
 }
 
-.status__relative-time {
+.status__relative-time,
+.notification__relative_time {
   color: $dark-text-color;
   float: right;
   font-size: 14px;
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index f843f0b42..c863e3b4f 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -240,3 +240,171 @@
     border-radius: 0;
   }
 }
+
+.page-header {
+  background: lighten($ui-base-color, 8%);
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+  border-radius: 4px;
+  padding: 60px 15px;
+  text-align: center;
+  margin: 10px 0;
+
+  h1 {
+    color: $primary-text-color;
+    font-size: 36px;
+    line-height: 1.1;
+    font-weight: 700;
+    margin-bottom: 10px;
+  }
+
+  p {
+    font-size: 15px;
+    color: $darker-text-color;
+  }
+}
+
+.directory {
+  background: $ui-base-color;
+  border-radius: 0 0 4px 4px;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+  &__tag {
+    box-sizing: border-box;
+    margin-bottom: 10px;
+
+    a {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      background: $ui-base-color;
+      border-radius: 4px;
+      padding: 15px;
+      text-decoration: none;
+      color: inherit;
+      box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+
+      &:hover,
+      &:active,
+      &:focus {
+        background: lighten($ui-base-color, 8%);
+      }
+    }
+
+    &.active a {
+      background: $ui-highlight-color;
+      cursor: default;
+    }
+
+    h4 {
+      flex: 1 1 auto;
+      font-size: 18px;
+      font-weight: 700;
+      color: $primary-text-color;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+
+      .fa {
+        color: $darker-text-color;
+      }
+
+      small {
+        display: block;
+        font-weight: 400;
+        font-size: 15px;
+        margin-top: 8px;
+        color: $darker-text-color;
+      }
+    }
+
+    &.active h4 {
+      &,
+      .fa,
+      small {
+        color: $primary-text-color;
+      }
+    }
+
+    .avatar-stack {
+      flex: 0 0 auto;
+      width: (36px + 4px) * 3;
+    }
+
+    &.active .avatar-stack .account__avatar {
+      border-color: $ui-highlight-color;
+    }
+  }
+}
+
+.avatar-stack {
+  display: flex;
+  justify-content: flex-end;
+
+  .account__avatar {
+    flex: 0 0 auto;
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    position: relative;
+    margin-left: -10px;
+    border: 2px solid $ui-base-color;
+
+    &:nth-child(1) {
+      z-index: 1;
+    }
+
+    &:nth-child(2) {
+      z-index: 2;
+    }
+
+    &:nth-child(3) {
+      z-index: 3;
+    }
+  }
+}
+
+.accounts-table {
+  width: 100%;
+
+  .account {
+    padding: 0;
+    border: 0;
+  }
+
+  thead th {
+    text-align: center;
+    text-transform: uppercase;
+    color: $darker-text-color;
+    font-weight: 700;
+    padding: 10px;
+
+    &:first-child {
+      text-align: left;
+    }
+  }
+
+  tbody td {
+    padding: 15px 0;
+    vertical-align: middle;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
+  tbody tr:last-child td {
+    border-bottom: 0;
+  }
+
+  &__count {
+    width: 120px;
+    text-align: center;
+    font-size: 15px;
+    font-weight: 500;
+    color: $primary-text-color;
+
+    small {
+      display: block;
+      color: $darker-text-color;
+      font-weight: 400;
+      font-size: 14px;
+    }
+  }
+}
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 0cdb178c1..a1b186f1c 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -27,7 +27,7 @@ class FeedManager
   end
 
   def push_to_home(account, status)
-    return false unless add_to_feed(:home, account.id, status)
+    return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
     trim(:home, account.id)
     PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}")
     true
@@ -46,7 +46,7 @@ class FeedManager
       should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
       return false if should_filter
     end
-    return false unless add_to_feed(:list, list.id, status)
+    return false unless add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
     trim(:list, list.id)
     PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
     true
@@ -94,7 +94,7 @@ class FeedManager
 
     query.each do |status|
       next if status.direct_visibility? || status.limited_visibility? || filter?(:home, status, into_account)
-      add_to_feed(:home, into_account.id, status)
+      add_to_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
     end
 
     trim(:home, into_account.id)
@@ -132,7 +132,7 @@ class FeedManager
 
       statuses.each do |status|
         next if filter_from_home?(status, account)
-        added += 1 if add_to_feed(:home, account.id, status)
+        added += 1 if add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
       end
 
       break unless added.zero?
@@ -231,11 +231,11 @@ class FeedManager
   # added, and false if it was not added to the feed. Note that this is
   # an internal helper: callers must call trim or push updates if
   # either action is appropriate.
-  def add_to_feed(timeline_type, account_id, status)
+  def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true)
     timeline_key = key(timeline_type, account_id)
     reblog_key   = key(timeline_type, account_id, 'reblogs')
 
-    if status.reblog?
+    if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
       # If the original status or a reblog of it is within
       # REBLOG_FALLOFF statuses from the top, do not re-insert it into
       # the feed
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index e5b168502..559e00d20 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -33,6 +33,7 @@ class UserSettingsDecorator
     user.settings['flavour']             = flavour_preference if change?('setting_flavour')
     user.settings['skin']                = skin_preference if change?('setting_skin')
     user.settings['hide_network']        = hide_network_preference if change?('setting_hide_network')
+    user.settings['aggregate_reblogs']   = aggregate_reblogs_preference if change?('setting_aggregate_reblogs')
   end
 
   def merged_notification_emails
@@ -107,6 +108,10 @@ class UserSettingsDecorator
     settings['setting_default_language']
   end
 
+  def aggregate_reblogs_preference
+    boolean_cast_setting 'setting_aggregate_reblogs'
+  end
+
   def boolean_cast_setting(key)
     ActiveModel::Type::Boolean.new.cast(settings[key])
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index e6b5bd69f..71264bc9f 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -43,11 +43,13 @@
 #  featured_collection_url :string
 #  fields                  :jsonb
 #  actor_type              :string
+#  discoverable            :boolean
 #
 
 class Account < ApplicationRecord
   USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
   MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
+  MIN_FOLLOWERS_DISCOVERY = 10
 
   include AccountAssociations
   include AccountAvatar
@@ -93,6 +95,10 @@ class Account < ApplicationRecord
   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
   scope :searchable, -> { where(suspended: false).where(moved_to_account_id: nil) }
+  scope :discoverable, -> { where(silenced: false).where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) }
+  scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
+  scope :popular, -> { order('account_stats.followers_count desc') }
+  scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) }
 
   delegate :email,
            :unconfirmed_email,
@@ -178,6 +184,40 @@ class Account < ApplicationRecord
     @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
 
+  def tags_as_strings=(tag_names)
+    tag_names.map! { |name| name.mb_chars.downcase.to_s }
+    tag_names.uniq!
+
+    # Existing hashtags
+    hashtags_map = Tag.where(name: tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag }
+
+    # Initialize not yet existing hashtags
+    tag_names.each do |name|
+      next if hashtags_map.key?(name)
+      hashtags_map[name] = Tag.new(name: name)
+    end
+
+    # Remove hashtags that are to be deleted
+    tags.each do |tag|
+      if hashtags_map.key?(tag.name)
+        hashtags_map.delete(tag.name)
+      else
+        transaction do
+          tags.delete(tag)
+          tag.decrement_count!(:accounts_count)
+        end
+      end
+    end
+
+    # Add hashtags that were so far missing
+    hashtags_map.each_value do |tag|
+      transaction do
+        tags << tag
+        tag.increment_count!(:accounts_count)
+      end
+    end
+  end
+
   def fields
     (self[:fields] || []).map { |f| Field.new(self, f) }
   end
diff --git a/app/models/account_stat.rb b/app/models/account_stat.rb
index d5715268e..9813aa84f 100644
--- a/app/models/account_stat.rb
+++ b/app/models/account_stat.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-
 # == Schema Information
 #
 # Table name: account_stats
@@ -11,16 +10,25 @@
 #  followers_count :bigint(8)        default(0), not null
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
+#  last_status_at  :datetime
 #
 
 class AccountStat < ApplicationRecord
   belongs_to :account, inverse_of: :account_stat
 
   def increment_count!(key)
-    update(key => public_send(key) + 1)
+    update(attributes_for_increment(key))
   end
 
   def decrement_count!(key)
     update(key => [public_send(key) - 1, 0].max)
   end
+
+  private
+
+  def attributes_for_increment(key)
+    attrs = { key => public_send(key) + 1 }
+    attrs[:last_status_at] = Time.now.utc if key == :statuses_count
+    attrs
+  end
 end
diff --git a/app/models/account_tag_stat.rb b/app/models/account_tag_stat.rb
new file mode 100644
index 000000000..3c36c155a
--- /dev/null
+++ b/app/models/account_tag_stat.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_tag_stats
+#
+#  id             :bigint(8)        not null, primary key
+#  tag_id         :bigint(8)        not null
+#  accounts_count :bigint(8)        default(0), not null
+#  hidden         :boolean          default(FALSE), not null
+#  created_at     :datetime         not null
+#  updated_at     :datetime         not null
+#
+
+class AccountTagStat < ApplicationRecord
+  belongs_to :tag, inverse_of: :account_tag_stat
+
+  def increment_count!(key)
+    update(key => public_send(key) + 1)
+  end
+
+  def decrement_count!(key)
+    update(key => [public_send(key) - 1, 0].max)
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 9dba8000d..38e2481c5 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -50,5 +50,8 @@ module AccountAssociations
 
     # Account migrations
     belongs_to :moved_to_account, class_name: 'Account', optional: true
+
+    # Hashtags
+    has_and_belongs_to_many :tags
   end
 end
diff --git a/app/models/concerns/account_counters.rb b/app/models/concerns/account_counters.rb
index fa3ec9a3d..3581df8dd 100644
--- a/app/models/concerns/account_counters.rb
+++ b/app/models/concerns/account_counters.rb
@@ -16,6 +16,7 @@ module AccountCounters
            :followers_count=,
            :increment_count!,
            :decrement_count!,
+           :last_status_at,
            to: :account_stat
 
   def account_stat
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 4f31f796e..99830ae92 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -11,12 +11,36 @@
 
 class Tag < ApplicationRecord
   has_and_belongs_to_many :statuses
+  has_and_belongs_to_many :accounts
+  has_and_belongs_to_many :sample_accounts, -> { searchable.discoverable.popular.limit(3) }, class_name: 'Account'
+
+  has_one :account_tag_stat, dependent: :destroy
 
   HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*'
   HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
 
   validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i }
 
+  scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
+  scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
+
+  delegate :accounts_count,
+           :accounts_count=,
+           :increment_count!,
+           :decrement_count!,
+           :hidden?,
+           to: :account_tag_stat
+
+  after_save :save_account_tag_stat
+
+  def account_tag_stat
+    super || build_account_tag_stat
+  end
+
+  def cached_sample_accounts
+    Rails.cache.fetch("#{cache_key}/sample_accounts", expires_in: 12.hours) { sample_accounts }
+  end
+
   def to_param
     name
   end
@@ -43,4 +67,11 @@ class Tag < ApplicationRecord
       Tag.where('lower(name) like lower(?)', pattern).order(:name).limit(limit)
     end
   end
+
+  private
+
+  def save_account_tag_stat
+    return unless account_tag_stat&.changed?
+    account_tag_stat.save
+  end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 704523d34..5a21419bf 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -95,7 +95,7 @@ class User < ApplicationRecord
 
   delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
            :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network,
-           :expand_spoilers, :default_language, to: :settings, prefix: :setting, allow_nil: false
+           :expand_spoilers, :default_language, :aggregate_reblogs, to: :settings, prefix: :setting, allow_nil: false
 
   attr_reader :invite_code
 
@@ -231,6 +231,10 @@ class User < ApplicationRecord
     @hides_network ||= settings.hide_network
   end
 
+  def aggregates_reblogs?
+    @aggregates_reblogs ||= settings.aggregate_reblogs
+  end
+
   def token_for_app(a)
     return nil if a.nil? || a.owner != self
     Doorkeeper::AccessToken
diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb
new file mode 100644
index 000000000..c63de01db
--- /dev/null
+++ b/app/policies/tag_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class TagPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def hide?
+    staff?
+  end
+
+  def unhide?
+    staff?
+  end
+end
diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb
index ec69d944a..36665177d 100644
--- a/app/services/update_account_service.rb
+++ b/app/services/update_account_service.rb
@@ -10,6 +10,7 @@ class UpdateAccountService < BaseService
 
       authorize_all_follow_requests(account) if was_locked && !account.locked
       check_links(account)
+      process_hashtags(account)
     end
   end
 
@@ -24,4 +25,8 @@ class UpdateAccountService < BaseService
   def check_links(account)
     VerifyAccountLinksWorker.perform_async(account.id)
   end
+
+  def process_hashtags(account)
+    account.tags_as_strings = Extractor.extract_hashtags(account.note)
+  end
 end
diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml
new file mode 100644
index 000000000..961b83f93
--- /dev/null
+++ b/app/views/admin/tags/_tag.html.haml
@@ -0,0 +1,12 @@
+%tr
+  %td
+    = link_to explore_hashtag_path(tag) do
+      = fa_icon 'hashtag'
+      = tag.name
+  %td
+    = t('directories.people', count: tag.accounts_count)
+  %td
+    - if tag.hidden?
+      = table_link_to 'eye', t('admin.tags.unhide'), unhide_admin_tag_path(tag.id, **@filter_params), method: :post
+    - else
+      = table_link_to 'eye-slash', t('admin.tags.hide'), hide_admin_tag_path(tag.id, **@filter_params), method: :post
diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml
new file mode 100644
index 000000000..4ba395860
--- /dev/null
+++ b/app/views/admin/tags/index.html.haml
@@ -0,0 +1,19 @@
+- content_for :page_title do
+  = t('admin.tags.title')
+
+.filters
+  .filter-subset
+    %strong= t('admin.reports.status')
+    %ul
+      %li= filter_link_to t('admin.tags.visible'), hidden: nil
+      %li= filter_link_to t('admin.tags.hidden'), hidden: '1'
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.tags.name')
+        %th= t('admin.tags.accounts')
+        %th
+    %tbody
+      = render @tags
diff --git a/app/views/application/_card.html.haml b/app/views/application/_card.html.haml
index 9cf8f8ff2..e6059b035 100644
--- a/app/views/application/_card.html.haml
+++ b/app/views/application/_card.html.haml
@@ -9,6 +9,7 @@
         = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
 
       .display-name
+        %span{id: "default_account_display_name", style: "display:none;"}= account.username
         %bdi
           %strong.emojify.p-name= display_name(account, custom_emojify: true)
         %span
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
new file mode 100644
index 000000000..f70eb964a
--- /dev/null
+++ b/app/views/directories/index.html.haml
@@ -0,0 +1,61 @@
+- content_for :page_title do
+  = t('directories.explore_mastodon', title: site_title)
+
+- content_for :header_tags do
+  %meta{ name: 'description', content: t('directories.explanation') }
+
+  = opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname)
+  = opengraph 'og:type', 'website'
+  = opengraph 'og:title', t('directories.explore_mastodon', title: site_title)
+  = opengraph 'og:description', t('directories.explanation')
+  = opengraph 'og:image', File.join(root_url, 'android-chrome-192x192.png')
+
+.page-header
+  %h1= t('directories.explore_mastodon', title: site_title)
+  %p= t('directories.explanation')
+
+.grid
+  .column-0
+    .account__section-headline
+      = active_link_to t('directories.most_recently_active'), @tag ? explore_hashtag_path(@tag) : explore_path
+      = active_link_to t('directories.most_popular'), @tag ? explore_hashtag_popular_path(@tag) : explore_popular_path
+
+    - if @accounts.empty?
+      = nothing_here
+    - else
+      .directory
+        %table.accounts-table
+          %tbody
+            - @accounts.each do |account|
+              %tr
+                %td= account_link_to account
+                %td.accounts-table__count
+                  = number_to_human account.statuses_count, strip_insignificant_zeros: true
+                  %small= t('accounts.posts', count: account.statuses_count).downcase
+                %td.accounts-table__count
+                  = number_to_human account.followers_count, strip_insignificant_zeros: true
+                  %small= t('accounts.followers', count: account.followers_count).downcase
+                %td.accounts-table__count
+                  - 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
+                    \-
+                  %small= t('accounts.last_active')
+
+      = paginate @accounts
+
+  .column-1
+    - if @tags.empty?
+      .nothing-here.nothing-here--flexible
+    - else
+      - @tags.each do |tag|
+        .directory__tag{ class: tag.id == @tag&.id ? 'active' : nil }
+          = link_to explore_hashtag_path(tag) do
+            %h4
+              = fa_icon 'hashtag'
+              = tag.name
+              %small= t('directories.people', count: tag.accounts_count)
+
+            .avatar-stack
+              - tag.cached_sample_accounts.each do |account|
+                = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index fd5c67a99..ee49ed06c 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -5,6 +5,10 @@
         .nav-left
           = link_to root_url, class: 'brand' do
             = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
+
+          = link_to t('directories.directory'), explore_path, class: 'nav-link'
+          = link_to t('about.about_this'), about_more_path, class: 'nav-link'
+          = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link'
         .nav-center
         .nav-right
           - if user_signed_in?
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 45f9fd178..53390b6d1 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -43,6 +43,9 @@
     = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
 
   .fields-group
+    = f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label
+
+  .fields-group
     = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label
     = f.input :setting_display_media, collection: ['default', 'show_all', 'hide_all'], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.defaults.setting_display_media_#{item}"), content_tag(:span, t("simple_form.hints.defaults.setting_display_media_#{item}"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
     = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 2ba236fb5..212c6cb44 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -18,7 +18,6 @@
 
       = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT))
 
-
   %hr.spacer/
 
   .fields-group
@@ -27,6 +26,9 @@
   .fields-group
     = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
 
+  .fields-group
+    = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable_html', min_followers: Account::MIN_FOLLOWERS_DISCOVERY, path: explore_path)
+
   %hr.spacer/
 
   .fields-row
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 58cf2a101..8e0b5f75a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -48,6 +48,7 @@ en:
       other: Followers
     following: Following
     joined: Joined %{date}
+    last_active: last active
     link_verified_on: Ownership of this link was checked on %{date}
     media: Media
     moved_html: "%{name} has moved to %{new_profile_link}:"
@@ -114,6 +115,7 @@ en:
       media_attachments: Media attachments
       memorialize: Turn into memoriam
       moderation:
+        active: Active
         all: All
         silenced: Silenced
         suspended: Suspended
@@ -439,6 +441,14 @@ en:
       proceed: Proceed
       title: Suspend %{acct}
       warning_html: 'Suspending this account will <strong>irreversibly</strong> delete data from this account, which includes:'
+    tags:
+      accounts: Accounts
+      hidden: Hidden
+      hide: Hide from directory
+      name: Hashtag
+      title: Hashtags
+      unhide: Show in directory
+      visible: Visible
     title: Administration
   admin_mailer:
     new_report:
@@ -517,6 +527,15 @@ en:
     success_msg: Your account was successfully deleted
     warning_html: Only deletion of content from this particular instance 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
+  directories:
+    directory: Profile directory
+    explanation: Discover users based on their interests
+    explore_mastodon: Explore %{title}
+    most_popular: Most popular
+    most_recently_active: Most recently active
+    people:
+      one: "%{count} person"
+      other: "%{count} people"
   errors:
     '403': You don't have permission to view this page.
     '404': The page you were looking for doesn't exist.
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index f6958b4e1..14b1d8087 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -54,6 +54,7 @@ pl:
       other: Śledzących
     following: Śledzonych
     joined: Dołączył(a) %{date}
+    last_active: ostatnio aktywny(-a)
     link_verified_on: Własność tego odnośnika została sprawdzona %{date}
     media: Zawartość multimedialna
     moved_html: "%{name} korzysta teraz z konta %{new_profile_link}:"
@@ -122,6 +123,7 @@ pl:
       media_attachments: Załączniki multimedialne
       memorialize: Przełącz na „In Memoriam”
       moderation:
+        active: Aktywne
         all: Wszystkie
         silenced: Wyciszone
         suspended: Zawieszone
@@ -445,6 +447,14 @@ pl:
       proceed: Przejdź
       title: Zawieś %{acct}
       warning_html: 'Zawieszenie konta będzie skutkowało <strong>nieodwracalnym</strong> usunięciem danych z tego konta, wliczając:'
+    tags:
+      accounts: Konta
+      hidden: Ukryte
+      hide: Ukryj w katalogu
+      name: Hashtag
+      title: Hashtagi
+      unhide: Pokazuj w katalogu
+      visible: Widoczne
     title: Administracja
   admin_mailer:
     new_report:
@@ -523,6 +533,17 @@ pl:
     success_msg: Twoje konto zostało pomyślnie usunięte
     warning_html: Możemy usunąć zawartość jedynie w obrębie tej instancji. Zawartość udostępniona publicznie pozostawia trwałe ślady. Serwery niepodłączone do sieci bądź nieśledzące Twoich aktualizacji mogą zachować Twoje dane.
     warning_title: Dostępność usuniętej zawartości
+  directories:
+    directory: Katalog profilów
+    explanation: Poznaj profile na podstawie zainteresowań
+    explore_mastodon: Odkrywaj %{title}
+    most_popular: Napopularniejsi
+    most_recently_active: Ostatnio aktywni
+    people:
+      few: "%{count} osoby"
+      many: "%{count} osób"
+      one: "%{count} osoba"
+      other: "%{count} osób"
   errors:
     '403': Nie masz uprawnień, aby wyświetlić tę stronę.
     '404': Strona, którą próbujesz odwiedzić, nie istnieje.
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index e37952923..ed6e6faa0 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -8,6 +8,7 @@ en:
         bot: This account mainly performs automated actions and might not be monitored
         context: One or multiple contexts where the filter should apply
         digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
+        discoverable_html: The <a href="%{path}" target="_blank">directory</a> lets people find accounts based on interests and activity. Requires at least %{min_followers} followers
         email: You will be sent a confirmation e-mail
         fields: You can have up to 4 items displayed as a table on your profile
         header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
@@ -18,6 +19,7 @@ en:
         password: Use at least 8 characters
         phrase: Will be matched regardless of casing in text or content warning of a toot
         scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones.
+        setting_aggregate_reblogs: Do not show new boosts for toots that have been recently boosted (only affects newly-received boosts)
         setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
         setting_display_media_default: Hide media marked as sensitive
         setting_display_media_hide_all: Always hide all media
@@ -48,6 +50,7 @@ en:
         context: Filter contexts
         current_password: Current password
         data: Data
+        discoverable: List this account on the directory
         display_name: Display name
         email: E-mail address
         expires_in: Expire after
@@ -63,6 +66,7 @@ en:
         otp_attempt: Two-factor code
         password: Password
         phrase: Keyword or phrase
+        setting_aggregate_reblogs: Group boosts in timelines
         setting_auto_play_gif: Auto-play animated GIFs
         setting_boost_modal: Show confirmation dialog before boosting
         setting_default_language: Posting language
diff --git a/config/locales/simple_form.pl.yml b/config/locales/simple_form.pl.yml
index b4c876826..23dd04481 100644
--- a/config/locales/simple_form.pl.yml
+++ b/config/locales/simple_form.pl.yml
@@ -8,6 +8,7 @@ pl:
         bot: To konto wykonuje głównie zautomatyzowane działania i może nie być monitorowane
         context: Jedno lub wiele miejsc, w których filtr zostanie zastosowany
         digest: Wysyłane tylko po długiej nieaktywności, jeżeli w tym czasie otrzymaleś jakąś wiadomość bezpośrednią
+        discoverable_html: <a href="%{path}" target="_blank">Katalog</a> pozwala znaleźć konta na podstawie zainteresowań i aktywności. Profil musi śledzić przynajmniej %{min_followers} osób
         fields: Możesz ustawić maksymalnie 4 niestandardowe pola wyświetlane jako tabela na Twoim profilu
         header: PNG, GIF lub JPG. Maksymalnie %{size}. Zostanie zmniejszony do %{dimensions}px
         inbox_url: Skopiuj adres ze strony głównej przekaźnika, którego chcesz użyć
@@ -42,6 +43,7 @@ pl:
         context: Filtruj zawartość
         current_password: Obecne hasło
         data: Dane
+        discoverable: Wyświetlaj ten profil w katalogu
         display_name: Widoczna nazwa
         email: Adres e-mail
         expires_in: Wygaśnie po
diff --git a/config/navigation.rb b/config/navigation.rb
index 6fed173ef..b90e44964 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -34,6 +34,7 @@ SimpleNavigation::Configuration.run do |navigation|
       admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports}
       admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts}
       admin.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
+      admin.item :tags, safe_join([fa_icon('tag fw'), t('admin.tags.title')]), admin_tags_path
       admin.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances}, if: -> { current_user.admin? }
       admin.item :domain_blocks, safe_join([fa_icon('lock fw'), t('admin.domain_blocks.title')]), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks}, if: -> { current_user.admin? }
       admin.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
diff --git a/config/routes.rb b/config/routes.rb
index 446aad58b..6b33d8baf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -80,6 +80,11 @@ Rails.application.routes.draw do
   get  '/interact/:id', to: 'remote_interaction#new', as: :remote_interaction
   post '/interact/:id', to: 'remote_interaction#create'
 
+  get '/explore', to: 'directories#index', as: :explore
+  get '/explore/popular', to: 'directories#index', as: :explore_popular
+  get '/explore/:id', to: 'directories#show', as: :explore_hashtag
+  get '/explore/:id/popular', to: 'directories#show', as: :explore_hashtag_popular
+
   namespace :settings do
     resource :profile, only: [:show, :update]
 
@@ -210,6 +215,13 @@ Rails.application.routes.draw do
     end
 
     resources :account_moderation_notes, only: [:create, :destroy]
+
+    resources :tags, only: [:index] do
+      member do
+        post :hide
+        post :unhide
+      end
+    end
   end
 
   get '/admin', to: redirect('/admin/dashboard', status: 302)
diff --git a/config/settings.yml b/config/settings.yml
index 48639e9a8..bfccd2cc7 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -35,6 +35,7 @@ defaults: &defaults
   noindex: false
   flavour: 'glitch'
   skin: 'default'
+  aggregate_reblogs: true
   notification_emails:
     follow: false
     reblog: false
diff --git a/db/migrate/20181203003808_create_accounts_tags_join_table.rb b/db/migrate/20181203003808_create_accounts_tags_join_table.rb
new file mode 100644
index 000000000..3c275c2b7
--- /dev/null
+++ b/db/migrate/20181203003808_create_accounts_tags_join_table.rb
@@ -0,0 +1,8 @@
+class CreateAccountsTagsJoinTable < ActiveRecord::Migration[5.2]
+  def change
+    create_join_table :accounts, :tags do |t|
+      t.index [:account_id, :tag_id]
+      t.index [:tag_id, :account_id], unique: true
+    end
+  end
+end
diff --git a/db/migrate/20181203021853_add_discoverable_to_accounts.rb b/db/migrate/20181203021853_add_discoverable_to_accounts.rb
new file mode 100644
index 000000000..5bbae2203
--- /dev/null
+++ b/db/migrate/20181203021853_add_discoverable_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddDiscoverableToAccounts < ActiveRecord::Migration[5.2]
+  def change
+    add_column :accounts, :discoverable, :boolean
+  end
+end
diff --git a/db/migrate/20181204193439_add_last_status_at_to_account_stats.rb b/db/migrate/20181204193439_add_last_status_at_to_account_stats.rb
new file mode 100644
index 000000000..946662707
--- /dev/null
+++ b/db/migrate/20181204193439_add_last_status_at_to_account_stats.rb
@@ -0,0 +1,5 @@
+class AddLastStatusAtToAccountStats < ActiveRecord::Migration[5.2]
+  def change
+    add_column :account_stats, :last_status_at, :datetime
+  end
+end
diff --git a/db/migrate/20181204215309_create_account_tag_stats.rb b/db/migrate/20181204215309_create_account_tag_stats.rb
new file mode 100644
index 000000000..15ed8587e
--- /dev/null
+++ b/db/migrate/20181204215309_create_account_tag_stats.rb
@@ -0,0 +1,11 @@
+class CreateAccountTagStats < ActiveRecord::Migration[5.2]
+  def change
+    create_table :account_tag_stats do |t|
+      t.belongs_to :tag, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
+      t.bigint :accounts_count, default: 0, null: false
+      t.boolean :hidden, default: false, null: false
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4db687137..6e30ed120 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: 2018_11_27_165847) do
+ActiveRecord::Schema.define(version: 2018_12_04_215309) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -63,9 +63,19 @@ ActiveRecord::Schema.define(version: 2018_11_27_165847) do
     t.bigint "followers_count", default: 0, null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
+    t.datetime "last_status_at"
     t.index ["account_id"], name: "index_account_stats_on_account_id", unique: true
   end
 
+  create_table "account_tag_stats", force: :cascade do |t|
+    t.bigint "tag_id", null: false
+    t.bigint "accounts_count", default: 0, null: false
+    t.boolean "hidden", default: false, null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["tag_id"], name: "index_account_tag_stats_on_tag_id", unique: true
+  end
+
   create_table "accounts", force: :cascade do |t|
     t.string "username", default: "", null: false
     t.string "domain"
@@ -106,6 +116,7 @@ ActiveRecord::Schema.define(version: 2018_11_27_165847) do
     t.string "featured_collection_url"
     t.jsonb "fields"
     t.string "actor_type"
+    t.boolean "discoverable"
     t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
     t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
     t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id"
@@ -113,6 +124,13 @@ ActiveRecord::Schema.define(version: 2018_11_27_165847) do
     t.index ["url"], name: "index_accounts_on_url"
   end
 
+  create_table "accounts_tags", id: false, force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.bigint "tag_id", null: false
+    t.index ["account_id", "tag_id"], name: "index_accounts_tags_on_account_id_and_tag_id"
+    t.index ["tag_id", "account_id"], name: "index_accounts_tags_on_tag_id_and_account_id", unique: true
+  end
+
   create_table "admin_action_logs", force: :cascade do |t|
     t.bigint "account_id"
     t.string "action", default: "", null: false
@@ -649,6 +667,7 @@ ActiveRecord::Schema.define(version: 2018_11_27_165847) do
   add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade
   add_foreign_key "account_pins", "accounts", on_delete: :cascade
   add_foreign_key "account_stats", "accounts", on_delete: :cascade
+  add_foreign_key "account_tag_stats", "tags", on_delete: :cascade
   add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
   add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
   add_foreign_key "backups", "users", on_delete: :nullify
diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb
index 5c12fea7d..4055d9342 100644
--- a/spec/controllers/activitypub/inboxes_controller_spec.rb
+++ b/spec/controllers/activitypub/inboxes_controller_spec.rb
@@ -1,7 +1,29 @@
+# frozen_string_literal: true
+
 require 'rails_helper'
 
 RSpec.describe ActivityPub::InboxesController, type: :controller do
   describe 'POST #create' do
-    pending
+    context 'if signed_request_account' do
+      it 'returns 202' do
+        allow(controller).to receive(:signed_request_account) do
+          Fabricate(:account)
+        end
+
+        post :create
+        expect(response).to have_http_status(202)
+      end
+    end
+
+    context 'not signed_request_account' do
+      it 'returns 401' do
+        allow(controller).to receive(:signed_request_account) do
+          false
+        end
+
+        post :create
+        expect(response).to have_http_status(401)
+      end
+    end
   end
 end
diff --git a/spec/fabricators/account_tag_stat_fabricator.rb b/spec/fabricators/account_tag_stat_fabricator.rb
new file mode 100644
index 000000000..9edb550be
--- /dev/null
+++ b/spec/fabricators/account_tag_stat_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:account_tag_stat) do
+  accounts_count ""
+end
diff --git a/spec/helpers/admin/account_moderation_notes_helper_spec.rb b/spec/helpers/admin/account_moderation_notes_helper_spec.rb
index 01b60c851..c07f6c4b8 100644
--- a/spec/helpers/admin/account_moderation_notes_helper_spec.rb
+++ b/spec/helpers/admin/account_moderation_notes_helper_spec.rb
@@ -1,15 +1,55 @@
+# frozen_string_literal: true
+
 require 'rails_helper'
 
-# Specs in this file have access to a helper object that includes
-# the Admin::AccountModerationNotesHelper. For example:
-#
-# describe Admin::AccountModerationNotesHelper do
-#   describe "string concat" do
-#     it "concats two strings with spaces" do
-#       expect(helper.concat_strings("this","that")).to eq("this that")
-#     end
-#   end
-# end
 RSpec.describe Admin::AccountModerationNotesHelper, type: :helper do
-  pending "add some examples to (or delete) #{__FILE__}"
+  include StreamEntriesHelper
+
+  describe '#admin_account_link_to' do
+    context 'account is nil' do
+      let(:account) { nil }
+
+      it 'returns nil' do
+        expect(helper.admin_account_link_to(account)).to be_nil
+      end
+    end
+
+    context 'with account' do
+      let(:account) { Fabricate(:account) }
+
+      it 'calls #link_to' do
+        expect(helper).to receive(:link_to).with(
+          admin_account_path(account.id),
+          class: name_tag_classes(account),
+          title: account.acct
+        )
+
+        helper.admin_account_link_to(account)
+      end
+    end
+  end
+
+  describe '#admin_account_inline_link_to' do
+    context 'account is nil' do
+      let(:account) { nil }
+
+      it 'returns nil' do
+        expect(helper.admin_account_inline_link_to(account)).to be_nil
+      end
+    end
+
+    context 'with account' do
+      let(:account) { Fabricate(:account) }
+
+      it 'calls #link_to' do
+        expect(helper).to receive(:link_to).with(
+          admin_account_path(account.id),
+          class: name_tag_classes(account, true),
+          title: account.acct
+        )
+
+        helper.admin_account_inline_link_to(account)
+      end
+    end
+  end
 end
diff --git a/spec/models/account_pin_spec.rb b/spec/models/account_pin_spec.rb
deleted file mode 100644
index 4f226b127..000000000
--- a/spec/models/account_pin_spec.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe AccountPin, type: :model do
-  pending "add some examples to (or delete) #{__FILE__}"
-end
diff --git a/spec/models/account_tag_stat_spec.rb b/spec/models/account_tag_stat_spec.rb
new file mode 100644
index 000000000..6d3057f35
--- /dev/null
+++ b/spec/models/account_tag_stat_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe AccountTagStat, type: :model do
+  key = 'accounts_count'
+  let(:account_tag_stat) { Fabricate(:tag).account_tag_stat }
+
+  describe '#increment_count!' do
+    it 'calls #update' do
+      args = { key => account_tag_stat.public_send(key) + 1 }
+      expect(account_tag_stat).to receive(:update).with(args)
+      account_tag_stat.increment_count!(key)
+    end
+
+    it 'increments value by 1' do
+      expect do
+        account_tag_stat.increment_count!(key)
+      end.to change { account_tag_stat.accounts_count }.by(1)
+    end
+  end
+
+  describe '#decrement_count!' do
+    it 'calls #update' do
+      args = { key => [account_tag_stat.public_send(key) - 1, 0].max }
+      expect(account_tag_stat).to receive(:update).with(args)
+      account_tag_stat.decrement_count!(key)
+    end
+
+    it 'decrements value by 1' do
+      account_tag_stat.update(key => 1)
+
+      expect do
+        account_tag_stat.decrement_count!(key)
+      end.to change { account_tag_stat.accounts_count }.by(-1)
+    end
+  end
+end
diff --git a/spec/validators/unique_username_validator_spec.rb b/spec/validators/unique_username_validator_spec.rb
index b9d773bed..c2e2eedf4 100644
--- a/spec/validators/unique_username_validator_spec.rb
+++ b/spec/validators/unique_username_validator_spec.rb
@@ -15,14 +15,6 @@ describe UniqueUsernameValidator do
       expect(account).to be_valid
     end
 
-    it 'adds an error when the username is already used with ignoring dots' do
-      pending 'allowing dots in username is still in development'
-      Fabricate(:account, username: 'abcd.ef')
-      account = double(username: 'ab.cdef', persisted?: false, errors: double(add: nil))
-      subject.validate(account)
-      expect(account.errors).to have_received(:add)
-    end
-
     it 'adds an error when the username is already used with ignoring cases' do
       Fabricate(:account, username: 'ABCdef')
       account = double(username: 'abcDEF', persisted?: false, errors: double(add: nil))