Refactor how public and tag timelines are queried (#14728)
parent
a6121a159c
commit
e8bc187845
|
@ -20,26 +20,25 @@ class Api::V1::Timelines::PublicController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def cached_public_statuses_page
|
def cached_public_statuses_page
|
||||||
cache_collection_paginated_by_id(
|
cache_collection(public_statuses, Status)
|
||||||
public_statuses,
|
|
||||||
Status,
|
|
||||||
limit_param(DEFAULT_STATUSES_LIMIT),
|
|
||||||
params_slice(:max_id, :since_id, :min_id)
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def public_statuses
|
def public_statuses
|
||||||
statuses = public_timeline_statuses
|
public_feed.get(
|
||||||
|
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||||
if truthy_param?(:only_media)
|
params[:max_id],
|
||||||
statuses.joins(:media_attachments).group(:id)
|
params[:since_id],
|
||||||
else
|
params[:min_id]
|
||||||
statuses
|
)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def public_timeline_statuses
|
def public_feed
|
||||||
Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local))
|
PublicFeed.new(
|
||||||
|
current_account,
|
||||||
|
local: truthy_param?(:local),
|
||||||
|
remote: truthy_param?(:remote),
|
||||||
|
only_media: truthy_param?(:only_media)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def insert_pagination_headers
|
def insert_pagination_headers
|
||||||
|
|
|
@ -20,23 +20,29 @@ class Api::V1::Timelines::TagController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def cached_tagged_statuses
|
def cached_tagged_statuses
|
||||||
if @tag.nil?
|
@tag.nil? ? [] : cache_collection(tag_timeline_statuses, Status)
|
||||||
[]
|
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_timeline_statuses
|
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
|
end
|
||||||
|
|
||||||
def insert_pagination_headers
|
def insert_pagination_headers
|
||||||
|
|
|
@ -10,8 +10,9 @@ class TagsController < ApplicationController
|
||||||
|
|
||||||
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
|
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :authenticate_user!, if: :whitelist_mode?
|
before_action :authenticate_user!, if: :whitelist_mode?
|
||||||
before_action :set_tag
|
|
||||||
before_action :set_local
|
before_action :set_local
|
||||||
|
before_action :set_tag
|
||||||
|
before_action :set_statuses
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
before_action :set_instance_presenter
|
before_action :set_instance_presenter
|
||||||
|
|
||||||
|
@ -25,20 +26,11 @@ class TagsController < ApplicationController
|
||||||
|
|
||||||
format.rss do
|
format.rss do
|
||||||
expires_in 0, public: true
|
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)
|
render xml: RSS::TagSerializer.render(@tag, @statuses)
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
format.json do
|
||||||
expires_in 3.minutes, public: public_fetch_mode?
|
expires_in 3.minutes, public: public_fetch_mode?
|
||||||
|
|
||||||
@statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local).paginate_by_max_id(PAGE_SIZE, params[:max_id])
|
|
||||||
@statuses = cache_collection(@statuses, Status)
|
|
||||||
|
|
||||||
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -54,6 +46,15 @@ class TagsController < ApplicationController
|
||||||
@local = truthy_param?(:local)
|
@local = truthy_param?(:local)
|
||||||
end
|
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
|
def set_body_classes
|
||||||
@body_classes = 'with-modals'
|
@body_classes = 'with-modals'
|
||||||
end
|
end
|
||||||
|
@ -62,16 +63,16 @@ class TagsController < ApplicationController
|
||||||
@instance_presenter = InstancePresenter.new
|
@instance_presenter = InstancePresenter.new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def limit_param
|
||||||
|
params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
|
||||||
|
end
|
||||||
|
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: tag_url(@tag, filter_params),
|
id: tag_url(@tag),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @tag.statuses.count,
|
size: @tag.statuses.count,
|
||||||
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
|
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
|
||||||
params.slice(:any, :all, :none).permit(:any, :all, :none)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
# 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
|
||||||
|
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_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 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 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 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
|
|
@ -85,12 +85,12 @@ class Status < ApplicationRecord
|
||||||
scope :recent, -> { reorder(id: :desc) }
|
scope :recent, -> { reorder(id: :desc) }
|
||||||
scope :remote, -> { where(local: false).where.not(uri: nil) }
|
scope :remote, -> { where(local: false).where.not(uri: nil) }
|
||||||
scope :local, -> { where(local: true).or(where(uri: nil)) }
|
scope :local, -> { where(local: true).or(where(uri: nil)) }
|
||||||
|
|
||||||
scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
|
scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
|
||||||
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
|
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
|
||||||
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
|
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
|
||||||
scope :with_public_visibility, -> { where(visibility: :public) }
|
scope :with_public_visibility, -> { where(visibility: :public) }
|
||||||
scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
|
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 :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 :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) }
|
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
|
||||||
|
@ -277,26 +277,6 @@ class Status < ApplicationRecord
|
||||||
visibilities.keys - %w(direct limited)
|
visibilities.keys - %w(direct limited)
|
||||||
end
|
end
|
||||||
|
|
||||||
def in_chosen_languages(account)
|
|
||||||
where(language: nil).or where(language: account.chosen_languages)
|
|
||||||
end
|
|
||||||
|
|
||||||
def as_public_timeline(account = nil, local_only = false)
|
|
||||||
query = timeline_scope(local_only).without_replies
|
|
||||||
|
|
||||||
apply_timeline_filters(query, account, [:local, true].include?(local_only))
|
|
||||||
end
|
|
||||||
|
|
||||||
def as_tag_timeline(tag, account = nil, local_only = false)
|
|
||||||
query = timeline_scope(local_only).tagged_with(tag)
|
|
||||||
|
|
||||||
apply_timeline_filters(query, account, local_only)
|
|
||||||
end
|
|
||||||
|
|
||||||
def as_outbox_timeline(account)
|
|
||||||
where(account: account, visibility: :public)
|
|
||||||
end
|
|
||||||
|
|
||||||
def favourites_map(status_ids, account_id)
|
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 }
|
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
|
end
|
||||||
|
@ -373,51 +353,6 @@ class Status < ApplicationRecord
|
||||||
status&.distributable? ? status : nil
|
status&.distributable? ? status : nil
|
||||||
end.compact
|
end.compact
|
||||||
end
|
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
|
|
||||||
.with_public_visibility
|
|
||||||
.without_reblogs
|
|
||||||
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.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
|
end
|
||||||
|
|
||||||
def status_stat
|
def status_stat
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
# 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!(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
|
|
@ -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
|
|
|
@ -0,0 +1,212 @@
|
||||||
|
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) }
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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 { 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
|
||||||
|
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
|
||||||
|
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
|
|
@ -267,241 +267,6 @@ RSpec.describe Status, type: :model do
|
||||||
end
|
end
|
||||||
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
|
|
||||||
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
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.permitted_for' do
|
describe '.permitted_for' do
|
||||||
subject { described_class.permitted_for(target_account, account).pluck(:visibility) }
|
subject { described_class.permitted_for(target_account, account).pluck(:visibility) }
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
describe HashtagQueryService, type: :service do
|
describe TagFeed, type: :service do
|
||||||
describe '.call' do
|
describe '#get' do
|
||||||
let(:account) { Fabricate(:account) }
|
let(:account) { Fabricate(:account) }
|
||||||
let(:tag1) { Fabricate(:tag) }
|
let(:tag1) { Fabricate(:tag) }
|
||||||
let(:tag2) { Fabricate(:tag) }
|
let(:tag2) { Fabricate(:tag) }
|
||||||
|
@ -10,35 +10,35 @@ describe HashtagQueryService, type: :service do
|
||||||
let!(:both) { Fabricate(:status, tags: [tag1, tag2]) }
|
let!(:both) { Fabricate(:status, tags: [tag1, tag2]) }
|
||||||
|
|
||||||
it 'can add tags in "any" mode' do
|
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 status1
|
||||||
expect(results).to include status2
|
expect(results).to include status2
|
||||||
expect(results).to include both
|
expect(results).to include both
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'can remove tags in "all" mode' do
|
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 status1
|
||||||
expect(results).to_not include status2
|
expect(results).to_not include status2
|
||||||
expect(results).to include both
|
expect(results).to include both
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'can remove tags in "none" mode' do
|
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 include status1
|
||||||
expect(results).to_not include status2
|
expect(results).to_not include status2
|
||||||
expect(results).to_not include both
|
expect(results).to_not include both
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'ignores an invalid mode' do
|
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 include status1
|
||||||
expect(results).to_not include status2
|
expect(results).to_not include status2
|
||||||
expect(results).to include both
|
expect(results).to include both
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'handles being passed non existant tag names' do
|
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 include status1
|
||||||
expect(results).to_not include status2
|
expect(results).to_not include status2
|
||||||
expect(results).to include both
|
expect(results).to include both
|
||||||
|
@ -46,15 +46,23 @@ describe HashtagQueryService, type: :service do
|
||||||
|
|
||||||
it 'can restrict to an account' do
|
it 'can restrict to an account' do
|
||||||
BlockService.new.call(account, status1.account)
|
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
|
expect(results).to_not include status1
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'can restrict to local' do
|
it 'can restrict to local' do
|
||||||
status1.account.update(domain: 'example.com')
|
status1.account.update(domain: 'example.com')
|
||||||
status1.update(local: false, uri: 'example.com/toot')
|
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
|
expect(results).to_not include status1
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
end
|
end
|
|
@ -28,10 +28,10 @@ RSpec.describe FanOutOnWriteService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'delivers status to hashtag' do
|
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
|
end
|
||||||
|
|
||||||
it 'delivers status to public timeline' do
|
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
|
||||||
end
|
end
|
||||||
|
|
Reference in New Issue