about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2020-09-14 13:49:06 -0500
committerStarfall <us@starfall.systems>2020-09-14 13:49:06 -0500
commitbc32e736fbd7c6afd59e619c885fa80ab637324a (patch)
treeafc04eaa04aeb45b56c378bdc474428d6fdcc813 /app
parentfd0b806603dbd632d259d06af789c7195c7264dc (diff)
parentafa753a890197b188ddf7ba6a8493d4fe361d956 (diff)
Merge branch 'glitch' into main
Diffstat (limited to 'app')
-rw-r--r--app/controllers/activitypub/collections_controller.rb32
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb20
-rw-r--r--app/controllers/api/v1/accounts/featured_tags_controller.rb22
-rw-r--r--app/controllers/api/v1/featured_tags/suggestions_controller.rb8
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb32
-rw-r--r--app/controllers/api/v1/timelines/tag_controller.rb34
-rw-r--r--app/controllers/instance_actors_controller.rb2
-rw-r--r--app/controllers/settings/featured_tags_controller.rb8
-rw-r--r--app/controllers/tags_controller.rb31
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss1
-rw-r--r--app/javascript/mastodon/actions/accounts.js37
-rw-r--r--app/javascript/mastodon/actions/statuses.js64
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss8
-rw-r--r--app/lib/activitypub/adapter.rb2
-rw-r--r--app/lib/feed_manager.rb272
-rw-r--r--app/models/public_feed.rb104
-rw-r--r--app/models/status.rb66
-rw-r--r--app/models/tag.rb2
-rw-r--r--app/models/tag_feed.rb58
-rw-r--r--app/serializers/activitypub/actor_serializer.rb8
-rw-r--r--app/serializers/activitypub/collection_serializer.rb2
-rw-r--r--app/serializers/activitypub/hashtag_serializer.rb23
-rw-r--r--app/serializers/rest/account_featured_tag_serializer.rb19
-rw-r--r--app/services/after_block_service.rb2
-rw-r--r--app/services/hashtag_query_service.rb22
-rw-r--r--app/services/notify_service.rb6
-rw-r--r--app/services/precompute_feed_service.rb2
-rw-r--r--app/views/settings/featured_tags/index.html.haml2
-rw-r--r--app/workers/feed_insert_worker.rb10
-rw-r--r--app/workers/merge_worker.rb4
-rw-r--r--app/workers/mute_worker.rb7
-rw-r--r--app/workers/unmerge_worker.rb4
32 files changed, 570 insertions, 344 deletions
diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
index e62fba748..00f3d3cba 100644
--- a/app/controllers/activitypub/collections_controller.rb
+++ b/app/controllers/activitypub/collections_controller.rb
@@ -12,7 +12,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
 
   def show
     expires_in 3.minutes, public: public_fetch_mode?
-    render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, skip_activities: true
+    render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
   end
 
   private
@@ -20,17 +20,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
   def set_items
     case params[:id]
     when 'featured'
-      @items = begin
-        # Because in public fetch mode we cache the response, there would be no
-        # benefit from performing the check below, since a blocked account or domain
-        # would likely be served the cache from the reverse proxy anyway
-
-        if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
-          []
-        else
-          cache_collection(@account.pinned_statuses.not_local_only, Status)
-        end
-      end
+      @items = for_signed_account { cache_collection(@account.pinned_statuses.not_local_only, Status) }
+    when 'tags'
+      @items = for_signed_account { @account.featured_tags }
     when 'devices'
       @items = @account.devices
     else
@@ -40,7 +32,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
 
   def set_size
     case params[:id]
-    when 'featured', 'devices'
+    when 'featured', 'devices', 'tags'
       @size = @items.size
     else
       not_found
@@ -51,7 +43,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
     case params[:id]
     when 'featured'
       @type = :ordered
-    when 'devices'
+    when 'devices', 'tags'
       @type = :unordered
     else
       not_found
@@ -66,4 +58,16 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
       items: @items
     )
   end
+
+  def for_signed_account
+    # Because in public fetch mode we cache the response, there would be no
+    # benefit from performing the check below, since a blocked account or domain
+    # would likely be served the cache from the reverse proxy anyway
+
+    if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
+      []
+    else
+      yield
+    end
+  end
 end
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index c33c15255..e066860bf 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -20,9 +20,9 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
   def outbox_presenter
     if page_requested?
       ActivityPub::CollectionPresenter.new(
-        id: account_outbox_url(@account, page_params),
+        id: outbox_url(page_params),
         type: :ordered,
-        part_of: account_outbox_url(@account),
+        part_of: outbox_url,
         prev: prev_page,
         next: next_page,
         items: @statuses
@@ -32,12 +32,20 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
         id: account_outbox_url(@account),
         type: :ordered,
         size: @account.statuses_count,
-        first: account_outbox_url(@account, page: true),
-        last: account_outbox_url(@account, page: true, min_id: 0)
+        first: outbox_url(page: true),
+        last: outbox_url(page: true, min_id: 0)
       )
     end
   end
 
+  def outbox_url(**kwargs)
+    if params[:account_username].present?
+      account_outbox_url(@account, **kwargs)
+    else
+      instance_actor_outbox_url(**kwargs)
+    end
+  end
+
   def next_page
     account_outbox_url(@account, page: true, max_id: @statuses.last.id) if @statuses.size == LIMIT
   end
@@ -65,4 +73,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
   def page_params
     { page: true, max_id: params[:max_id], min_id: params[:min_id] }.compact
   end
+
+  def set_account
+    @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
+  end
 end
diff --git a/app/controllers/api/v1/accounts/featured_tags_controller.rb b/app/controllers/api/v1/accounts/featured_tags_controller.rb
new file mode 100644
index 000000000..d6277261d
--- /dev/null
+++ b/app/controllers/api/v1/accounts/featured_tags_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
+  before_action :set_account
+  before_action :set_featured_tags
+
+  respond_to :json
+
+  def index
+    render json: @featured_tags, each_serializer: REST::AccountFeaturedTagSerializer
+  end
+
+  private
+
+  def set_account
+    @account = Account.find(params[:account_id])
+  end
+
+  def set_featured_tags
+    @featured_tags = @account.featured_tags
+  end
+end
diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb
index 8c1b81a0f..75545d3c7 100644
--- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb
+++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb
@@ -3,15 +3,15 @@
 class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
   before_action :require_user!
-  before_action :set_most_used_tags, only: :index
+  before_action :set_recently_used_tags, only: :index
 
   def index
-    render json: @most_used_tags, each_serializer: REST::TagSerializer
+    render json: @recently_used_tags, each_serializer: REST::TagSerializer
   end
 
   private
 
-  def set_most_used_tags
-    @most_used_tags = Tag.most_used(current_account).where.not(id: current_account.featured_tags).limit(10)
+  def set_recently_used_tags
+    @recently_used_tags = Tag.recently_used(current_account).where.not(id: current_account.featured_tags).limit(10)
   end
 end
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index 52b5cb323..fbd99667c 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -20,28 +20,26 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def cached_public_statuses_page
-    cache_collection_paginated_by_id(
-      public_statuses,
-      Status,
-      limit_param(DEFAULT_STATUSES_LIMIT),
-      params_slice(:max_id, :since_id, :min_id)
-    )
+    cache_collection(public_statuses, Status)
   end
 
   def public_statuses
-    statuses = public_timeline_statuses
-
-    statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only)
-
-    if truthy_param?(:only_media)
-      statuses.joins(:media_attachments).group(:id)
-    else
-      statuses
-    end
+    public_feed.get(
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id],
+      params[:min_id]
+    )
   end
 
-  def public_timeline_statuses
-    Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local))
+  def public_feed
+    PublicFeed.new(
+      current_account,
+      local: truthy_param?(:local),
+      remote: truthy_param?(:remote),
+      only_media: truthy_param?(:only_media),
+      allow_local_only: truthy_param?(:allow_local_only)
+    )
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 76f7d3590..64a1db58d 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -20,23 +20,29 @@ class Api::V1::Timelines::TagController < Api::BaseController
   end
 
   def cached_tagged_statuses
-    if @tag.nil?
-      []
-    else
-      statuses = tag_timeline_statuses
-      statuses = statuses.joins(:media_attachments) if truthy_param?(:only_media)
-
-      cache_collection_paginated_by_id(
-        statuses,
-        Status,
-        limit_param(DEFAULT_STATUSES_LIMIT),
-        params_slice(:max_id, :since_id, :min_id)
-      )
-    end
+    @tag.nil? ? [] : cache_collection(tag_timeline_statuses, Status)
   end
 
   def tag_timeline_statuses
-    HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, truthy_param?(:local))
+    tag_feed.get(
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id],
+      params[:min_id]
+    )
+  end
+
+  def tag_feed
+    TagFeed.new(
+      @tag,
+      current_account,
+      any: params[:any],
+      all: params[:all],
+      none: params[:none],
+      local: truthy_param?(:local),
+      remote: truthy_param?(:remote),
+      only_media: truthy_param?(:only_media)
+    )
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb
index 6f02d6a35..4b074ca19 100644
--- a/app/controllers/instance_actors_controller.rb
+++ b/app/controllers/instance_actors_controller.rb
@@ -17,6 +17,6 @@ class InstanceActorsController < ApplicationController
   end
 
   def restrict_fields_to
-    %i(id type preferred_username inbox public_key endpoints url manually_approves_followers)
+    %i(id type preferred_username inbox outbox public_key endpoints url manually_approves_followers)
   end
 end
diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb
index 3a3241425..e9861da56 100644
--- a/app/controllers/settings/featured_tags_controller.rb
+++ b/app/controllers/settings/featured_tags_controller.rb
@@ -6,7 +6,7 @@ class Settings::FeaturedTagsController < Settings::BaseController
   before_action :authenticate_user!
   before_action :set_featured_tags, only: :index
   before_action :set_featured_tag, except: [:index, :create]
-  before_action :set_most_used_tags, only: :index
+  before_action :set_recently_used_tags, only: :index
 
   def index
     @featured_tag = FeaturedTag.new
@@ -20,7 +20,7 @@ class Settings::FeaturedTagsController < Settings::BaseController
       redirect_to settings_featured_tags_path
     else
       set_featured_tags
-      set_most_used_tags
+      set_recently_used_tags
 
       render :index
     end
@@ -41,8 +41,8 @@ class Settings::FeaturedTagsController < Settings::BaseController
     @featured_tags = current_account.featured_tags.order(statuses_count: :desc).reject(&:new_record?)
   end
 
-  def set_most_used_tags
-    @most_used_tags = Tag.most_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10)
+  def set_recently_used_tags
+    @recently_used_tags = Tag.recently_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10)
   end
 
   def featured_tag_params
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 69db89eb3..64736e77f 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -10,8 +10,9 @@ class TagsController < ApplicationController
 
   before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
   before_action :authenticate_user!, if: :whitelist_mode?
-  before_action :set_tag
   before_action :set_local
+  before_action :set_tag
+  before_action :set_statuses
   before_action :set_body_classes
   before_action :set_instance_presenter
 
@@ -26,20 +27,11 @@ class TagsController < ApplicationController
 
       format.rss do
         expires_in 0, public: true
-
-        limit     = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
-        @statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(limit)
-        @statuses = cache_collection(@statuses, Status)
-
         render xml: RSS::TagSerializer.render(@tag, @statuses)
       end
 
       format.json do
         expires_in 3.minutes, public: public_fetch_mode?
-
-        @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local).paginate_by_max_id(PAGE_SIZE, params[:max_id])
-        @statuses = cache_collection(@statuses, Status)
-
         render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
       end
     end
@@ -55,6 +47,15 @@ class TagsController < ApplicationController
     @local = truthy_param?(:local)
   end
 
+  def set_statuses
+    case request.format&.to_sym
+    when :json
+      @statuses = cache_collection(TagFeed.new(@tag, current_account, local: @local).get(PAGE_SIZE, params[:max_id], params[:since_id], params[:min_id]), Status)
+    when :rss
+      @statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status)
+    end
+  end
+
   def set_body_classes
     @body_classes = 'with-modals'
   end
@@ -63,16 +64,16 @@ class TagsController < ApplicationController
     @instance_presenter = InstancePresenter.new
   end
 
+  def limit_param
+    params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
+  end
+
   def collection_presenter
     ActivityPub::CollectionPresenter.new(
-      id: tag_url(@tag, filter_params),
+      id: tag_url(@tag),
       type: :ordered,
       size: @tag.statuses.count,
       items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
     )
   end
-
-  def filter_params
-    params.slice(:any, :all, :none).permit(:any, :all, :none)
-  end
 end
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 306e62342..04266c497 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -464,6 +464,7 @@
   padding: 4px 0;
   border-radius: 4px;
   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  z-index: 9999;
 
   ul {
     list-style: none;
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index cb2c682a4..d28f7dad8 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -1,6 +1,5 @@
 import api, { getLinks } from '../api';
-import openDB from '../storage/db';
-import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer';
+import { importFetchedAccount, importFetchedAccounts } from './importer';
 
 export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
 export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
@@ -74,45 +73,13 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
 export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
 export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
-function getFromDB(dispatch, getState, index, id) {
-  return new Promise((resolve, reject) => {
-    const request = index.get(id);
-
-    request.onerror = reject;
-
-    request.onsuccess = () => {
-      if (!request.result) {
-        reject();
-        return;
-      }
-
-      dispatch(importAccount(request.result));
-      resolve(request.result.moved && getFromDB(dispatch, getState, index, request.result.moved));
-    };
-  });
-}
-
 export function fetchAccount(id) {
   return (dispatch, getState) => {
     dispatch(fetchRelationships([id]));
-
-    if (getState().getIn(['accounts', id], null) !== null) {
-      return;
-    }
-
     dispatch(fetchAccountRequest(id));
 
-    openDB().then(db => getFromDB(
-      dispatch,
-      getState,
-      db.transaction('accounts', 'read').objectStore('accounts').index('id'),
-      id,
-    ).then(() => db.close(), error => {
-      db.close();
-      throw error;
-    })).catch(() => api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+    api(getState).get(`/api/v1/accounts/${id}`).then(response => {
       dispatch(importFetchedAccount(response.data));
-    })).then(() => {
       dispatch(fetchAccountSuccess());
     }).catch(error => {
       dispatch(fetchAccountFail(id, error));
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index e565e0b0a..3fc7c0702 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -1,9 +1,7 @@
 import api from '../api';
-import openDB from '../storage/db';
-import { evictStatus } from '../storage/modifier';
 
 import { deleteFromTimelines } from './timelines';
-import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus, importFetchedAccount } from './importer';
+import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
 import { ensureComposeIsVisible } from './compose';
 
 export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
@@ -40,48 +38,6 @@ export function fetchStatusRequest(id, skipLoading) {
   };
 };
 
-function getFromDB(dispatch, getState, accountIndex, index, id) {
-  return new Promise((resolve, reject) => {
-    const request = index.get(id);
-
-    request.onerror = reject;
-
-    request.onsuccess = () => {
-      const promises = [];
-
-      if (!request.result) {
-        reject();
-        return;
-      }
-
-      dispatch(importStatus(request.result));
-
-      if (getState().getIn(['accounts', request.result.account], null) === null) {
-        promises.push(new Promise((accountResolve, accountReject) => {
-          const accountRequest = accountIndex.get(request.result.account);
-
-          accountRequest.onerror = accountReject;
-          accountRequest.onsuccess = () => {
-            if (!request.result) {
-              accountReject();
-              return;
-            }
-
-            dispatch(importAccount(accountRequest.result));
-            accountResolve();
-          };
-        }));
-      }
-
-      if (request.result.reblog && getState().getIn(['statuses', request.result.reblog], null) === null) {
-        promises.push(getFromDB(dispatch, getState, accountIndex, index, request.result.reblog));
-      }
-
-      resolve(Promise.all(promises));
-    };
-  });
-}
-
 export function fetchStatus(id) {
   return (dispatch, getState) => {
     const skipLoading = getState().getIn(['statuses', id], null) !== null;
@@ -94,23 +50,10 @@ export function fetchStatus(id) {
 
     dispatch(fetchStatusRequest(id, skipLoading));
 
-    openDB().then(db => {
-      const transaction = db.transaction(['accounts', 'statuses'], 'read');
-      const accountIndex = transaction.objectStore('accounts').index('id');
-      const index = transaction.objectStore('statuses').index('id');
-
-      return getFromDB(dispatch, getState, accountIndex, index, id).then(() => {
-        db.close();
-      }, error => {
-        db.close();
-        throw error;
-      });
-    }).then(() => {
-      dispatch(fetchStatusSuccess(skipLoading));
-    }, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+    api(getState).get(`/api/v1/statuses/${id}`).then(response => {
       dispatch(importFetchedStatus(response.data));
       dispatch(fetchStatusSuccess(skipLoading));
-    })).catch(error => {
+    }).catch(error => {
       dispatch(fetchStatusFail(id, error, skipLoading));
     });
   };
@@ -152,7 +95,6 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
     dispatch(deleteStatusRequest(id));
 
     api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
-      evictStatus(id);
       dispatch(deleteStatusSuccess(id));
       dispatch(deleteFromTimelines(id));
       dispatch(importFetchedAccount(response.data.account));
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index ae79c6baf..0c0eb281a 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -256,14 +256,6 @@ html {
   background: $ui-base-color;
 }
 
-.status.status-direct {
-  background: lighten($ui-base-color, 4%);
-}
-
-.focusable:focus .status.status-direct {
-  background: lighten($ui-base-color, 8%);
-}
-
 .detailed-status,
 .detailed-status__action-bar {
   background: $white;
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 712c48823..4e406b41d 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -14,7 +14,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     moved_to: { 'movedTo' => { '@id' => 'as:movedTo', '@type' => '@id' } },
     also_known_as: { 'alsoKnownAs' => { '@id' => 'as:alsoKnownAs', '@type' => '@id' } },
     emoji: { 'toot' => 'http://joinmastodon.org/ns#', 'Emoji' => 'toot:Emoji' },
-    featured: { 'toot' => 'http://joinmastodon.org/ns#', 'featured' => { '@id' => 'toot:featured', '@type' => '@id' } },
+    featured: { 'toot' => 'http://joinmastodon.org/ns#', 'featured' => { '@id' => 'toot:featured', '@type' => '@id' }, 'featuredTags' => { '@id' => 'toot:featuredTags', '@type' => '@id' } },
     property_value: { 'schema' => 'http://schema.org#', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' },
     atom_uri: { 'ostatus' => 'http://ostatus.org#', 'atomUri' => 'ostatus:atomUri' },
     conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 915f3fa58..3c1f8d6e2 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -6,33 +6,56 @@ class FeedManager
   include Singleton
   include Redisable
 
+  # Maximum number of items stored in a single feed
   MAX_ITEMS = 400
 
-  # Must be <= MAX_ITEMS or the tracking sets will grow forever
+  # Number of items in the feed since last reblog of status
+  # before the new reblog will be inserted. Must be <= MAX_ITEMS
+  # or the tracking sets will grow forever
   REBLOG_FALLOFF = 40
 
+  # Execute block for every active account
+  # @yield [Account]
+  # @return [void]
   def with_active_accounts(&block)
     Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each(&block)
   end
 
+  # Redis key of a feed
+  # @param [Symbol] type
+  # @param [Integer] id
+  # @param [Symbol] subtype
+  # @return [String]
   def key(type, id, subtype = nil)
     return "feed:#{type}:#{id}" unless subtype
 
     "feed:#{type}:#{id}:#{subtype}"
   end
 
-  def filter?(timeline_type, status, receiver_id)
-    if timeline_type == :home
-      filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]))
-    elsif timeline_type == :mentions
-      filter_from_mentions?(status, receiver_id)
-    elsif timeline_type == :direct
-      filter_from_direct?(status, receiver_id)
+  # Check if the status should not be added to a feed
+  # @param [Symbol] timeline_type
+  # @param [Status] status
+  # @param [Account|List] receiver
+  # @return [Boolean]
+  def filter?(timeline_type, status, receiver)
+    case timeline_type
+    when :home
+      filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]))
+    when :list
+      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
+    when :mentions
+      filter_from_mentions?(status, receiver.id)
+    when :direct
+      filter_from_direct?(status, receiver.id)
     else
       false
     end
   end
 
+  # Add a status to a home feed and send a streaming API update
+  # @param [Account] account
+  # @param [Status] status
+  # @return [Boolean]
   def push_to_home(account, status)
     return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
 
@@ -41,6 +64,10 @@ class FeedManager
     true
   end
 
+  # Remove a status from a home feed and send a streaming API update
+  # @param [Account] account
+  # @param [Status] status
+  # @return [Boolean]
   def unpush_from_home(account, status)
     return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
 
@@ -48,21 +75,22 @@ class FeedManager
     true
   end
 
+  # Add a status to a list feed and send a streaming API update
+  # @param [List] list
+  # @param [Status] status
+  # @return [Boolean]
   def push_to_list(list, status)
-    if status.reply? && status.in_reply_to_account_id != status.account_id
-      should_filter = status.in_reply_to_account_id != list.account_id
-      should_filter &&= !list.show_all_replies?
-      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, list.account.user&.aggregates_reblogs?)
+    return false if filter_from_list?(status, list) || !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
   end
 
+  # Remove a status from a list feed and send a streaming API update
+  # @param [List] list
+  # @param [Status] status
+  # @return [Boolean]
   def unpush_from_list(list, status)
     return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
 
@@ -70,44 +98,34 @@ class FeedManager
     true
   end
 
+  # Add a status to a linear direct message feed and send a streaming API update
+  # @param [Account] account
+  # @param [Status] status
+  # @return [Boolean]
   def push_to_direct(account, status)
     return false unless add_to_feed(:direct, account.id, status)
+
     trim(:direct, account.id)
     PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}")
     true
   end
 
+  # Remove a status from a linear direct message feed and send a streaming API update
+  # @param [List] list
+  # @param [Status] status
+  # @return [Boolean]
   def unpush_from_direct(account, status)
     return false unless remove_from_feed(:direct, account.id, status)
-    redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
-  end
 
-  def trim(type, account_id)
-    timeline_key = key(type, account_id)
-    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))
-
-    # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
-    # tracking anything after it for deduplication purposes.
-    falloff_rank  = FeedManager::REBLOG_FALLOFF - 1
-    falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
-    falloff_score = falloff_range&.first&.last&.to_i || 0
-
-    # Get any reblogs we might have to clean up after.
-    redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id|
-      # Remove it from the set of reblogs we're tracking *first* to avoid races.
-      redis.zrem(reblog_key, reblogged_id)
-      # Just drop any set we might have created to track additional reblogs.
-      # This means that if this reblog is deleted, we won't automatically insert
-      # another reblog, but also that any new reblog can be inserted into the
-      # feed.
-      redis.del(key(type, account_id, "reblogs:#{reblogged_id}"))
-    end
+    redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
+    true
   end
 
-  def merge_into_timeline(from_account, into_account)
+  # Fill a home feed with an account's statuses
+  # @param [Account] from_account
+  # @param [Account] into_account
+  # @return [void]
+  def merge_into_home(from_account, into_account)
     timeline_key = key(:home, into_account.id)
     aggregate    = into_account.user&.aggregates_reblogs?
     query        = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
@@ -129,7 +147,37 @@ class FeedManager
     trim(:home, into_account.id)
   end
 
-  def unmerge_from_timeline(from_account, into_account)
+  # Fill a list feed with an account's statuses
+  # @param [Account] from_account
+  # @param [List] list
+  # @return [void]
+  def merge_into_list(from_account, list)
+    timeline_key = key(:list, list.id)
+    aggregate    = list.account.user&.aggregates_reblogs?
+    query        = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
+
+    if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
+      oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i
+      query = query.where('id > ?', oldest_home_score)
+    end
+
+    statuses = query.to_a
+    crutches = build_crutches(list.account_id, statuses)
+
+    statuses.each do |status|
+      next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list)
+
+      add_to_feed(:list, list.id, status, aggregate)
+    end
+
+    trim(:list, list.id)
+  end
+
+  # Remove an account's statuses from a home feed
+  # @param [Account] from_account
+  # @param [Account] into_account
+  # @return [void]
+  def unmerge_from_home(from_account, into_account)
     timeline_key      = key(:home, into_account.id)
     oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
 
@@ -138,14 +186,31 @@ class FeedManager
     end
   end
 
-  def clear_from_timeline(account, target_account)
-    # Clear from timeline all statuses from or mentionning target_account
+  # Remove an account's statuses from a list feed
+  # @param [Account] from_account
+  # @param [List] list
+  # @return [void]
+  def unmerge_from_list(from_account, list)
+    timeline_key      = key(:list, list.id)
+    oldest_list_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
+
+    from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_list_score).reorder(nil).find_each do |status|
+      remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
+    end
+  end
+
+  # Clear all statuses from or mentioning target_account from a home feed
+  # @param [Account] account
+  # @param [Account] target_account
+  # @return [void]
+  def clear_from_home(account, target_account)
     timeline_key        = key(:home, account.id)
     timeline_status_ids = redis.zrange(timeline_key, 0, -1)
     statuses            = Status.where(id: timeline_status_ids).select(:id, :reblog_of_id, :account_id).to_a
     reblogged_ids       = Status.where(id: statuses.map(&:reblog_of_id).compact, account: target_account).pluck(:id)
     with_mentions_ids   = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact, account: target_account).pluck(:status_id)
-    target_statuses     = statuses.filter do |status|
+
+    target_statuses = statuses.select do |status|
       status.account_id == target_account.id || reblogged_ids.include?(status.reblog_of_id) || with_mentions_ids.include?(status.id) || with_mentions_ids.include?(status.reblog_of_id)
     end
 
@@ -154,7 +219,10 @@ class FeedManager
     end
   end
 
-  def populate_feed(account)
+  # Populate home feed of account from scratch
+  # @param [Account] account
+  # @return [void]
+  def populate_home(account)
     limit        = FeedManager::MAX_ITEMS / 2
     aggregate    = account.user&.aggregates_reblogs?
     timeline_key = key(:home, account.id)
@@ -187,6 +255,9 @@ class FeedManager
     end
   end
 
+  # Populate direct feed of account from scratch
+  # @param [Account] account
+  # @return [void]
   def populate_direct_feed(account)
     added  = 0
     limit  = FeedManager::MAX_ITEMS / 2
@@ -210,15 +281,59 @@ class FeedManager
 
   private
 
-  def push_update_required?(timeline_id)
-    redis.exists?("subscribed:#{timeline_id}")
+  # Trim a feed to maximum size by removing older items
+  # @param [Symbol] type
+  # @param [Integer] timeline_id
+  # @return [void]
+  def trim(type, timeline_id)
+    timeline_key = key(type, timeline_id)
+    reblog_key   = key(type, timeline_id, 'reblogs')
+
+    # Remove any items past the MAX_ITEMS'th entry in our feed
+    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.
+    falloff_rank  = FeedManager::REBLOG_FALLOFF
+    falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
+    falloff_score = falloff_range&.first&.last&.to_i
+
+    return if falloff_score.nil?
+
+    # Get any reblogs we might have to clean up after.
+    redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id|
+      # Remove it from the set of reblogs we're tracking *first* to avoid races.
+      redis.zrem(reblog_key, reblogged_id)
+      # Just drop any set we might have created to track additional reblogs.
+      # This means that if this reblog is deleted, we won't automatically insert
+      # another reblog, but also that any new reblog can be inserted into the
+      # feed.
+      redis.del(key(type, timeline_id, "reblogs:#{reblogged_id}"))
+    end
   end
 
+  # Check if there is a streaming API client connected
+  # for the given feed
+  # @param [String] timeline_key
+  # @return [Boolean]
+  def push_update_required?(timeline_key)
+    redis.exists?("subscribed:#{timeline_key}")
+  end
+
+  # Check if the account is blocking or muting any of the given accounts
+  # @param [Integer] receiver_id
+  # @param [Array<Integer>] account_ids
+  # @param [Symbol] context
   def blocks_or_mutes?(receiver_id, account_ids, context)
     Block.where(account_id: receiver_id, target_account_id: account_ids).any? ||
       (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?)
   end
 
+  # Check if status should not be added to the home feed
+  # @param [Status] status
+  # @param [Integer] receiver_id
+  # @param [Hash] crutches
+  # @return [Boolean]
   def filter_from_home?(status, receiver_id, crutches)
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
@@ -251,6 +366,11 @@ class FeedManager
     false
   end
 
+  # Check if status should not be added to the mentions feed
+  # @see NotifyService
+  # @param [Status] status
+  # @param [Integer] receiver_id
+  # @return [Boolean]
   def filter_from_mentions?(status, receiver_id)
     return true if receiver_id == status.account_id
     return true if phrase_filtered?(status, receiver_id, :notifications)
@@ -267,11 +387,36 @@ class FeedManager
     should_filter
   end
 
+  # Check if status should not be added to the linear direct message feed
+  # @param [Status] status
+  # @param [Integer] receiver_id
+  # @return [Boolean]
   def filter_from_direct?(status, receiver_id)
     return false if receiver_id == status.account_id
     filter_from_mentions?(status, receiver_id)
   end
 
+  # Check if status should not be added to the list feed
+  # @param [Status] status
+  # @param [List] list
+  # @return [Boolean]
+  def filter_from_list?(status, list)
+    if status.reply? && status.in_reply_to_account_id != status.account_id
+      should_filter = status.in_reply_to_account_id != list.account_id
+      should_filter &&= !list.show_all_replies?
+      should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
+
+      return !!should_filter
+    end
+
+    false
+  end
+
+  # Check if the status hits a phrase filter
+  # @param [Status] status
+  # @param [Integer] receiver_id
+  # @param [Symbol] context
+  # @return [Boolean]
   def phrase_filtered?(status, receiver_id, context)
     active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
 
@@ -307,6 +452,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.
+  # @param [Symbol] timeline_type
+  # @param [Integer] account_id
+  # @param [Status] status
+  # @param [Boolean] aggregate_reblogs
+  # @return [Boolean]
   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')
@@ -319,14 +469,12 @@ class FeedManager
 
       return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF
 
-      reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id)
-
-      if reblog_rank.nil?
+      # The ordered set at `reblog_key` holds statuses which have a reblog
+      # in the top `REBLOG_FALLOFF` statuses of the timeline
+      if redis.zadd(reblog_key, status.id, status.reblog_of_id, nx: true)
         # This is not something we've already seen reblogged, so we
-        # can just add it to the feed (and note that we're
-        # reblogging it).
+        # can just add it to the feed (and note that we're reblogging it).
         redis.zadd(timeline_key, status.id, status.id)
-        redis.zadd(reblog_key, status.id, status.reblog_of_id)
       else
         # Another reblog of the same status was already in the
         # REBLOG_FALLOFF most recent statuses, so we note that this
@@ -340,9 +488,7 @@ class FeedManager
       # delay of the worker deliverying the original status, the late addition
       # by merging timelines, and other reasons.
       # If such a reblog already exists, just do not re-insert it into the feed.
-      rank = redis.zrevrank(reblog_key, status.id)
-
-      return false unless rank.nil?
+      return false unless redis.zscore(reblog_key, status.id).nil?
 
       redis.zadd(timeline_key, status.id, status.id)
     end
@@ -354,6 +500,11 @@ class FeedManager
   # with reblogs, and returning true if a status was removed. As with
   # `add_to_feed`, this does not trigger push updates, so callers must
   # do so if appropriate.
+  # @param [Symbol] timeline_type
+  # @param [Integer] account_id
+  # @param [Status] status
+  # @param [Boolean] aggregate_reblogs
+  # @return [Boolean]
   def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
     timeline_key = key(timeline_type, account_id)
     reblog_key   = key(timeline_type, account_id, 'reblogs')
@@ -388,6 +539,11 @@ class FeedManager
     redis.zrem(timeline_key, status.id)
   end
 
+  # Pre-fetch various objects and relationships for given statuses that
+  # are going to be checked by the filtering methods
+  # @param [Integer] receiver_id
+  # @param [Array<Status>] statuses
+  # @return [Hash]
   def build_crutches(receiver_id, statuses)
     crutches = {}
 
diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb
new file mode 100644
index 000000000..2839da5cb
--- /dev/null
+++ b/app/models/public_feed.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+class PublicFeed < Feed
+  # @param [Account] account
+  # @param [Hash] options
+  # @option [Boolean] :with_replies
+  # @option [Boolean] :with_reblogs
+  # @option [Boolean] :local
+  # @option [Boolean] :remote
+  # @option [Boolean] :only_media
+  # @option [Boolean] :allow_local_only
+  def initialize(account, options = {})
+    @account = account
+    @options = options
+  end
+
+  # @param [Integer] limit
+  # @param [Integer] max_id
+  # @param [Integer] since_id
+  # @param [Integer] min_id
+  # @return [Array<Status>]
+  def get(limit, max_id = nil, since_id = nil, min_id = nil)
+    scope = public_scope
+
+    scope.merge!(without_local_only_scope) unless allow_local_only?
+    scope.merge!(without_replies_scope) unless with_replies?
+    scope.merge!(without_reblogs_scope) unless with_reblogs?
+    scope.merge!(local_only_scope) if local_only?
+    scope.merge!(remote_only_scope) if remote_only?
+    scope.merge!(account_filters_scope) if account?
+    scope.merge!(media_only_scope) if media_only?
+
+    scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
+  end
+
+  private
+
+  def allow_local_only?
+    local_account? && (local_only? || @options[:allow_local_only])
+  end
+
+  def with_reblogs?
+    @options[:with_reblogs]
+  end
+
+  def with_replies?
+    @options[:with_replies]
+  end
+
+  def local_only?
+    @options[:local]
+  end
+
+  def remote_only?
+    @options[:remote]
+  end
+
+  def account?
+    @account.present?
+  end
+
+  def local_account?
+    @account&.local?
+  end
+
+  def media_only?
+    @options[:only_media]
+  end
+
+  def public_scope
+    Status.with_public_visibility.joins(:account).merge(Account.without_suspended.without_silenced)
+  end
+
+  def local_only_scope
+    Status.local
+  end
+
+  def remote_only_scope
+    Status.remote
+  end
+
+  def without_replies_scope
+    Status.without_replies
+  end
+
+  def without_reblogs_scope
+    Status.without_reblogs
+  end
+
+  def media_only_scope
+    Status.joins(:media_attachments).group(:id)
+  end
+
+  def without_local_only_scope
+    Status.not_local_only
+  end
+
+  def account_filters_scope
+    Status.not_excluded_by_account(@account).tap do |scope|
+      scope.merge!(Status.not_domain_blocked_by_account(@account)) unless local_only?
+      scope.merge!(Status.in_chosen_languages(@account)) if @account.chosen_languages.present?
+    end
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 594ae98c0..8495927af 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -89,12 +89,12 @@ class Status < ApplicationRecord
   scope :recent, -> { reorder(id: :desc) }
   scope :remote, -> { where(local: false).where.not(uri: nil) }
   scope :local,  -> { where(local: true).or(where(uri: nil)) }
-
   scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
   scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
   scope :with_public_visibility, -> { where(visibility: :public) }
   scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
+  scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
@@ -330,23 +330,6 @@ class Status < ApplicationRecord
       end
     end
 
-    def as_public_timeline(account = nil, local_only = false)
-      query = timeline_scope(local_only)
-      query = query.without_replies unless Setting.show_replies_in_public_timelines
-
-      apply_timeline_filters(query, account, [:local, true].include?(local_only))
-    end
-
-    def as_tag_timeline(tag, account = nil, local_only = false)
-      query = timeline_scope(local_only).tagged_with(tag)
-
-      apply_timeline_filters(query, account, local_only)
-    end
-
-    def as_outbox_timeline(account)
-      where(account: account, visibility: :public)
-    end
-
     def favourites_map(status_ids, account_id)
       Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
     end
@@ -423,53 +406,6 @@ class Status < ApplicationRecord
         status&.distributable? ? status : nil
       end.compact
     end
-
-    private
-
-    def timeline_scope(scope = false)
-      starting_scope = case scope
-                       when :local, true
-                         Status.local
-                       when :remote
-                         Status.remote
-                       else
-                         Status
-                       end
-      starting_scope = starting_scope.with_public_visibility
-      if Setting.show_reblogs_in_public_timelines
-        starting_scope
-      else
-        starting_scope.without_reblogs
-      end
-    end
-
-    def apply_timeline_filters(query, account, local_only)
-      if account.nil?
-        filter_timeline_default(query)
-      else
-        filter_timeline_for_account(query, account, local_only)
-      end
-    end
-
-    def filter_timeline_for_account(query, account, local_only)
-      query = query.not_excluded_by_account(account)
-      query = query.not_domain_blocked_by_account(account) unless local_only
-      query = query.in_chosen_languages(account) if account.chosen_languages.present?
-      query.merge(account_silencing_filter(account))
-    end
-
-    def filter_timeline_default(query)
-      query.not_local_only.excluding_silenced_accounts
-    end
-
-    def account_silencing_filter(account)
-      if account.silenced?
-        including_myself = left_outer_joins(:account).where(account_id: account.id).references(:accounts)
-        excluding_silenced_accounts.or(including_myself)
-      else
-        excluding_silenced_accounts
-      end
-    end
   end
 
   def marked_local_only?
diff --git a/app/models/tag.rb b/app/models/tag.rb
index bce76fc16..df2f86d95 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -39,7 +39,7 @@ class Tag < ApplicationRecord
   scope :listable, -> { where(listable: [true, nil]) }
   scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) }
   scope :discoverable, -> { listable.joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) }
-  scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
+  scope :recently_used, ->(account) { joins(:statuses).where(statuses: { id: account.statuses.select(:id).limit(1000) }).group(:id).order(Arel.sql('count(*) desc')) }
   scope :matches_name, ->(value) { where(arel_table[:name].matches("#{value}%")) }
 
   delegate :accounts_count,
diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb
new file mode 100644
index 000000000..baff55020
--- /dev/null
+++ b/app/models/tag_feed.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class TagFeed < PublicFeed
+  LIMIT_PER_MODE = 4
+
+  # @param [Tag] tag
+  # @param [Account] account
+  # @param [Hash] options
+  # @option [Enumerable<String>] :any
+  # @option [Enumerable<String>] :all
+  # @option [Enumerable<String>] :none
+  # @option [Boolean] :local
+  # @option [Boolean] :remote
+  # @option [Boolean] :only_media
+  def initialize(tag, account, options = {})
+    @tag     = tag
+    @account = account
+    @options = options
+  end
+
+  # @param [Integer] limit
+  # @param [Integer] max_id
+  # @param [Integer] since_id
+  # @param [Integer] min_id
+  # @return [Array<Status>]
+  def get(limit, max_id = nil, since_id = nil, min_id = nil)
+    scope = public_scope
+
+    scope.merge!(without_local_only_scope) unless local_account?
+    scope.merge!(tagged_with_any_scope)
+    scope.merge!(tagged_with_all_scope)
+    scope.merge!(tagged_with_none_scope)
+    scope.merge!(local_only_scope) if local_only?
+    scope.merge!(remote_only_scope) if remote_only?
+    scope.merge!(account_filters_scope) if account?
+    scope.merge!(media_only_scope) if media_only?
+
+    scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
+  end
+
+  private
+
+  def tagged_with_any_scope
+    Status.group(:id).tagged_with(tags_for(Array(@tag.name) | Array(@options[:any])))
+  end
+
+  def tagged_with_all_scope
+    Status.group(:id).tagged_with_all(tags_for(@options[:all]))
+  end
+
+  def tagged_with_none_scope
+    Status.group(:id).tagged_with_none(tags_for(@options[:none]))
+  end
+
+  def tags_for(names)
+    Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present?
+  end
+end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index 627d4446b..5d2741b17 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -10,7 +10,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
                      :discoverable, :olm
 
   attributes :id, :type, :following, :followers,
-             :inbox, :outbox, :featured,
+             :inbox, :outbox, :featured, :featured_tags,
              :preferred_username, :name, :summary,
              :url, :manually_approves_followers,
              :discoverable
@@ -74,13 +74,17 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   end
 
   def outbox
-    account_outbox_url(object)
+    object.instance_actor? ? instance_actor_outbox_url : account_outbox_url(object)
   end
 
   def featured
     account_collection_url(object, :featured)
   end
 
+  def featured_tags
+    account_collection_url(object, :tags)
+  end
+
   def endpoints
     object
   end
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
index ea7af5433..34026a6b5 100644
--- a/app/serializers/activitypub/collection_serializer.rb
+++ b/app/serializers/activitypub/collection_serializer.rb
@@ -16,6 +16,8 @@ class ActivityPub::CollectionSerializer < ActivityPub::Serializer
       ActivityPub::NoteSerializer
     when 'Device'
       ActivityPub::DeviceSerializer
+    when 'FeaturedTag'
+      ActivityPub::HashtagSerializer
     when 'ActivityPub::CollectionPresenter'
       ActivityPub::CollectionSerializer
     when 'String'
diff --git a/app/serializers/activitypub/hashtag_serializer.rb b/app/serializers/activitypub/hashtag_serializer.rb
new file mode 100644
index 000000000..1a56e4dfe
--- /dev/null
+++ b/app/serializers/activitypub/hashtag_serializer.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ActivityPub::HashtagSerializer < ActivityPub::Serializer
+  include RoutingHelper
+
+  attributes :type, :href, :name
+
+  def type
+    'Hashtag'
+  end
+
+  def name
+    "##{object.name}"
+  end
+
+  def href
+    if object.class.name == 'FeaturedTag'
+      short_account_tag_url(object.account, object.tag)
+    else
+      tag_url(object)
+    end
+  end
+end
diff --git a/app/serializers/rest/account_featured_tag_serializer.rb b/app/serializers/rest/account_featured_tag_serializer.rb
new file mode 100644
index 000000000..d8d5fd68c
--- /dev/null
+++ b/app/serializers/rest/account_featured_tag_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class REST::AccountFeaturedTagSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :id, :name, :url
+
+  def id
+    object.tag.id.to_s
+  end
+
+  def name
+    "##{object.name}"
+  end
+
+  def url
+    short_account_tag_url(object.account, object.tag)
+  end
+end
diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb
index 2a0e10a79..314919df8 100644
--- a/app/services/after_block_service.rb
+++ b/app/services/after_block_service.rb
@@ -13,7 +13,7 @@ class AfterBlockService < BaseService
   private
 
   def clear_home_feed!
-    FeedManager.instance.clear_from_timeline(@account, @target_account)
+    FeedManager.instance.clear_from_home(@account, @target_account)
   end
 
   def clear_conversations!
diff --git a/app/services/hashtag_query_service.rb b/app/services/hashtag_query_service.rb
deleted file mode 100644
index 0bdf60221..000000000
--- a/app/services/hashtag_query_service.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-class HashtagQueryService < BaseService
-  LIMIT_PER_MODE = 4
-
-  def call(tag, params, account = nil, local = false)
-    tags = tags_for(Array(tag.name) | Array(params[:any])).pluck(:id)
-    all  = tags_for(params[:all])
-    none = tags_for(params[:none])
-
-    Status.group(:id)
-          .as_tag_timeline(tags, account, local)
-          .tagged_with_all(all)
-          .tagged_with_none(none)
-  end
-
-  private
-
-  def tags_for(names)
-    Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present?
-  end
-end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index abd676494..e4ca10eb1 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -13,15 +13,13 @@ class NotifyService < BaseService
     push_to_conversation! if direct_message?
     send_email! if email_enabled?
   rescue ActiveRecord::RecordInvalid
-    # rubocop:disable Style/RedundantReturn
-    return
-    # rubocop:enable Style/RedundantReturn
+    nil
   end
 
   private
 
   def blocked_mention?
-    FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id)
+    FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
   end
 
   def blocked_favourite?
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index 029c2f6e5..b4fa70710 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -2,7 +2,7 @@
 
 class PrecomputeFeedService < BaseService
   def call(account)
-    FeedManager.instance.populate_feed(account)
+    FeedManager.instance.populate_home(account)
     FeedManager.instance.populate_direct_feed(account)
   ensure
     Redis.current.del("account:#{account.id}:regeneration")
diff --git a/app/views/settings/featured_tags/index.html.haml b/app/views/settings/featured_tags/index.html.haml
index 6734d027c..297379893 100644
--- a/app/views/settings/featured_tags/index.html.haml
+++ b/app/views/settings/featured_tags/index.html.haml
@@ -9,7 +9,7 @@
   = render 'shared/error_messages', object: @featured_tag
 
   .fields-group
-    = f.input :name, wrapper: :with_block_label, hint: safe_join([t('simple_form.hints.featured_tag.name'), safe_join(@most_used_tags.map { |tag| link_to("##{tag.name}", settings_featured_tags_path(featured_tag: { name: tag.name }), method: :post) }, ', ')], ' ')
+    = f.input :name, wrapper: :with_block_label, hint: safe_join([t('simple_form.hints.featured_tag.name'), safe_join(@recently_used_tags.map { |tag| link_to("##{tag.name}", settings_featured_tags_path(featured_tag: { name: tag.name }), method: :post) }, ', ')], ' ')
 
   .actions
     = f.button :button, t('featured_tags.add_new'), type: :submit
diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb
index 546f5c0c2..fd35af562 100644
--- a/app/workers/feed_insert_worker.rb
+++ b/app/workers/feed_insert_worker.rb
@@ -29,13 +29,13 @@ class FeedInsertWorker
   end
 
   def feed_filtered?
-    # Note: Lists are a variation of home, so the filtering rules
-    # of home apply to both
     case @type
-    when :home, :list
-      FeedManager.instance.filter?(:home, @status, @follower.id)
+    when :home
+      FeedManager.instance.filter?(:home, @status, @follower)
+    when :list
+      FeedManager.instance.filter?(:list, @status, @list)
     when :direct
-      FeedManager.instance.filter?(:direct, @status, @account.id)
+      FeedManager.instance.filter?(:direct, @status, @account)
     end
   end
 
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index d745cb99c..74ef7d4da 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -6,6 +6,8 @@ class MergeWorker
   sidekiq_options queue: 'pull'
 
   def perform(from_account_id, into_account_id)
-    FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
+    FeedManager.instance.merge_into_home(Account.find(from_account_id), Account.find(into_account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end
diff --git a/app/workers/mute_worker.rb b/app/workers/mute_worker.rb
index 7bf0923a5..c74f657cb 100644
--- a/app/workers/mute_worker.rb
+++ b/app/workers/mute_worker.rb
@@ -4,9 +4,8 @@ class MuteWorker
   include Sidekiq::Worker
 
   def perform(account_id, target_account_id)
-    FeedManager.instance.clear_from_timeline(
-      Account.find(account_id),
-      Account.find(target_account_id)
-    )
+    FeedManager.instance.clear_from_home(Account.find(account_id), Account.find(target_account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end
diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb
index ea6aacebf..1a23faae5 100644
--- a/app/workers/unmerge_worker.rb
+++ b/app/workers/unmerge_worker.rb
@@ -6,6 +6,8 @@ class UnmergeWorker
   sidekiq_options queue: 'pull'
 
   def perform(from_account_id, into_account_id)
-    FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
+    FeedManager.instance.unmerge_from_home(Account.find(from_account_id), Account.find(into_account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end