about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile4
-rw-r--r--Gemfile.lock40
-rw-r--r--app/controllers/api/v1/featured_tags/suggestions_controller.rb8
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb33
-rw-r--r--app/controllers/api/v1/timelines/tag_controller.rb34
-rw-r--r--app/controllers/settings/featured_tags_controller.rb8
-rw-r--r--app/controllers/tags_controller.rb33
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js14
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js9
-rw-r--r--app/javascript/flavours/glitch/components/status.js3
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js6
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow_request.js6
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js7
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js38
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js5
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js59
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss25
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss4
-rw-r--r--app/lib/feed_manager.rb274
-rw-r--r--app/models/public_feed.rb104
-rw-r--r--app/models/status.rb63
-rw-r--r--app/models/tag.rb2
-rw-r--r--app/models/tag_feed.rb58
-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.rb3
-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
-rw-r--r--package.json6
-rw-r--r--spec/lib/feed_manager_spec.rb90
-rw-r--r--spec/models/public_feed_spec.rb274
-rw-r--r--spec/models/status_spec.rb282
-rw-r--r--spec/models/tag_feed_spec.rb (renamed from spec/services/hashtag_query_service_spec.rb)40
-rw-r--r--spec/services/fan_out_on_write_service_spec.rb4
-rw-r--r--yarn.lock212
41 files changed, 1048 insertions, 760 deletions
diff --git a/Gemfile b/Gemfile
index cab8eae87..12cc251c3 100644
--- a/Gemfile
+++ b/Gemfile
@@ -142,8 +142,8 @@ group :development do
   gem 'letter_opener', '~> 1.7'
   gem 'letter_opener_web', '~> 1.4'
   gem 'memory_profiler'
-  gem 'rubocop', '~> 0.88', require: false
-  gem 'rubocop-rails', '~> 2.6', require: false
+  gem 'rubocop', '~> 0.90', require: false
+  gem 'rubocop-rails', '~> 2.8', require: false
   gem 'brakeman', '~> 4.9', require: false
   gem 'bundler-audit', '~> 0.7', require: false
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 15973bdec..7a1192329 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -81,7 +81,7 @@ GEM
       cocaine (~> 0.5.3)
     awrence (1.1.1)
     aws-eventstream (1.1.0)
-    aws-partitions (1.363.0)
+    aws-partitions (1.365.0)
     aws-sdk-core (3.105.0)
       aws-eventstream (~> 1, >= 1.0.2)
       aws-partitions (~> 1, >= 1.239.0)
@@ -96,7 +96,7 @@ GEM
       aws-sigv4 (~> 1.1)
     aws-sigv4 (1.2.2)
       aws-eventstream (~> 1, >= 1.0.2)
-    bcrypt (3.1.15)
+    bcrypt (3.1.16)
     better_errors (2.7.1)
       coderay (>= 1.0.0)
       erubi (>= 1.0.0)
@@ -108,7 +108,7 @@ GEM
       ffi (~> 1.10.0)
     bootsnap (1.4.8)
       msgpack (~> 1.0)
-    brakeman (4.9.0)
+    brakeman (4.9.1)
     browser (4.2.0)
     builder (3.2.4)
     bullet (6.1.0)
@@ -358,7 +358,7 @@ GEM
     mimemagic (0.3.5)
     mini_mime (1.0.2)
     mini_portile2 (2.4.0)
-    minitest (5.14.1)
+    minitest (5.14.2)
     msgpack (1.3.3)
     multi_json (1.15.0)
     multipart-post (2.1.1)
@@ -366,7 +366,7 @@ GEM
     net-scp (3.0.0)
       net-ssh (>= 2.6.5, < 7.0.0)
     net-ssh (6.1.0)
-    nio4r (2.5.2)
+    nio4r (2.5.3)
     nokogiri (1.10.10)
       mini_portile2 (~> 2.4.0)
     nokogumbo (2.0.2)
@@ -376,7 +376,7 @@ GEM
       concurrent-ruby (~> 1.0, >= 1.0.2)
       sidekiq (>= 3.5)
       statsd-ruby (~> 1.4, >= 1.4.0)
-    oj (3.10.13)
+    oj (3.10.14)
     omniauth (1.9.1)
       hashie (>= 3.4.6)
       rack (>= 1.6.2, < 3)
@@ -390,7 +390,7 @@ GEM
     openssl (2.2.0)
     openssl-signature_algorithm (0.4.0)
     orm_adapter (0.5.0)
-    ox (2.13.2)
+    ox (2.13.3)
     paperclip (6.0.0)
       activemodel (>= 4.2.0)
       activesupport (>= 4.2.0)
@@ -430,8 +430,8 @@ GEM
     pry-rails (0.3.9)
       pry (>= 0.10.4)
     psych (3.1.0)
-    public_suffix (4.0.5)
-    puma (4.3.5)
+    public_suffix (4.0.6)
+    puma (4.3.6)
       nio4r (~> 2.0)
     pundit (2.1.0)
       activesupport (>= 3.0.0)
@@ -480,7 +480,7 @@ GEM
       thor (>= 0.19.0, < 2.0)
     rainbow (3.0.0)
     rake (13.0.1)
-    rdf (3.1.5)
+    rdf (3.1.6)
       hamster (~> 3.0)
       link_header (~> 0.0, >= 0.0.8)
     rdf-normalize (0.4.0)
@@ -545,21 +545,21 @@ GEM
     rspec-support (3.9.3)
     rspec_junit_formatter (0.4.1)
       rspec-core (>= 2, < 4, != 2.12.0)
-    rubocop (0.88.0)
+    rubocop (0.90.0)
       parallel (~> 1.10)
       parser (>= 2.7.1.1)
       rainbow (>= 2.2.2, < 4.0)
       regexp_parser (>= 1.7)
       rexml
-      rubocop-ast (>= 0.1.0, < 1.0)
+      rubocop-ast (>= 0.3.0, < 1.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 1.4.0, < 2.0)
     rubocop-ast (0.3.0)
       parser (>= 2.7.1.4)
-    rubocop-rails (2.6.0)
+    rubocop-rails (2.8.0)
       activesupport (>= 4.2.0)
       rack (>= 1.1)
-      rubocop (>= 0.82.0)
+      rubocop (>= 0.87.0)
     ruby-progressbar (1.10.1)
     ruby-saml (1.11.0)
       nokogiri (>= 1.5.10)
@@ -587,10 +587,10 @@ GEM
       sidekiq (>= 3)
       thwait
       tilt (>= 1.4.0)
-    sidekiq-unique-jobs (6.0.22)
+    sidekiq-unique-jobs (6.0.23)
       concurrent-ruby (~> 1.0, >= 1.0.5)
       sidekiq (>= 4.0, < 7.0)
-      thor (~> 0)
+      thor (>= 0.20, < 2.0)
     simple-navigation (4.1.0)
       activesupport (>= 2.3.2)
     simple_form (5.0.2)
@@ -654,8 +654,8 @@ GEM
     w3c_validators (1.3.5)
       json (>= 1.8)
       nokogiri (~> 1.6)
-    warden (1.2.8)
-      rack (>= 2.0.6)
+    warden (1.2.9)
+      rack (>= 2.0.9)
     webauthn (3.0.0.alpha1)
       android_key_attestation (~> 0.3.0)
       awrence (~> 1.1)
@@ -794,8 +794,8 @@ DEPENDENCIES
   rspec-rails (~> 4.0)
   rspec-sidekiq (~> 3.1)
   rspec_junit_formatter (~> 0.4)
-  rubocop (~> 0.88)
-  rubocop-rails (~> 2.6)
+  rubocop (~> 0.90)
+  rubocop-rails (~> 2.8)
   ruby-progressbar (~> 1.10)
   sanitize (~> 5.2)
   sidekiq (~> 6.1)
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 274392c61..fbd99667c 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -20,29 +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
-    local = truthy_param?(:local) ? true : :local_reblogs
-    Status.as_public_timeline(current_account, truthy_param?(:remote) ? nil : 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/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 368419ef5..d8b6019f5 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,22 +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)
-        @statuses = @statuses.without_semiprivate unless authenticated_or_following?(@account)
-        @statuses = @statuses.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', target_domain: current_account&.domain
       end
     end
@@ -57,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
@@ -65,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/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index 96e29accf..6b49ebf88 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -105,15 +105,15 @@ export function submitMarkers() {
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
-    const params = { timeline: ['notifications'] };
+  const params = { timeline: ['notifications'] };
 
-    dispatch(fetchMarkersRequest());
+  dispatch(fetchMarkersRequest());
 
-    api(getState).get('/api/v1/markers', { params }).then(response => {
-      dispatch(fetchMarkersSuccess(response.data));
-    }).catch(error => {
-      dispatch(fetchMarkersFail(error));
-    });
+  api(getState).get('/api/v1/markers', { params }).then(response => {
+    dispatch(fetchMarkersSuccess(response.data));
+  }).catch(error => {
+    dispatch(fetchMarkersFail(error));
+  });
 };
 
 export function fetchMarkersRequest() {
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index ceb1e6df6..ccc427c29 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -18,6 +18,7 @@ import compareId from 'flavours/glitch/util/compare_id';
 import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
 
 // tracking the notif cleaning request
 export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
@@ -45,6 +46,8 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
 export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
 
+export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
 });
@@ -318,3 +321,9 @@ export function setFilter (filterType) {
     dispatch(saveSettings());
   };
 };
+
+export function markNotificationsAsRead() {
+  return {
+    type: NOTIFICATIONS_MARK_AS_READ,
+  };
+};
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 69f93a2f1..25d98554a 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -124,6 +124,7 @@ class Status extends ImmutablePureComponent {
     'notification',
     'hidden',
     'expanded',
+    'unread',
   ]
 
   updateOnStates = [
@@ -700,7 +701,7 @@ class Status extends ImmutablePureComponent {
       unpublished: status.get('published') === false,
       'has-background': isCollapsed && background,
       'status__wrapper-reply': !!status.get('in_reply_to_id'),
-      read: unread === false,
+      unread,
       muted,
     }, 'focusable');
 
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index d8a51c689..b4549fdf8 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -159,7 +159,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
     listItems = listItems.concat([
       <div key='9'>
         <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
-        {lists.map(list =>
+        {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list =>
           <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
         )}
       </div>,
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js
index 5f405e976..7b47f411b 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
 
 // Our imports.
 import Permalink from 'flavours/glitch/components/permalink';
@@ -19,6 +20,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
     id: PropTypes.string.isRequired,
     account: ImmutablePropTypes.map.isRequired,
     notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
   };
 
   handleMoveUp = () => {
@@ -59,7 +61,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, notification, hidden } = this.props;
+    const { account, notification, hidden, unread } = this.props;
 
     //  Links to the display name.
     const displayName = account.get('display_name_html') || account.get('username');
@@ -76,7 +78,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
     //  Renders.
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-follow focusable' tabIndex='0'>
+        <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0'>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon fixedWidth id='user-plus' />
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
index d73dac434..f351c1035 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
@@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import NotificationOverlayContainer from '../containers/overlay_container';
 import { HotKeys } from 'react-hotkeys';
 import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
@@ -25,6 +26,7 @@ class FollowRequest extends ImmutablePureComponent {
     onReject: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
   };
 
   handleMoveUp = () => {
@@ -65,7 +67,7 @@ class FollowRequest extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, hidden, account, onAuthorize, onReject, notification } = this.props;
+    const { intl, hidden, account, onAuthorize, onReject, notification, unread } = this.props;
 
     if (!account) {
       return <div />;
@@ -94,7 +96,7 @@ class FollowRequest extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-follow-request focusable' tabIndex='0'>
+        <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0'>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='user' fixedWidth />
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index 62fc28386..bd415856c 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -22,6 +22,7 @@ export default class Notification extends ImmutablePureComponent {
     cacheMediaWidth: PropTypes.func,
     cachedMediaWidth: PropTypes.number,
     onUnmount: PropTypes.func,
+    unread: PropTypes.bool,
   };
 
   render () {
@@ -46,6 +47,7 @@ export default class Notification extends ImmutablePureComponent {
           onMoveDown={onMoveDown}
           onMoveUp={onMoveUp}
           onMention={onMention}
+          unread={this.props.unread}
         />
       );
     case 'follow_request':
@@ -58,6 +60,7 @@ export default class Notification extends ImmutablePureComponent {
           onMoveDown={onMoveDown}
           onMoveUp={onMoveUp}
           onMention={onMention}
+          unread={this.props.unread}
         />
       );
     case 'mention':
@@ -77,6 +80,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     case 'favourite':
@@ -98,6 +102,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     case 'reblog':
@@ -119,6 +124,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     case 'poll':
@@ -140,6 +146,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     default:
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 26710feff..681323860 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -12,8 +12,10 @@ import {
   mountNotifications,
   unmountNotifications,
   loadPending,
+  markNotificationsAsRead,
 } from 'flavours/glitch/actions/notifications';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { submitMarkers } from 'flavours/glitch/actions/markers';
 import NotificationContainer from './containers/notification_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -24,12 +26,14 @@ import { debounce } from 'lodash';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import LoadGap from 'flavours/glitch/components/load_gap';
 import Icon from 'flavours/glitch/components/icon';
+import compareId from 'flavours/glitch/util/compare_id';
 
 import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
   enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
+  markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
 });
 
 const getNotifications = createSelector([
@@ -56,6 +60,8 @@ const mapStateToProps = state => ({
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
+  lastReadId: state.getIn(['notifications', 'readMarkerId']),
+  canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
 });
 
 /* glitch */
@@ -63,6 +69,10 @@ const mapDispatchToProps = dispatch => ({
   onEnterCleaningMode(yes) {
     dispatch(enterNotificationClearingMode(yes));
   },
+  onMarkAsRead() {
+    dispatch(markNotificationsAsRead());
+    dispatch(submitMarkers());
+  },
   onMount() {
     dispatch(mountNotifications());
   },
@@ -93,6 +103,8 @@ class Notifications extends React.PureComponent {
     onEnterCleaningMode: PropTypes.func,
     onMount: PropTypes.func,
     onUnmount: PropTypes.func,
+    lastReadId: PropTypes.string,
+    canMarkAsRead: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -194,8 +206,12 @@ class Notifications extends React.PureComponent {
     this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
   }
 
+  handleMarkAsRead = () => {
+    this.props.onMarkAsRead();
+  }
+
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
     const { notifCleaning, notifCleaningActive } = this.props;
     const { animatingNCD } = this.state;
     const pinned = !!columnId;
@@ -224,6 +240,7 @@ class Notifications extends React.PureComponent {
           accountId={item.get('account')}
           onMoveUp={this.handleMoveUp}
           onMoveDown={this.handleMoveDown}
+          unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
         />
       ));
     } else {
@@ -252,6 +269,21 @@ class Notifications extends React.PureComponent {
       </ScrollableList>
     );
 
+    const extraButtons = [];
+
+    if (canMarkAsRead) {
+      extraButtons.push(
+        <button
+          aria-label={intl.formatMessage(messages.markAsRead)}
+          title={intl.formatMessage(messages.markAsRead)}
+          onClick={this.handleMarkAsRead}
+          className='column-header__button'
+        >
+          <Icon id='check' />
+        </button>
+      );
+    }
+
     const notifCleaningButtonClassName = classNames('column-header__button', {
       'active': notifCleaningActive,
     });
@@ -263,7 +295,7 @@ class Notifications extends React.PureComponent {
 
     const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
 
-    const notifCleaningButton = (
+    extraButtons.push(
       <button
         aria-label={msgEnterNotifCleaning}
         title={msgEnterNotifCleaning}
@@ -300,7 +332,7 @@ class Notifications extends React.PureComponent {
           pinned={pinned}
           multiColumn={multiColumn}
           localSettings={this.props.localSettings}
-          extraButton={notifCleaningButton}
+          extraButton={extraButtons}
           appendContent={notifCleaningDrawer}
         >
           <ColumnSettingsContainer />
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index ee1d898bb..2366226ac 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -12,7 +12,7 @@ import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
 import { fetchFilters } from 'flavours/glitch/actions/filters';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
-import { synchronouslySubmitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
+import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
 import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import PermaLink from 'flavours/glitch/components/permalink';
@@ -360,6 +360,9 @@ class UI extends React.Component {
   handleVisibilityChange = () => {
     const visibility = !document[this.visibilityHiddenProp];
     this.props.dispatch(notificationsSetVisibility(visibility));
+    if (visibility) {
+      this.props.dispatch(submitMarkers());
+    }
   }
 
   componentWillMount () {
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index 31d9611a3..474ca3012 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -16,6 +16,7 @@ import {
   NOTIFICATIONS_DELETE_MARKED_FAIL,
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+  NOTIFICATIONS_MARK_AS_READ,
 } from 'flavours/glitch/actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -39,6 +40,7 @@ const initialState = ImmutableMap({
   mounted: 0,
   unread: 0,
   lastReadId: '0',
+  readMarkerId: '0',
   isLoading: false,
   cleaningMode: false,
   isTabVisible: true,
@@ -55,16 +57,16 @@ const notificationToMap = (state, notification) => ImmutableMap({
 });
 
 const normalizeNotification = (state, notification, usePendingItems) => {
-  const top = !shouldCountUnreadNotifications(state);
+  const top = state.get('top');
 
   if (usePendingItems || !state.get('pendingItems').isEmpty()) {
     return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification))).update('unread', unread => unread + 1);
   }
 
-  if (top) {
-    state = state.set('lastReadId', notification.id);
-  } else {
+  if (shouldCountUnreadNotifications(state)) {
     state = state.update('unread', unread => unread + 1);
+  } else {
+    state = state.set('lastReadId', notification.id);
   }
 
   return state.update('items', list => {
@@ -77,7 +79,6 @@ const normalizeNotification = (state, notification, usePendingItems) => {
 };
 
 const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => {
-  const top = !(shouldCountUnreadNotifications(state));
   const lastReadId = state.get('lastReadId');
   let items = ImmutableList();
 
@@ -102,18 +103,19 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
       });
     }
 
-    if (top) {
-      if (!items.isEmpty()) {
-        mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id'));
-      }
-    } else {
-      mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0));
-    }
-
     if (!next) {
       mutable.set('hasMore', false);
     }
 
+    if (shouldCountUnreadNotifications(state)) {
+      mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0));
+    } else {
+      const mostRecent = items.find(item => item !== null);
+      if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {
+        mutable.set('lastReadId', mostRecent.get('id'));
+      }
+    }
+
     mutable.set('isLoading', false);
   });
 };
@@ -127,7 +129,7 @@ const clearUnread = (state) => {
   state = state.set('unread', state.get('pendingItems').size);
   const lastNotification = state.get('items').find(item => item !== null);
   return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
-}
+};
 
 const updateTop = (state, top) => {
   state = state.set('top', top);
@@ -136,16 +138,17 @@ const updateTop = (state, top) => {
     state = clearUnread(state);
   }
 
-  return state.set('top', top);
+  return state;
 };
 
 const deleteByStatus = (state, statusId) => {
-  const top = !(shouldCountUnreadNotifications(state));
-  if (!top) {
-    const lastReadId = state.get('lastReadId');
+  const lastReadId = state.get('lastReadId');
+
+  if (shouldCountUnreadNotifications(state)) {
     const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
     state = state.update('unread', unread => unread - deletedUnread.size);
   }
+
   const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
   const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
   state = state.update('unread', unread => unread - deletedUnread.size);
@@ -183,6 +186,7 @@ const deleteMarkedNotifs = (state) => {
 const updateMounted = (state) => {
   state = state.update('mounted', count => count + 1);
   if (!shouldCountUnreadNotifications(state)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
   return state;
@@ -191,13 +195,20 @@ const updateMounted = (state) => {
 const updateVisibility = (state, visibility) => {
   state = state.set('isTabVisible', visibility);
   if (!shouldCountUnreadNotifications(state)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
   return state;
 };
 
 const shouldCountUnreadNotifications = (state) => {
-  return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0);
+  const isTabVisible   = state.get('isTabVisible');
+  const isOnTop        = state.get('top');
+  const isMounted      = state.get('mounted') > 0;
+  const lastReadId     = state.get('lastReadId');
+  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0);
+
+  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
 };
 
 const recountUnread = (state, last_read_id) => {
@@ -206,11 +217,15 @@ const recountUnread = (state, last_read_id) => {
       mutable.set('lastReadId', last_read_id);
     }
 
+    if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) {
+      mutable.set('readMarkerId', last_read_id);
+    }
+
     if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) {
       mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0));
     }
   });
-}
+};
 
 export default function notifications(state = initialState, action) {
   let st;
@@ -284,6 +299,10 @@ export default function notifications(state = initialState, action) {
     }
     return markAllForDelete(st, action.yes);
 
+  case NOTIFICATIONS_MARK_AS_READ:
+    const lastNotification = state.get('items').find(item => item !== null);
+    return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
+
   default:
     return state;
   }
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/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index a65581136..ba75e3ffe 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -275,7 +275,7 @@
     outline: 0;
     background: lighten($ui-base-color, 4%);
 
-    &.status.status-direct:not(.read) {
+    &.status.status-direct {
       background: lighten($ui-base-color, 12%);
 
       &.muted {
@@ -316,7 +316,7 @@
     margin-top: 8px;
   }
 
-  &.status-direct:not(.read) {
+  &.status-direct {
     background: lighten($ui-base-color, 8%);
     border-bottom-color: lighten($ui-base-color, 12%);
   }
@@ -399,7 +399,7 @@
     &:focus > .status__content:after {
       background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));
     }
-    &.status-direct:not(.read)> .status__content:after {
+    &.status-direct > .status__content:after {
       background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));
     }
 
@@ -1054,3 +1054,22 @@ a.status-card.compact:hover {
     text-decoration: underline;
   }
 }
+
+.notification,
+.status {
+  position: relative;
+
+  &.unread {
+    &::before {
+      content: "";
+      position: absolute;
+      top: 0;
+      left: 0;
+      pointer-events: 0;
+      width: 100%;
+      height: 100%;
+      border-left: 2px solid $highlight-text-color;
+      pointer-events: none;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index c83c82766..e5a5cc246 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -27,7 +27,7 @@
   }
 }
 
-.status.status-direct:not(.read) {
+.status.status-direct {
   background: darken($ui-base-color, 8%);
   border-bottom-color: darken($ui-base-color, 12%);
 
@@ -36,7 +36,7 @@
   }
 }
 
-.focusable:focus.status.status-direct:not(.read) {
+.focusable:focus.status.status-direct {
   background: darken($ui-base-color, 4%);
 
   &.collapsed> .status__content:after {
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 4e34e3b61..69009ffde 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -7,33 +7,56 @@ class FeedManager
   include Singleton
   include Redisable
 
+  # Maximum number of items stored in a single feed
   MAX_ITEMS = 1000
 
-  # 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 = 50
 
+  # 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 [:home, :list].include?(timeline_type)
-      filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]), filter_options_for(receiver_id))
-    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]), filter_options_for(receiver.id))
+    when :list
+      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), filter_options_for(receiver.id))
+    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?)
 
@@ -42,6 +65,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?)
 
@@ -49,24 +76,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)
-    return false if status.reblog?
-
-    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 &&= status.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?)
 
@@ -74,6 +99,10 @@ 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)
 
@@ -82,10 +111,15 @@ class FeedManager
     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))
+    true
   end
 
   def unpush_status(account, status)
@@ -107,32 +141,11 @@ class FeedManager
     end
   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
-  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)
@@ -155,7 +168,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
 
@@ -164,14 +207,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
 
@@ -180,7 +240,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)
@@ -214,6 +277,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
@@ -238,15 +304,60 @@ 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
+  # @param [Hash] filter_options
+  # @return [Boolean]
   def filter_from_home?(status, receiver_id, crutches, filter_options)
     conversation = status.conversation
     reblog_conversation = status.reblog&.conversation
@@ -316,6 +427,11 @@ class FeedManager
     crutches[:following][status.account_id]
   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)
@@ -338,12 +454,37 @@ class FeedManager
           .exists?
   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
 
@@ -379,6 +520,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')
@@ -391,14 +537,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
@@ -412,9 +556,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
@@ -426,6 +568,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')
@@ -471,6 +618,11 @@ class FeedManager
     end
   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 83ab58418..c79cbeaf9 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -106,13 +106,13 @@ 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(reply: false) }
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
   scope :with_public_visibility, -> { where(visibility: :public, published: true) }
   scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) }
   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) }
@@ -451,23 +451,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, include_unlisted: true).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
@@ -606,50 +589,6 @@ class Status < ApplicationRecord
       query = query.in_chosen_languages(account) if account.chosen_languages.present?
       query
     end
-
-    def timeline_scope(scope = false, include_unlisted: false)
-      starting_scope = case scope
-                       when :local, true
-                         Status.local
-                       when :remote
-                         Status.remote
-                       when :local_reblogs
-                         Status.locally_reblogged
-                       else
-                         Status
-                       end
-      starting_scope = include_unlisted ? starting_scope.distributable : starting_scope.with_public_visibility
-      scope != :local_reblogs ? starting_scope.without_reblogs : starting_scope
-    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 = query.not_hidden_by_account(account)
-      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/services/after_block_service.rb b/app/services/after_block_service.rb
index 432ba65e6..70e6467c7 100644
--- a/app/services/after_block_service.rb
+++ b/app/services/after_block_service.rb
@@ -15,7 +15,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 65f6052bf..755fad768 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 40cfad572..b4fa70710 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -2,8 +2,7 @@
 
 class PrecomputeFeedService < BaseService
   def call(account)
-    Redis.current.del("feed:home:#{account.id}")
-    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
diff --git a/package.json b/package.json
index b850e3976..ab9e3e061 100644
--- a/package.json
+++ b/package.json
@@ -60,11 +60,11 @@
   },
   "private": true,
   "dependencies": {
-    "@babel/core": "^7.11.1",
+    "@babel/core": "^7.11.6",
     "@babel/plugin-proposal-class-properties": "^7.8.3",
     "@babel/plugin-proposal-decorators": "^7.10.5",
     "@babel/plugin-transform-react-inline-elements": "^7.10.4",
-    "@babel/plugin-transform-runtime": "^7.11.0",
+    "@babel/plugin-transform-runtime": "^7.11.5",
     "@babel/preset-env": "^7.11.0",
     "@babel/preset-react": "^7.10.4",
     "@babel/runtime": "^7.11.2",
@@ -157,7 +157,7 @@
     "reselect": "^4.0.0",
     "rimraf": "^3.0.2",
     "sass": "^1.26.10",
-    "sass-loader": "^9.0.3",
+    "sass-loader": "^10.0.2",
     "stacktrace-js": "^2.0.2",
     "stringz": "^2.1.0",
     "substring-trie": "^1.0.2",
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index bb5bdfdc5..22c9ff31b 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -29,14 +29,14 @@ RSpec.describe FeedManager do
       it 'returns false for followee\'s status' do
         status = Fabricate(:status, text: 'Hello world', account: alice)
         bob.follow!(alice)
-        expect(FeedManager.instance.filter?(:home, status, bob.id)).to be false
+        expect(FeedManager.instance.filter?(:home, status, bob)).to be false
       end
 
       it 'returns false for reblog by followee' do
         status = Fabricate(:status, text: 'Hello world', account: jeff)
         reblog = Fabricate(:status, reblog: status, account: alice)
         bob.follow!(alice)
-        expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be false
+        expect(FeedManager.instance.filter?(:home, reblog, bob)).to be false
       end
 
       it 'returns true for reblog by followee of blocked account' do
@@ -44,7 +44,7 @@ RSpec.describe FeedManager do
         reblog = Fabricate(:status, reblog: status, account: alice)
         bob.follow!(alice)
         bob.block!(jeff)
-        expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
       end
 
       it 'returns true for reblog by followee of muted account' do
@@ -52,7 +52,7 @@ RSpec.describe FeedManager do
         reblog = Fabricate(:status, reblog: status, account: alice)
         bob.follow!(alice)
         bob.mute!(jeff)
-        expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
       end
 
       it 'returns true for reblog by followee of someone who is blocking recipient' do
@@ -60,14 +60,14 @@ RSpec.describe FeedManager do
         reblog = Fabricate(:status, reblog: status, account: alice)
         bob.follow!(alice)
         jeff.block!(bob)
-        expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
       end
 
       it 'returns true for reblog from account with reblogs disabled' do
         status = Fabricate(:status, text: 'Hello world', account: jeff)
         reblog = Fabricate(:status, reblog: status, account: alice)
         bob.follow!(alice, reblogs: false)
-        expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
       end
 
       it 'returns false for reply by followee to another followee' do
@@ -75,55 +75,55 @@ RSpec.describe FeedManager do
         reply  = Fabricate(:status, text: 'Nay', thread: status, account: alice)
         bob.follow!(alice)
         bob.follow!(jeff)
-        expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be false
+        expect(FeedManager.instance.filter?(:home, reply, bob)).to be false
       end
 
       it 'returns false for reply by followee to recipient' do
         status = Fabricate(:status, text: 'Hello world', account: bob)
         reply  = Fabricate(:status, text: 'Nay', thread: status, account: alice)
         bob.follow!(alice)
-        expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be false
+        expect(FeedManager.instance.filter?(:home, reply, bob)).to be false
       end
 
       it 'returns false for reply by followee to self' do
         status = Fabricate(:status, text: 'Hello world', account: alice)
         reply  = Fabricate(:status, text: 'Nay', thread: status, account: alice)
         bob.follow!(alice)
-        expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be false
+        expect(FeedManager.instance.filter?(:home, reply, bob)).to be false
       end
 
       it 'returns true for reply by followee to non-followed account' do
         status = Fabricate(:status, text: 'Hello world', account: jeff)
         reply  = Fabricate(:status, text: 'Nay', thread: status, account: alice)
         bob.follow!(alice)
-        expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, reply, bob)).to be true
       end
 
       it 'returns true for the second reply by followee to a non-federated status' do
         reply        = Fabricate(:status, text: 'Reply 1', reply: true, account: alice)
         second_reply = Fabricate(:status, text: 'Reply 2', thread: reply, account: alice)
         bob.follow!(alice)
-        expect(FeedManager.instance.filter?(:home, second_reply, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, second_reply, bob)).to be true
       end
 
       it 'returns false for status by followee mentioning another account' do
         bob.follow!(alice)
         status = PostStatusService.new.call(alice, text: 'Hey @jeff')
-        expect(FeedManager.instance.filter?(:home, status, bob.id)).to be false
+        expect(FeedManager.instance.filter?(:home, status, bob)).to be false
       end
 
       it 'returns true for status by followee mentioning blocked account' do
         bob.block!(jeff)
         bob.follow!(alice)
         status = PostStatusService.new.call(alice, text: 'Hey @jeff')
-        expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, status, bob)).to be true
       end
 
       it 'returns true for status by followee mentioning muted account' do
         bob.mute!(jeff)
         bob.follow!(alice)
         status = PostStatusService.new.call(alice, text: 'Hey @jeff')
-        expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:home, status, bob)).to be true
       end
 
       it 'returns true for reblog of a personally blocked domain' do
@@ -131,7 +131,7 @@ RSpec.describe FeedManager do
         alice.follow!(jeff)
         status = Fabricate(:status, text: 'Hello world', account: bob)
         reblog = Fabricate(:status, reblog: status, account: jeff)
-        expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
+        expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true
       end
 
       context 'for irreversibly muted phrases' do
@@ -139,7 +139,7 @@ RSpec.describe FeedManager do
           alice.custom_filters.create!(phrase: 'bob', context: %w(home), irreversible: true)
           alice.follow!(jeff)
           status = Fabricate(:status, text: 'bobcats', account: jeff)
-          expect(FeedManager.instance.filter?(:home, status, alice.id)).to be_falsy
+          expect(FeedManager.instance.filter?(:home, status, alice)).to be_falsy
         end
 
         it 'returns true if phrase is contained' do
@@ -147,14 +147,14 @@ RSpec.describe FeedManager do
           alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
           alice.follow!(jeff)
           status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff)
-          expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+          expect(FeedManager.instance.filter?(:home, status, alice)).to be true
         end
 
         it 'matches substrings if whole_word is false' do
           alice.custom_filters.create!(phrase: 'take', context: %w(home), whole_word: false, irreversible: true)
           alice.follow!(jeff)
           status = Fabricate(:status, text: 'shiitake', account: jeff)
-          expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+          expect(FeedManager.instance.filter?(:home, status, alice)).to be true
         end
 
         it 'returns true if phrase is contained in a poll option' do
@@ -162,7 +162,7 @@ RSpec.describe FeedManager do
           alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
           alice.follow!(jeff)
           status = Fabricate(:status, text: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff)
-          expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+          expect(FeedManager.instance.filter?(:home, status, alice)).to be true
         end
       end
     end
@@ -171,27 +171,27 @@ RSpec.describe FeedManager do
       it 'returns true for status that mentions blocked account' do
         bob.block!(jeff)
         status = PostStatusService.new.call(alice, text: 'Hey @jeff')
-        expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:mentions, status, bob)).to be true
       end
 
       it 'returns true for status that replies to a blocked account' do
         status = Fabricate(:status, text: 'Hello world', account: jeff)
         reply  = Fabricate(:status, text: 'Nay', thread: status, account: alice)
         bob.block!(jeff)
-        expect(FeedManager.instance.filter?(:mentions, reply, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:mentions, reply, bob)).to be true
       end
 
       it 'returns true for status by silenced account who recipient is not following' do
         status = Fabricate(:status, text: 'Hello world', account: alice)
         alice.silence!
-        expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
+        expect(FeedManager.instance.filter?(:mentions, status, bob)).to be true
       end
 
       it 'returns false for status by followed silenced account' do
         status = Fabricate(:status, text: 'Hello world', account: alice)
         alice.silence!
         bob.follow!(alice)
-        expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
+        expect(FeedManager.instance.filter?(:mentions, status, bob)).to be false
       end
     end
   end
@@ -421,52 +421,20 @@ RSpec.describe FeedManager do
     end
   end
 
-  describe '#merge_into_timeline' do
+  describe '#merge_into_home' do
     it "does not push source account's statuses whose reblogs are already inserted" do
       account = Fabricate(:account, id: 0)
       reblog = Fabricate(:status)
       status = Fabricate(:status, reblog: reblog)
       FeedManager.instance.push_to_home(account, status)
 
-      FeedManager.instance.merge_into_timeline(account, reblog.account)
+      FeedManager.instance.merge_into_home(account, reblog.account)
 
       expect(Redis.current.zscore("feed:home:0", reblog.id)).to eq nil
     end
   end
 
-  describe '#trim' do
-    let(:receiver) { Fabricate(:account) }
-
-    it 'cleans up reblog tracking keys' do
-      reblogged      = Fabricate(:status)
-      status         = Fabricate(:status, reblog: reblogged)
-      another_status = Fabricate(:status, reblog: reblogged)
-      reblogs_key    = FeedManager.instance.key('home', receiver.id, 'reblogs')
-      reblog_set_key = FeedManager.instance.key('home', receiver.id, "reblogs:#{reblogged.id}")
-
-      FeedManager.instance.push_to_home(receiver, status)
-      FeedManager.instance.push_to_home(receiver, another_status)
-
-      # We should have a tracking set and an entry in reblogs.
-      expect(Redis.current.exists?(reblog_set_key)).to be true
-      expect(Redis.current.zrange(reblogs_key, 0, -1)).to eq [reblogged.id.to_s]
-
-      # Push everything off the end of the feed.
-      FeedManager::MAX_ITEMS.times do
-        FeedManager.instance.push_to_home(receiver, Fabricate(:status))
-      end
-
-      # `trim` should be called automatically, but do it anyway, as
-      # we're testing `trim`, not side effects of `push`.
-      FeedManager.instance.trim('home', receiver.id)
-
-      # We should not have any reblog tracking data.
-      expect(Redis.current.exists?(reblog_set_key)).to be false
-      expect(Redis.current.zrange(reblogs_key, 0, -1)).to be_empty
-    end
-  end
-
-  describe '#unpush' do
+  describe '#unpush_from_home' do
     let(:receiver) { Fabricate(:account) }
 
     it 'leaves a reblogged status if original was on feed' do
@@ -532,7 +500,7 @@ RSpec.describe FeedManager do
     end
   end
 
-  describe '#clear_from_timeline' do
+  describe '#clear_from_home' do
     let(:account)          { Fabricate(:account) }
     let(:followed_account) { Fabricate(:account) }
     let(:target_account)   { Fabricate(:account) }
@@ -550,8 +518,8 @@ RSpec.describe FeedManager do
       end
     end
 
-    it 'correctly cleans the timeline' do
-      FeedManager.instance.clear_from_timeline(account, target_account)
+    it 'correctly cleans the home timeline' do
+      FeedManager.instance.clear_from_home(account, target_account)
 
       expect(Redis.current.zrange("feed:home:#{account.id}", 0, -1)).to eq [status_1.id.to_s, status_7.id.to_s]
     end
diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb
new file mode 100644
index 000000000..c251953a4
--- /dev/null
+++ b/spec/models/public_feed_spec.rb
@@ -0,0 +1,274 @@
+require 'rails_helper'
+
+RSpec.describe PublicFeed, type: :model do
+  let(:account) { Fabricate(:account) }
+
+  describe '#get' do
+    subject { described_class.new(nil).get(20).map(&:id) }
+
+    it 'only includes statuses with public visibility' do
+      public_status = Fabricate(:status, visibility: :public)
+      private_status = Fabricate(:status, visibility: :private)
+
+      expect(subject).to include(public_status.id)
+      expect(subject).not_to include(private_status.id)
+    end
+
+    it 'does not include replies' do
+      status = Fabricate(:status)
+      reply = Fabricate(:status, in_reply_to_id: status.id)
+
+      expect(subject).to include(status.id)
+      expect(subject).not_to include(reply.id)
+    end
+
+    it 'does not include boosts' do
+      status = Fabricate(:status)
+      boost = Fabricate(:status, reblog_of_id: status.id)
+
+      expect(subject).to include(status.id)
+      expect(subject).not_to include(boost.id)
+    end
+
+    it 'filters out silenced accounts' do
+      account = Fabricate(:account)
+      silenced_account = Fabricate(:account, silenced: true)
+      status = Fabricate(:status, account: account)
+      silenced_status = Fabricate(:status, account: silenced_account)
+
+      expect(subject).to include(status.id)
+      expect(subject).not_to include(silenced_status.id)
+    end
+
+    context 'without local_only option' do
+      let(:viewer) { nil }
+
+      let!(:local_account)  { Fabricate(:account, domain: nil) }
+      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
+      let!(:local_status)   { Fabricate(:status, account: local_account) }
+      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
+      let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) }
+
+      subject { described_class.new(viewer).get(20).map(&:id) }
+
+      context 'without a viewer' do
+        let(:viewer) { nil }
+
+        it 'includes remote instances statuses' do
+          expect(subject).to include(remote_status.id)
+        end
+
+        it 'includes local statuses' do
+          expect(subject).to include(local_status.id)
+        end
+
+        it 'does not include local-only statuses' do
+          expect(subject).not_to include(local_only_status.id)
+        end
+      end
+
+      context 'with a viewer' do
+        let(:viewer) { Fabricate(:account, username: 'viewer') }
+
+        it 'includes remote instances statuses' do
+          expect(subject).to include(remote_status.id)
+        end
+
+        it 'includes local statuses' do
+          expect(subject).to include(local_status.id)
+        end
+
+        it 'does not include local-only statuses' do
+          expect(subject).not_to include(local_only_status.id)
+        end
+      end
+    end
+
+    context 'without local_only option but allow_local_only' do
+      let(:viewer) { nil }
+
+      let!(:local_account)  { Fabricate(:account, domain: nil) }
+      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
+      let!(:local_status)   { Fabricate(:status, account: local_account) }
+      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
+      let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) }
+
+      subject { described_class.new(viewer, allow_local_only: true).get(20).map(&:id) }
+
+      context 'without a viewer' do
+        let(:viewer) { nil }
+
+        it 'includes remote instances statuses' do
+          expect(subject).to include(remote_status.id)
+        end
+
+        it 'includes local statuses' do
+          expect(subject).to include(local_status.id)
+        end
+
+        it 'does not include local-only statuses' do
+          expect(subject).not_to include(local_only_status.id)
+        end
+      end
+
+      context 'with a viewer' do
+        let(:viewer) { Fabricate(:account, username: 'viewer') }
+
+        it 'includes remote instances statuses' do
+          expect(subject).to include(remote_status.id)
+        end
+
+        it 'includes local statuses' do
+          expect(subject).to include(local_status.id)
+        end
+
+        it 'includes local-only statuses' do
+          expect(subject).to include(local_only_status.id)
+        end
+      end
+    end
+
+    context 'with a local_only option set' do
+      let!(:local_account)  { Fabricate(:account, domain: nil) }
+      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
+      let!(:local_status)   { Fabricate(:status, account: local_account) }
+      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
+      let!(:local_only_status) { Fabricate(:status, account: local_account, local_only: true) }
+
+      subject { described_class.new(viewer, local: true).get(20).map(&:id) }
+
+      context 'without a viewer' do
+        let(:viewer) { nil }
+
+        it 'does not include remote instances statuses' do
+          expect(subject).to include(local_status.id)
+          expect(subject).not_to include(remote_status.id)
+        end
+
+        it 'does not include local-only statuses' do
+          expect(subject).not_to include(local_only_status.id)
+        end
+      end
+
+      context 'with a viewer' do
+        let(:viewer) { Fabricate(:account, username: 'viewer') }
+
+        it 'does not include remote instances statuses' do
+          expect(subject).to include(local_status.id)
+          expect(subject).not_to include(remote_status.id)
+        end
+
+        it 'is not affected by personal domain blocks' do
+          viewer.block_domain!('test.com')
+          expect(subject).to include(local_status.id)
+          expect(subject).not_to include(remote_status.id)
+        end
+
+        it 'includes local-only statuses' do
+          expect(subject).to include(local_only_status.id)
+        end
+      end
+    end
+
+    context 'with a remote_only option set' do
+      let!(:local_account)  { Fabricate(:account, domain: nil) }
+      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
+      let!(:local_status)   { Fabricate(:status, account: local_account) }
+      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
+
+      subject { described_class.new(viewer, remote: true).get(20).map(&:id) }
+
+      context 'without a viewer' do
+        let(:viewer) { nil }
+
+        it 'does not include local instances statuses' do
+          expect(subject).not_to include(local_status.id)
+          expect(subject).to include(remote_status.id)
+        end
+      end
+
+      context 'with a viewer' do
+        let(:viewer) { Fabricate(:account, username: 'viewer') }
+
+        it 'does not include local instances statuses' do
+          expect(subject).not_to include(local_status.id)
+          expect(subject).to include(remote_status.id)
+        end
+      end
+    end
+
+    describe 'with an account passed in' do
+      before do
+        @account = Fabricate(:account)
+      end
+
+      subject { described_class.new(@account).get(20).map(&:id) }
+
+      it 'excludes statuses from accounts blocked by the account' do
+        blocked = Fabricate(:account)
+        @account.block!(blocked)
+        blocked_status = Fabricate(:status, account: blocked)
+
+        expect(subject).not_to include(blocked_status.id)
+      end
+
+      it 'excludes statuses from accounts who have blocked the account' do
+        blocker = Fabricate(:account)
+        blocker.block!(@account)
+        blocked_status = Fabricate(:status, account: blocker)
+
+        expect(subject).not_to include(blocked_status.id)
+      end
+
+      it 'excludes statuses from accounts muted by the account' do
+        muted = Fabricate(:account)
+        @account.mute!(muted)
+        muted_status = Fabricate(:status, account: muted)
+
+        expect(subject).not_to include(muted_status.id)
+      end
+
+      it 'excludes statuses from accounts from personally blocked domains' do
+        blocked = Fabricate(:account, domain: 'example.com')
+        @account.block_domain!(blocked.domain)
+        blocked_status = Fabricate(:status, account: blocked)
+
+        expect(subject).not_to include(blocked_status.id)
+      end
+
+      context 'with language preferences' do
+        it 'excludes statuses in languages not allowed by the account user' do
+          user = Fabricate(:user, chosen_languages: [:en, :es])
+          @account.update(user: user)
+          en_status = Fabricate(:status, language: 'en')
+          es_status = Fabricate(:status, language: 'es')
+          fr_status = Fabricate(:status, language: 'fr')
+
+          expect(subject).to include(en_status.id)
+          expect(subject).to include(es_status.id)
+          expect(subject).not_to include(fr_status.id)
+        end
+
+        it 'includes all languages when user does not have a setting' do
+          user = Fabricate(:user, chosen_languages: nil)
+          @account.update(user: user)
+
+          en_status = Fabricate(:status, language: 'en')
+          es_status = Fabricate(:status, language: 'es')
+
+          expect(subject).to include(en_status.id)
+          expect(subject).to include(es_status.id)
+        end
+
+        it 'includes all languages when account does not have a user' do
+          expect(@account.user).to be_nil
+          en_status = Fabricate(:status, language: 'en')
+          es_status = Fabricate(:status, language: 'es')
+
+          expect(subject).to include(en_status.id)
+          expect(subject).to include(es_status.id)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 041021d34..c1375ea94 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -354,288 +354,6 @@ RSpec.describe Status, type: :model do
     end
   end
 
-  describe '.as_public_timeline' do
-    it 'only includes statuses with public visibility' do
-      public_status = Fabricate(:status, visibility: :public)
-      private_status = Fabricate(:status, visibility: :private)
-
-      results = Status.as_public_timeline
-      expect(results).to include(public_status)
-      expect(results).not_to include(private_status)
-    end
-
-    it 'does not include replies' do
-      status = Fabricate(:status)
-      reply = Fabricate(:status, in_reply_to_id: status.id)
-
-      results = Status.as_public_timeline
-      expect(results).to include(status)
-      expect(results).not_to include(reply)
-    end
-
-    it 'does not include boosts' do
-      status = Fabricate(:status)
-      boost = Fabricate(:status, reblog_of_id: status.id)
-
-      results = Status.as_public_timeline
-      expect(results).to include(status)
-      expect(results).not_to include(boost)
-    end
-
-    it 'filters out silenced accounts' do
-      account = Fabricate(:account)
-      silenced_account = Fabricate(:account, silenced: true)
-      status = Fabricate(:status, account: account)
-      silenced_status = Fabricate(:status, account: silenced_account)
-
-      results = Status.as_public_timeline
-      expect(results).to include(status)
-      expect(results).not_to include(silenced_status)
-    end
-
-    context 'without local_only option' do
-      let(:viewer) { nil }
-
-      let!(:local_account)  { Fabricate(:account, domain: nil) }
-      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
-      let!(:local_status)   { Fabricate(:status, account: local_account) }
-      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
-
-      subject { Status.as_public_timeline(viewer, false) }
-
-      context 'without a viewer' do
-        let(:viewer) { nil }
-
-        it 'includes remote instances statuses' do
-          expect(subject).to include(remote_status)
-        end
-
-        it 'includes local statuses' do
-          expect(subject).to include(local_status)
-        end
-      end
-
-      context 'with a viewer' do
-        let(:viewer) { Fabricate(:account, username: 'viewer') }
-
-        it 'includes remote instances statuses' do
-          expect(subject).to include(remote_status)
-        end
-
-        it 'includes local statuses' do
-          expect(subject).to include(local_status)
-        end
-      end
-    end
-
-    context 'with a local_only option set' do
-      let!(:local_account)  { Fabricate(:account, domain: nil) }
-      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
-      let!(:local_status)   { Fabricate(:status, account: local_account) }
-      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
-
-      subject { Status.as_public_timeline(viewer, true) }
-
-      context 'without a viewer' do
-        let(:viewer) { nil }
-
-        it 'does not include remote instances statuses' do
-          expect(subject).to include(local_status)
-          expect(subject).not_to include(remote_status)
-        end
-      end
-
-      context 'with a viewer' do
-        let(:viewer) { Fabricate(:account, username: 'viewer') }
-
-        it 'does not include remote instances statuses' do
-          expect(subject).to include(local_status)
-          expect(subject).not_to include(remote_status)
-        end
-
-        it 'is not affected by personal domain blocks' do
-          viewer.block_domain!('test.com')
-          expect(subject).to include(local_status)
-          expect(subject).not_to include(remote_status)
-        end
-      end
-    end
-
-    context 'with a remote_only option set' do
-      let!(:local_account)  { Fabricate(:account, domain: nil) }
-      let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
-      let!(:local_status)   { Fabricate(:status, account: local_account) }
-      let!(:remote_status)  { Fabricate(:status, account: remote_account) }
-
-      subject { Status.as_public_timeline(viewer, :remote) }
-
-      context 'without a viewer' do
-        let(:viewer) { nil }
-
-        it 'does not include local instances statuses' do
-          expect(subject).not_to include(local_status)
-          expect(subject).to include(remote_status)
-        end
-      end
-
-      context 'with a viewer' do
-        let(:viewer) { Fabricate(:account, username: 'viewer') }
-
-        it 'does not include local instances statuses' do
-          expect(subject).not_to include(local_status)
-          expect(subject).to include(remote_status)
-        end
-      end
-    end
-
-    describe 'with an account passed in' do
-      before do
-        @account = Fabricate(:account)
-      end
-
-      it 'excludes statuses from accounts blocked by the account' do
-        blocked = Fabricate(:account)
-        Fabricate(:block, account: @account, target_account: blocked)
-        blocked_status = Fabricate(:status, account: blocked)
-
-        results = Status.as_public_timeline(@account)
-        expect(results).not_to include(blocked_status)
-      end
-
-      it 'excludes statuses from accounts who have blocked the account' do
-        blocked = Fabricate(:account)
-        Fabricate(:block, account: blocked, target_account: @account)
-        blocked_status = Fabricate(:status, account: blocked)
-
-        results = Status.as_public_timeline(@account)
-        expect(results).not_to include(blocked_status)
-      end
-
-      it 'excludes statuses from accounts muted by the account' do
-        muted = Fabricate(:account)
-        Fabricate(:mute, account: @account, target_account: muted)
-        muted_status = Fabricate(:status, account: muted)
-
-        results = Status.as_public_timeline(@account)
-        expect(results).not_to include(muted_status)
-      end
-
-      it 'excludes statuses from accounts from personally blocked domains' do
-        blocked = Fabricate(:account, domain: 'example.com')
-        @account.block_domain!(blocked.domain)
-        blocked_status = Fabricate(:status, account: blocked)
-
-        results = Status.as_public_timeline(@account)
-        expect(results).not_to include(blocked_status)
-      end
-
-      context 'with language preferences' do
-        it 'excludes statuses in languages not allowed by the account user' do
-          user = Fabricate(:user, chosen_languages: [:en, :es])
-          @account.update(user: user)
-          en_status = Fabricate(:status, language: 'en')
-          es_status = Fabricate(:status, language: 'es')
-          fr_status = Fabricate(:status, language: 'fr')
-
-          results = Status.as_public_timeline(@account)
-          expect(results).to include(en_status)
-          expect(results).to include(es_status)
-          expect(results).not_to include(fr_status)
-        end
-
-        it 'includes all languages when user does not have a setting' do
-          user = Fabricate(:user, chosen_languages: nil)
-          @account.update(user: user)
-
-          en_status = Fabricate(:status, language: 'en')
-          es_status = Fabricate(:status, language: 'es')
-
-          results = Status.as_public_timeline(@account)
-          expect(results).to include(en_status)
-          expect(results).to include(es_status)
-        end
-
-        it 'includes all languages when account does not have a user' do
-          expect(@account.user).to be_nil
-          en_status = Fabricate(:status, language: 'en')
-          es_status = Fabricate(:status, language: 'es')
-
-          results = Status.as_public_timeline(@account)
-          expect(results).to include(en_status)
-          expect(results).to include(es_status)
-        end
-      end
-    end
-
-    context 'with local-only statuses' do
-      let(:status) { Fabricate(:status, local_only: true) }
-
-      subject { Status.as_public_timeline(viewer) }
-
-      context 'without a viewer' do
-        let(:viewer) { nil }
-
-        it 'excludes local-only statuses' do
-          expect(subject).to_not include(status)
-        end
-      end
-
-      context 'with a viewer' do
-        let(:viewer) { Fabricate(:account, username: 'viewer') }
-
-        it 'includes local-only statuses' do
-          expect(subject).to include(status)
-        end
-      end
-
-      # TODO: What happens if the viewer is remote?
-      # Can the viewer be remote?
-      # What prevents the viewer from being remote?
-    end
-  end
-
-  describe '.as_tag_timeline' do
-    it 'includes statuses with a tag' do
-      tag = Fabricate(:tag)
-      status = Fabricate(:status, tags: [tag])
-      other = Fabricate(:status)
-
-      results = Status.as_tag_timeline(tag)
-      expect(results).to include(status)
-      expect(results).not_to include(other)
-    end
-
-    it 'allows replies to be included' do
-      original = Fabricate(:status)
-      tag = Fabricate(:tag)
-      status = Fabricate(:status, tags: [tag], in_reply_to_id: original.id)
-
-      results = Status.as_tag_timeline(tag)
-      expect(results).to include(status)
-    end
-
-    context 'on a local-only status' do
-      let(:tag) { Fabricate(:tag) }
-      let(:status) { Fabricate(:status, local_only: true, tags: [tag]) }
-
-      context 'without a viewer' do
-        let(:viewer) { nil }
-
-        it 'filters the local-only status out of the result set' do
-          expect(Status.as_tag_timeline(tag, viewer)).not_to include(status)
-        end
-      end
-
-      context 'with a viewer' do
-        let(:viewer) { Fabricate(:account, username: 'viewer', domain: nil) }
-
-        it 'keeps the local-only status in the result set' do
-          expect(Status.as_tag_timeline(tag, viewer)).to include(status)
-        end
-      end
-    end
-  end
-
   describe '.permitted_for' do
     subject { described_class.permitted_for(target_account, account).pluck(:visibility) }
 
diff --git a/spec/services/hashtag_query_service_spec.rb b/spec/models/tag_feed_spec.rb
index 24282d2f0..76277c467 100644
--- a/spec/services/hashtag_query_service_spec.rb
+++ b/spec/models/tag_feed_spec.rb
@@ -1,7 +1,7 @@
 require 'rails_helper'
 
-describe HashtagQueryService, type: :service do
-  describe '.call' do
+describe TagFeed, type: :service do
+  describe '#get' do
     let(:account) { Fabricate(:account) }
     let(:tag1) { Fabricate(:tag) }
     let(:tag2) { Fabricate(:tag) }
@@ -10,35 +10,35 @@ describe HashtagQueryService, type: :service do
     let!(:both) { Fabricate(:status, tags: [tag1, tag2]) }
 
     it 'can add tags in "any" mode' do
-      results = subject.call(tag1, { any: [tag2.name] })
+      results = described_class.new(tag1, nil, any: [tag2.name]).get(20)
       expect(results).to include status1
       expect(results).to include status2
       expect(results).to include both
     end
 
     it 'can remove tags in "all" mode' do
-      results = subject.call(tag1, { all: [tag2.name] })
+      results = described_class.new(tag1, nil, all: [tag2.name]).get(20)
       expect(results).to_not include status1
       expect(results).to_not include status2
       expect(results).to     include both
     end
 
     it 'can remove tags in "none" mode' do
-      results = subject.call(tag1, { none: [tag2.name] })
+      results = described_class.new(tag1, nil, none: [tag2.name]).get(20)
       expect(results).to     include status1
       expect(results).to_not include status2
       expect(results).to_not include both
     end
 
     it 'ignores an invalid mode' do
-      results = subject.call(tag1, { wark: [tag2.name] })
+      results = described_class.new(tag1, nil, wark: [tag2.name]).get(20)
       expect(results).to     include status1
       expect(results).to_not include status2
       expect(results).to     include both
     end
 
     it 'handles being passed non existant tag names' do
-      results = subject.call(tag1, { any: ['wark'] })
+      results = described_class.new(tag1, nil, any: ['wark']).get(20)
       expect(results).to     include status1
       expect(results).to_not include status2
       expect(results).to     include both
@@ -46,15 +46,37 @@ describe HashtagQueryService, type: :service do
 
     it 'can restrict to an account' do
       BlockService.new.call(account, status1.account)
-      results = subject.call(tag1, { none: [tag2.name] }, account)
+      results = described_class.new(tag1, account, none: [tag2.name]).get(20)
       expect(results).to_not include status1
     end
 
     it 'can restrict to local' do
       status1.account.update(domain: 'example.com')
       status1.update(local: false, uri: 'example.com/toot')
-      results = subject.call(tag1, { any: [tag2.name] }, nil, true)
+      results = described_class.new(tag1, nil, any: [tag2.name], local: true).get(20)
       expect(results).to_not include status1
     end
+
+    it 'allows replies to be included' do
+      original = Fabricate(:status)
+      status = Fabricate(:status, tags: [tag1], in_reply_to_id: original.id)
+
+      results = described_class.new(tag1, nil).get(20)
+      expect(results).to include(status)
+    end
+
+    context 'on a local-only status' do
+      let!(:status) { Fabricate(:status, tags: [tag1], local_only: true) }
+
+      it 'does not show local-only statuses without a viewer' do
+        results = described_class.new(tag1, nil).get(20)
+        expect(results).to_not include(status)
+      end
+
+      it 'shows local-only statuses given a viewer' do
+        results = described_class.new(tag1, account).get(20)
+        expect(results).to include(status)
+      end
+    end
   end
 end
diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb
index b7fc7f7ed..538dc2592 100644
--- a/spec/services/fan_out_on_write_service_spec.rb
+++ b/spec/services/fan_out_on_write_service_spec.rb
@@ -28,10 +28,10 @@ RSpec.describe FanOutOnWriteService, type: :service do
   end
 
   it 'delivers status to hashtag' do
-    expect(Tag.find_by!(name: 'test').statuses.pluck(:id)).to include status.id
+    expect(TagFeed.new(Tag.find_by(name: 'test'), alice).get(20).map(&:id)).to include status.id
   end
 
   it 'delivers status to public timeline' do
-    expect(Status.as_public_timeline(alice).map(&:id)).to include status.id
+    expect(PublicFeed.new(alice).get(20).map(&:id)).to include status.id
   end
 end
diff --git a/yarn.lock b/yarn.lock
index b9423b781..99df34583 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18,41 +18,19 @@
     invariant "^2.2.4"
     semver "^5.5.0"
 
-"@babel/core@^7.1.0", "@babel/core@^7.7.5":
-  version "7.11.4"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.11.4.tgz#4301dfdfafa01eeb97f1896c5501a3f0655d4229"
-  integrity sha512-5deljj5HlqRXN+5oJTY7Zs37iH3z3b++KjiKtIsJy1NrjOOVSEaJHEetLBhyu0aQOSNNZ/0IuEAan9GzRuDXHg==
+"@babel/core@^7.1.0", "@babel/core@^7.11.6", "@babel/core@^7.7.2", "@babel/core@^7.7.5":
+  version "7.11.6"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.11.6.tgz#3a9455dc7387ff1bac45770650bc13ba04a15651"
+  integrity sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==
   dependencies:
     "@babel/code-frame" "^7.10.4"
-    "@babel/generator" "^7.11.4"
+    "@babel/generator" "^7.11.6"
     "@babel/helper-module-transforms" "^7.11.0"
     "@babel/helpers" "^7.10.4"
-    "@babel/parser" "^7.11.4"
+    "@babel/parser" "^7.11.5"
     "@babel/template" "^7.10.4"
-    "@babel/traverse" "^7.11.0"
-    "@babel/types" "^7.11.0"
-    convert-source-map "^1.7.0"
-    debug "^4.1.0"
-    gensync "^1.0.0-beta.1"
-    json5 "^2.1.2"
-    lodash "^4.17.19"
-    resolve "^1.3.2"
-    semver "^5.4.1"
-    source-map "^0.5.0"
-
-"@babel/core@^7.11.1", "@babel/core@^7.7.2":
-  version "7.11.1"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.11.1.tgz#2c55b604e73a40dc21b0e52650b11c65cf276643"
-  integrity sha512-XqF7F6FWQdKGGWAzGELL+aCO1p+lRY5Tj5/tbT3St1G8NaH70jhhDIKknIZaDans0OQBG5wRAldROLHSt44BgQ==
-  dependencies:
-    "@babel/code-frame" "^7.10.4"
-    "@babel/generator" "^7.11.0"
-    "@babel/helper-module-transforms" "^7.11.0"
-    "@babel/helpers" "^7.10.4"
-    "@babel/parser" "^7.11.1"
-    "@babel/template" "^7.10.4"
-    "@babel/traverse" "^7.11.0"
-    "@babel/types" "^7.11.0"
+    "@babel/traverse" "^7.11.5"
+    "@babel/types" "^7.11.5"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.1"
@@ -62,12 +40,12 @@
     semver "^5.4.1"
     source-map "^0.5.0"
 
-"@babel/generator@^7.11.0", "@babel/generator@^7.11.4":
-  version "7.11.4"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.11.4.tgz#1ec7eec00defba5d6f83e50e3ee72ae2fee482be"
-  integrity sha512-Rn26vueFx0eOoz7iifCN2UHT6rGtnkSGWSoDRIy8jZN3B91PzeSULbswfLoOWuTuAcNwpG/mxy+uCTDnZ9Mp1g==
+"@babel/generator@^7.11.5", "@babel/generator@^7.11.6":
+  version "7.11.6"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.11.6.tgz#b868900f81b163b4d464ea24545c61cbac4dc620"
+  integrity sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==
   dependencies:
-    "@babel/types" "^7.11.0"
+    "@babel/types" "^7.11.5"
     jsesc "^2.5.1"
     source-map "^0.5.0"
 
@@ -311,15 +289,10 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.11.0", "@babel/parser@^7.11.4":
-  version "7.11.4"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.4.tgz#6fa1a118b8b0d80d0267b719213dc947e88cc0ca"
-  integrity sha512-MggwidiH+E9j5Sh8pbrX5sJvMcsqS5o+7iB42M9/k0CD63MjYbdP4nhSh7uB5wnv2/RVzTZFTxzF/kIa5mrCqA==
-
-"@babel/parser@^7.11.1", "@babel/parser@^7.7.0":
-  version "7.11.3"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.3.tgz#9e1eae46738bcd08e23e867bab43e7b95299a8f9"
-  integrity sha512-REo8xv7+sDxkKvoxEywIdsNFiZLybwdI7hcT5uEPyQrSMB4YQ973BfC9OOrD/81MaIjh6UxdulIQXkjmiH3PcA==
+"@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.11.5", "@babel/parser@^7.7.0":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037"
+  integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q==
 
 "@babel/plugin-proposal-async-generator-functions@^7.10.4":
   version "7.10.4"
@@ -806,10 +779,10 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-runtime@^7.11.0":
-  version "7.11.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.11.0.tgz#e27f78eb36f19448636e05c33c90fd9ad9b8bccf"
-  integrity sha512-LFEsP+t3wkYBlis8w6/kmnd6Kb1dxTd+wGJ8MlxTGzQo//ehtqlVL4S9DNUa53+dtPSQobN2CXx4d81FqC58cw==
+"@babel/plugin-transform-runtime@^7.11.5":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.11.5.tgz#f108bc8e0cf33c37da031c097d1df470b3a293fc"
+  integrity sha512-9aIoee+EhjySZ6vY5hnLjigHzunBlscx9ANKutkeWTJTx6m5Rbq6Ic01tLvO54lSusR+BxV7u4UDdCmXv5aagg==
   dependencies:
     "@babel/helper-module-imports" "^7.10.4"
     "@babel/helper-plugin-utils" "^7.10.4"
@@ -998,25 +971,25 @@
     "@babel/parser" "^7.10.4"
     "@babel/types" "^7.10.4"
 
-"@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.11.0", "@babel/traverse@^7.7.0":
-  version "7.11.0"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.11.0.tgz#9b996ce1b98f53f7c3e4175115605d56ed07dd24"
-  integrity sha512-ZB2V+LskoWKNpMq6E5UUCrjtDUh5IOTAyIl0dTjIEoXum/iKWkoIEKIRDnUucO6f+2FzNkE0oD4RLKoPIufDtg==
+"@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5", "@babel/traverse@^7.7.0":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3"
+  integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ==
   dependencies:
     "@babel/code-frame" "^7.10.4"
-    "@babel/generator" "^7.11.0"
+    "@babel/generator" "^7.11.5"
     "@babel/helper-function-name" "^7.10.4"
     "@babel/helper-split-export-declaration" "^7.11.0"
-    "@babel/parser" "^7.11.0"
-    "@babel/types" "^7.11.0"
+    "@babel/parser" "^7.11.5"
+    "@babel/types" "^7.11.5"
     debug "^4.1.0"
     globals "^11.1.0"
     lodash "^4.17.19"
 
-"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
-  version "7.11.0"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.0.tgz#2ae6bf1ba9ae8c3c43824e5861269871b206e90d"
-  integrity sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA==
+"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d"
+  integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q==
   dependencies:
     "@babel/helper-validator-identifier" "^7.10.4"
     lodash "^4.17.19"
@@ -1493,10 +1466,10 @@
     jest-diff "^25.2.1"
     pretty-format "^25.2.1"
 
-"@types/json-schema@^7.0.4":
-  version "7.0.4"
-  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
-  integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
+"@types/json-schema@^7.0.5":
+  version "7.0.6"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
+  integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==
 
 "@types/json5@^0.0.29":
   version "0.0.29"
@@ -1798,6 +1771,11 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1:
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da"
   integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==
 
+ajv-keywords@^3.5.2:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
+  integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
+
 ajv@^4.7.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536"
@@ -1806,7 +1784,7 @@ ajv@^4.7.0:
     co "^4.6.0"
     json-stable-stringify "^1.0.1"
 
-ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.9.1:
+ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.9.1:
   version "6.12.4"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234"
   integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ==
@@ -2528,12 +2506,12 @@ browserify-zlib@^0.2.0:
     pako "~1.0.5"
 
 browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5:
-  version "4.14.0"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.0.tgz#2908951abfe4ec98737b72f34c3bcedc8d43b000"
-  integrity sha512-pUsXKAF2lVwhmtpeA3LJrZ76jXuusrNyhduuQs7CDFf9foT4Y38aQOserd2lMe5DSSrjf3fx34oHwryuvxAUgQ==
+  version "4.14.1"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.1.tgz#cb2b490ba881d45dc3039078c7ed04411eaf3fa3"
+  integrity sha512-zyBTIHydW37pnb63c7fHFXUG6EcqWOqoMdDx6cdyaDFriZ20EoVxcE95S54N+heRqY8m8IUgB5zYta/gCwSaaA==
   dependencies:
-    caniuse-lite "^1.0.30001111"
-    electron-to-chromium "^1.3.523"
+    caniuse-lite "^1.0.30001124"
+    electron-to-chromium "^1.3.562"
     escalade "^3.0.2"
     node-releases "^1.1.60"
 
@@ -2703,10 +2681,10 @@ caniuse-api@^3.0.0:
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001111:
-  version "1.0.30001120"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001120.tgz#cd21d35e537214e19f7b9f4f161f7b0f2710d46c"
-  integrity sha512-JBP68okZs1X8D7MQTY602jxMYBmXEKOFkzTBaNSkubooMPFOAv2TXWaKle7qgHpjLDhUzA/TMT0qsNleVyXGUQ==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001124:
+  version "1.0.30001124"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001124.tgz#5d9998190258e11630d674fc50ea8e579ae0ced2"
+  integrity sha512-zQW8V3CdND7GHRH6rxm6s59Ww4g/qGWTheoboW9nfeMg7sUoopIfKCcNZUjwYRCOrvereh3kwDpZj4VLQ7zGtA==
 
 capture-exit@^2.0.0:
   version "2.0.0"
@@ -3826,10 +3804,10 @@ ejs@^2.3.4, ejs@^2.6.1:
   resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
   integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
 
-electron-to-chromium@^1.3.523:
-  version "1.3.545"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.545.tgz#d9add694c78554b8c00bc6e6fc929d5ccd7d1b99"
-  integrity sha512-+0R/i17u5E1cwF3g0W8Niq3UUKTUMyyT4kLkutZUHG8mDNvFsAckK3HIanzGVtixe3b6rknD8k7gHiR6nKFkgg==
+electron-to-chromium@^1.3.562:
+  version "1.3.562"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.562.tgz#79c20277ee1c8d0173a22af00e38433b752bc70f"
+  integrity sha512-WhRe6liQ2q/w1MZc8mD8INkenHivuHdrr4r5EQHNomy3NJux+incP6M6lDMd0paShP3MD0WGe5R1TWmEClf+Bg==
 
 elliptic@^6.5.3:
   version "6.5.3"
@@ -4298,21 +4276,21 @@ esquery@^1.2.0:
     estraverse "^5.1.0"
 
 esrecurse@^4.1.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf"
-  integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
+  integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
   dependencies:
-    estraverse "^4.1.0"
+    estraverse "^5.2.0"
 
-estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
+estraverse@^4.1.1, estraverse@^4.2.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
   integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
 
-estraverse@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642"
-  integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==
+estraverse@^5.1.0, estraverse@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
+  integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
 
 esutils@^2.0.2:
   version "2.0.3"
@@ -4333,9 +4311,9 @@ event-emitter@~0.3.5:
     es5-ext "~0.10.14"
 
 eventemitter3@^4.0.0:
-  version "4.0.5"
-  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.5.tgz#51d81e4f1ccc8311a04f0c20121ea824377ea6d9"
-  integrity sha512-QR0rh0YiPuxuDQ6+T9GAO/xWTExXpxIes1Nl9RykNGTnE1HJmkuEfxJH9cubjIOQZ/GH4qNBR4u8VSHaKiWs4g==
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
+  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
 
 events@^3.0.0:
   version "3.2.0"
@@ -6625,10 +6603,10 @@ kleur@^3.0.3:
   resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
   integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
 
-klona@^1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/klona/-/klona-1.1.2.tgz#a79e292518a5a5412ec8d097964bff1571a64db0"
-  integrity sha512-xf88rTeHiXk+XE2Vhi6yj8Wm3gMZrygGdKjJqN8HkV+PwF/t50/LdAKHoHpPcxFAlmQszTZ1CugrK25S7qDRLA==
+klona@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.3.tgz#98274552c513583ad7a01456a789a2a0b4a2a538"
+  integrity sha512-CgPOT3ZadDpXxKcfV56lEQ9OQSZ42Mk26gnozI+uN/k39vzD8toUhRQoqsX0m9Q3eMPEfsLWmtyUpK/yqST4yg==
 
 knot.js@^1.1.5:
   version "1.1.5"
@@ -7086,7 +7064,7 @@ minipass@^3.0.0, minipass@^3.1.1:
   dependencies:
     yallist "^4.0.0"
 
-minizlib@^2.1.0:
+minizlib@^2.1.1:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
   integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
@@ -8001,9 +7979,9 @@ posix-character-classes@^0.1.0:
   integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
 
 postcss-calc@^7.0.1:
-  version "7.0.3"
-  resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.3.tgz#d65cca92a3c52bf27ad37a5f732e0587b74f1623"
-  integrity sha512-IB/EAEmZhIMEIhG7Ov4x+l47UaXOS1n2f4FBUk/aKllQhtSCxWhTzn0nJgkqN7fo/jcWySvWTSB6Syk9L+31bA==
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.4.tgz#5e177ddb417341e6d4a193c5d9fd8ada79094f8b"
+  integrity sha512-0I79VRAd1UTkaHzY9w83P39YGO/M3bG7/tNLrHGEunBolfoGM0hSjrGvjoeaj0JE/zIw5GsI2KZ0UwDJqv5hjw==
   dependencies:
     postcss "^7.0.27"
     postcss-selector-parser "^6.0.2"
@@ -8365,9 +8343,9 @@ postgres-bytea@~1.0.0:
   integrity sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=
 
 postgres-date@~1.0.0:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.6.tgz#4925e8085b30c2ba1a06ac91b9a3473954a2ce2d"
-  integrity sha512-o2a4gxeFcox+CgB3Ig/kNHBP23PiEXHCXx7pcIIsvzoNz4qv+lKTyiSkjOXIMNUl12MO/mOYl2K6wR9X5K6Plg==
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8"
+  integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==
 
 postgres-interval@^1.1.0:
   version "1.2.0"
@@ -9435,15 +9413,15 @@ sass-lint@^1.13.1:
     path-is-absolute "^1.0.0"
     util "^0.10.3"
 
-sass-loader@^9.0.3:
-  version "9.0.3"
-  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-9.0.3.tgz#086adcf0bfdcc9d920413e2cdc3ba3321373d547"
-  integrity sha512-fOwsP98ac1VMme+V3+o0HaaMHp8Q/C9P+MUazLFVi3Jl7ORGHQXL1XeRZt3zLSGZQQPC8xE42Y2WptItvGjDQg==
+sass-loader@^10.0.2:
+  version "10.0.2"
+  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-10.0.2.tgz#c7b73010848b264792dd45372eea0b87cba4401e"
+  integrity sha512-wV6NDUVB8/iEYMalV/+139+vl2LaRFlZGEd5/xmdcdzQcgmis+npyco6NsDTVOlNA3y2NV9Gcz+vHyFMIT+ffg==
   dependencies:
-    klona "^1.1.2"
+    klona "^2.0.3"
     loader-utils "^2.0.0"
     neo-async "^2.6.2"
-    schema-utils "^2.7.0"
+    schema-utils "^2.7.1"
     semver "^7.3.2"
 
 sass@^1.26.10:
@@ -9482,14 +9460,14 @@ schema-utils@^1.0.0:
     ajv-errors "^1.0.0"
     ajv-keywords "^3.1.0"
 
-schema-utils@^2.2.0, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0:
-  version "2.7.0"
-  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7"
-  integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==
+schema-utils@^2.2.0, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0, schema-utils@^2.7.1:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7"
+  integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==
   dependencies:
-    "@types/json-schema" "^7.0.4"
-    ajv "^6.12.2"
-    ajv-keywords "^3.4.1"
+    "@types/json-schema" "^7.0.5"
+    ajv "^6.12.4"
+    ajv-keywords "^3.5.2"
 
 scroll-behavior@^0.9.1:
   version "0.9.12"
@@ -10303,14 +10281,14 @@ tapable@^1.0.0, tapable@^1.1.3:
   integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
 
 tar@^6.0.2:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39"
-  integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg==
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f"
+  integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==
   dependencies:
     chownr "^2.0.0"
     fs-minipass "^2.0.0"
     minipass "^3.0.0"
-    minizlib "^2.1.0"
+    minizlib "^2.1.1"
     mkdirp "^1.0.3"
     yallist "^4.0.0"