]> cat aescling's git repositories - mastodon.git/commitdiff
Merge branch 'master' into glitch-soc/merge-upstream
authorThibaut Girka <thib@sitedethib.com>
Tue, 8 Sep 2020 14:01:55 +0000 (16:01 +0200)
committerThibaut Girka <thib@sitedethib.com>
Tue, 8 Sep 2020 14:26:47 +0000 (16:26 +0200)
Conflicts:
- app/controllers/api/v1/timelines/public_controller.rb
- app/lib/feed_manager.rb
- app/models/status.rb
- app/services/precompute_feed_service.rb
- app/workers/feed_insert_worker.rb
- spec/models/status_spec.rb

All conflicts are due to upstream refactoring feed management and us having
local-only toots on top of that. Rewrote local-only toots management for
upstream's changes.

16 files changed:
1  2 
Gemfile
Gemfile.lock
app/controllers/api/v1/timelines/public_controller.rb
app/controllers/tags_controller.rb
app/lib/feed_manager.rb
app/models/public_feed.rb
app/models/status.rb
app/models/tag_feed.rb
app/services/precompute_feed_service.rb
app/workers/feed_insert_worker.rb
package.json
spec/lib/feed_manager_spec.rb
spec/models/public_feed_spec.rb
spec/models/status_spec.rb
spec/models/tag_feed_spec.rb
yarn.lock

diff --cc Gemfile
Simple merge
diff --cc Gemfile.lock
Simple merge
index 52b5cb323b8fe1a9e1f7bfdae6cae11a3ebe831f,d253b744f99bf9d24ff29369b3f4e307cbec1217..fbd99667c9f70d790bc614ce50a80f58a714b0f7
@@@ -29,19 -24,21 +24,22 @@@ class Api::V1::Timelines::PublicControl
    end
  
    def public_statuses
-     statuses = public_timeline_statuses
-     statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only)
-     if truthy_param?(:only_media)
-       statuses.joins(:media_attachments).group(:id)
-     else
-       statuses
-     end
+     public_feed.get(
+       limit_param(DEFAULT_STATUSES_LIMIT),
+       params[:max_id],
+       params[:since_id],
+       params[:min_id]
+     )
    end
  
-   def public_timeline_statuses
-     Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local))
+   def public_feed
+     PublicFeed.new(
+       current_account,
+       local: truthy_param?(:local),
+       remote: truthy_param?(:remote),
 -      only_media: truthy_param?(:only_media)
++      only_media: truthy_param?(:only_media),
++      allow_local_only: truthy_param?(:allow_local_only)
+     )
    end
  
    def insert_pagination_headers
Simple merge
index 915f3fa58dbd7a9cdb2b0fd6379572bc32213c8f,0876d107bd18fc4b384b00f41d2c40b0fd896427..3c1f8d6e25b8db0e3f0f5d1875056ed4e6b74ed1
@@@ -21,13 -32,19 +32,21 @@@ class FeedManage
      "feed:#{type}:#{id}:#{subtype}"
    end
  
-   def filter?(timeline_type, status, receiver_id)
-     if timeline_type == :home
-       filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]))
-     elsif timeline_type == :mentions
-       filter_from_mentions?(status, receiver_id)
-     elsif timeline_type == :direct
-       filter_from_direct?(status, receiver_id)
+   # Check if the status should not be added to a feed
+   # @param [Symbol] timeline_type
+   # @param [Status] status
+   # @param [Account|List] receiver
+   # @return [Boolean]
+   def filter?(timeline_type, status, receiver)
+     case timeline_type
+     when :home
+       filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]))
+     when :list
+       filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
+     when :mentions
+       filter_from_mentions?(status, receiver.id)
++    when :direct
++      filter_from_direct?(status, receiver.id)
      else
        false
      end
      true
    end
  
-     redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
-   end
++  # Add a status to a linear direct message feed and send a streaming API update
++  # @param [Account] account
++  # @param [Status] status
++  # @return [Boolean]
 +  def push_to_direct(account, status)
 +    return false unless add_to_feed(:direct, account.id, status)
++
 +    trim(:direct, account.id)
 +    PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}")
 +    true
 +  end
 +
++  # Remove a status from a linear direct message feed and send a streaming API update
++  # @param [List] list
++  # @param [Status] status
++  # @return [Boolean]
 +  def unpush_from_direct(account, status)
 +    return false unless remove_from_feed(:direct, account.id, status)
-   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
 +
-   def merge_into_timeline(from_account, into_account)
++    redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
++    true
 +  end
 +
+   # 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)
      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
 +    max_id = nil
 +
 +    loop do
 +      statuses = Status.as_direct_timeline(account, limit, max_id)
 +
 +      break if statuses.empty?
 +
 +      statuses.each do |status|
 +        next if filter_from_direct?(status, account)
 +        added += 1 if add_to_feed(:direct, account.id, status)
 +      end
 +
 +      break unless added.zero?
 +
 +      max_id = statuses.last.id
 +    end
 +  end
 +
    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?)
      should_filter
    end
  
++  # Check if status should not be added to the linear direct message feed
++  # @param [Status] status
++  # @param [Integer] receiver_id
++  # @return [Boolean]
 +  def filter_from_direct?(status, receiver_id)
 +    return false if receiver_id == status.account_id
 +    filter_from_mentions?(status, receiver_id)
 +  end
 +
+   # Check if status should not be added to the list feed
+   # @param [Status] status
+   # @param [List] list
+   # @return [Boolean]
+   def filter_from_list?(status, list)
+     if status.reply? && status.in_reply_to_account_id != status.account_id
+       should_filter = status.in_reply_to_account_id != list.account_id
+       should_filter &&= !list.show_all_replies?
+       should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
+       return !!should_filter
+     end
+     false
+   end
+   # Check if the status hits a phrase filter
+   # @param [Status] status
+   # @param [Integer] receiver_id
+   # @param [Symbol] context
+   # @return [Boolean]
    def phrase_filtered?(status, receiver_id, context)
      active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
  
index 0000000000000000000000000000000000000000,c8ce1a1403bb0aa38dd5be11a6b17163e0fd96a6..2839da5cb501ccaccf3498974f777172201b1dc7
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,90 +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
index 594ae98c07099ffc0dbcdda0acd223b582735828,c6e16ff7585518a78de9e7d6f8c693a536b68ebf..8495927afd7f4a6cc26fe282bfde8b66c1354c3f
@@@ -285,68 -277,6 +285,51 @@@ class Status < ApplicationRecor
        visibilities.keys - %w(direct limited)
      end
  
-     def as_public_timeline(account = nil, local_only = false)
-       query = timeline_scope(local_only)
-       query = query.without_replies unless Setting.show_replies_in_public_timelines
-       apply_timeline_filters(query, account, [:local, true].include?(local_only))
-     end
-     def as_tag_timeline(tag, account = nil, local_only = false)
-       query = timeline_scope(local_only).tagged_with(tag)
-       apply_timeline_filters(query, account, local_only)
-     end
-     def as_outbox_timeline(account)
-       where(account: account, visibility: :public)
-     end
 +    def in_chosen_languages(account)
 +      where(language: nil).or where(language: account.chosen_languages)
 +    end
 +
 +    def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
 +      # direct timeline is mix of direct message from_me and to_me.
 +      # 2 queries are executed with pagination.
 +      # constant expression using arel_table is required for partial index
 +
 +      # _from_me part does not require any timeline filters
 +      query_from_me = where(account_id: account.id)
 +                      .where(Status.arel_table[:visibility].eq(3))
 +                      .limit(limit)
 +                      .order('statuses.id DESC')
 +
 +      # _to_me part requires mute and block filter.
 +      # FIXME: may we check mutes.hide_notifications?
 +      query_to_me = Status
 +                    .joins(:mentions)
 +                    .merge(Mention.where(account_id: account.id))
 +                    .where(Status.arel_table[:visibility].eq(3))
 +                    .limit(limit)
 +                    .order('mentions.status_id DESC')
 +                    .not_excluded_by_account(account)
 +
 +      if max_id.present?
 +        query_from_me = query_from_me.where('statuses.id < ?', max_id)
 +        query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
 +      end
 +
 +      if since_id.present?
 +        query_from_me = query_from_me.where('statuses.id > ?', since_id)
 +        query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
 +      end
 +
 +      if cache_ids
 +        # returns array of cache_ids object that have id and updated_at
 +        (query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
 +      else
 +        # returns ActiveRecord.Relation
 +        items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
 +        Status.where(id: items.map(&:id))
 +      end
 +    end
 +
      def 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
          status&.distributable? ? status : nil
        end.compact
      end
-     private
-     def timeline_scope(scope = false)
-       starting_scope = case scope
-                        when :local, true
-                          Status.local
-                        when :remote
-                          Status.remote
-                        else
-                          Status
-                        end
-       starting_scope = starting_scope.with_public_visibility
-       if Setting.show_reblogs_in_public_timelines
-         starting_scope
-       else
-         starting_scope.without_reblogs
-       end
-     end
-     def apply_timeline_filters(query, account, local_only)
-       if account.nil?
-         filter_timeline_default(query)
-       else
-         filter_timeline_for_account(query, account, local_only)
-       end
-     end
-     def filter_timeline_for_account(query, account, local_only)
-       query = query.not_excluded_by_account(account)
-       query = query.not_domain_blocked_by_account(account) unless local_only
-       query = query.in_chosen_languages(account) if account.chosen_languages.present?
-       query.merge(account_silencing_filter(account))
-     end
-     def filter_timeline_default(query)
-       query.not_local_only.excluding_silenced_accounts
-     end
-     def account_silencing_filter(account)
-       if account.silenced?
-         including_myself = left_outer_joins(:account).where(account_id: account.id).references(:accounts)
-         excluding_silenced_accounts.or(including_myself)
-       else
-         excluding_silenced_accounts
-       end
-     end
    end
  
 +  def marked_local_only?
 +    # match both with and without U+FE0F (the emoji variation selector)
 +    /#{local_only_emoji}\ufe0f?\z/.match?(content)
 +  end
 +
 +  def local_only_emoji
 +    '👁'
 +  end
 +
    def status_stat
      super || build_status_stat
    end
index 0000000000000000000000000000000000000000,50634fe830d2cdc9130c17d229d23e00b2c7a2a6..baff5502037defef776a9c14f286e6bd4f6d434d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,57 +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
index 029c2f6e51b8aec9b23d87168da59cf419389c15,61f573534dda18ca4267e73b1da1a4f98dcc0c32..b4fa70710a313de74172c9d4c032c1d2cd1faa9b
@@@ -2,8 -2,7 +2,8 @@@
  
  class PrecomputeFeedService < BaseService
    def call(account)
-     FeedManager.instance.populate_feed(account)
+     FeedManager.instance.populate_home(account)
 +    FeedManager.instance.populate_direct_feed(account)
    ensure
      Redis.current.del("account:#{account.id}:regeneration")
    end
index 546f5c0c27f11fa0beb4eb40f3a40218979d3edb,633ec91bdc6beb490754e794f6a05ee085bd7a43..fd35af5621238d2a8b180d5012fe35f15f824857
@@@ -29,13 -27,11 +29,13 @@@ class FeedInsertWorke
    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 --cc package.json
Simple merge
index bb5bdfdc5edab8ee93284225b7eee589637d2280,d9c17470f5a65a01ca539ea559bea6e87615f032..22c9ff31b42969c88bfc83a187bc2495faaa2f17
@@@ -116,14 -116,7 +116,14 @@@ RSpec.describe FeedManager d
          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
index 0000000000000000000000000000000000000000,0392a582c69cd12d4363e3b83d763e204e308d9d..c251953a4c67cbfdc565f63511412194bbaf3380
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,212 +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
index 041021d347fa77ba1601aca25f9f10a1444c759c,20fb894e77f36988f18b1a49461a27b8ad5d35b5..c1375ea944eedfa5c26e8c17423b67944d09c8f5
@@@ -304,338 -267,6 +304,56 @@@ RSpec.describe Status, type: :model d
      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 '.as_direct_timeline' do
 +    let(:account) { Fabricate(:account) }
 +    let(:followed) { Fabricate(:account) }
 +    let(:not_followed) { Fabricate(:account) }
 +
 +    before do
 +      Fabricate(:follow, account: account, target_account: followed)
 +
 +      @self_public_status = Fabricate(:status, account: account, visibility: :public)
 +      @self_direct_status = Fabricate(:status, account: account, visibility: :direct)
 +      @followed_public_status = Fabricate(:status, account: followed, visibility: :public)
 +      @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct)
 +      @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct)
 +
 +      @results = Status.as_direct_timeline(account)
 +    end
 +
 +    it 'does not include public statuses from self' do
 +      expect(@results).to_not include(@self_public_status)
 +    end
 +
 +    it 'includes direct statuses from self' do
 +      expect(@results).to include(@self_direct_status)
 +    end
 +
 +    it 'does not include public statuses from followed' do
 +      expect(@results).to_not include(@followed_public_status)
 +    end
 +
 +    it 'does not include direct statuses not mentioning recipient from followed' do
 +      expect(@results).to_not include(@followed_direct_status)
 +    end
 +
 +    it 'does not include direct statuses not mentioning recipient from non-followed' do
 +      expect(@results).to_not include(@not_followed_direct_status)
 +    end
 +
 +    it 'includes direct statuses mentioning recipient from followed' do
 +      Fabricate(:mention, account: account, status: @followed_direct_status)
 +      results2 = Status.as_direct_timeline(account)
 +      expect(results2).to include(@followed_direct_status)
 +    end
 +
 +    it 'includes direct statuses mentioning recipient from non-followed' do
 +      Fabricate(:mention, account: account, status: @not_followed_direct_status)
 +      results2 = Status.as_direct_timeline(account)
 +      expect(results2).to include(@not_followed_direct_status)
 +    end
 +  end
 +
    describe '.permitted_for' do
      subject { described_class.permitted_for(target_account, account).pluck(:visibility) }
  
index 24282d2f0672e1defd94bd4bcc100bfe736c04f0,17d88eb99938f0d528b721ac71212fb86fc9d7a8..76277c4678987350e57c07bdbbcb43ea75b1a9fe
@@@ -53,8 -53,16 +53,30 @@@ describe TagFeed, type: :service d
      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 --cc yarn.lock
Simple merge