Introduce StatusThreadingConcern (#3490)
* Add a StatusFilter class to identify visibility of statuses by accounts * Extract StatusThreadingConcern from Status * Clarify purpose of checking for nil accountmaster
parent
c26cea262b
commit
6201f96b8a
@ -0,0 +1,56 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class StatusFilter |
||||
attr_reader :status, :account |
||||
|
||||
def initialize(status, account) |
||||
@status = status |
||||
@account = account |
||||
end |
||||
|
||||
def filtered? |
||||
account_present? && filtered_status? |
||||
end |
||||
|
||||
private |
||||
|
||||
def account_present? |
||||
!account.nil? |
||||
end |
||||
|
||||
def filtered_status? |
||||
blocking_account? || blocking_domain? || muting_account? || silenced_account? || blocked_by_policy? |
||||
end |
||||
|
||||
def blocking_account? |
||||
account.blocking? status.account_id |
||||
end |
||||
|
||||
def blocking_domain? |
||||
account.domain_blocking? status.account_domain |
||||
end |
||||
|
||||
def muting_account? |
||||
account.muting? status.account_id |
||||
end |
||||
|
||||
def silenced_account? |
||||
status_account_silenced? && !account_following_status_account? |
||||
end |
||||
|
||||
def status_account_silenced? |
||||
status.account.silenced? |
||||
end |
||||
|
||||
def account_following_status_account? |
||||
account.following? status.account_id |
||||
end |
||||
|
||||
def blocked_by_policy? |
||||
!policy_allows_show? |
||||
end |
||||
|
||||
def policy_allows_show? |
||||
StatusPolicy.new(account, status).show? |
||||
end |
||||
end |
@ -0,0 +1,89 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
module StatusThreadingConcern |
||||
extend ActiveSupport::Concern |
||||
|
||||
def ancestors(account = nil) |
||||
find_statuses_from_tree_path(ancestor_ids, account) |
||||
end |
||||
|
||||
def descendants(account = nil) |
||||
find_statuses_from_tree_path(descendant_ids, account) |
||||
end |
||||
|
||||
private |
||||
|
||||
def ancestor_ids |
||||
Rails.cache.fetch("ancestors:#{id}") do |
||||
ancestors_without_self.pluck(:id) |
||||
end |
||||
end |
||||
|
||||
def ancestors_without_self |
||||
ancestor_statuses - [self] |
||||
end |
||||
|
||||
def ancestor_statuses |
||||
Status.find_by_sql([<<-SQL.squish, id: id]) |
||||
WITH RECURSIVE search_tree(id, in_reply_to_id, path) |
||||
AS ( |
||||
SELECT id, in_reply_to_id, ARRAY[id] |
||||
FROM statuses |
||||
WHERE id = :id |
||||
UNION ALL |
||||
SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id |
||||
FROM search_tree |
||||
JOIN statuses ON statuses.id = search_tree.in_reply_to_id |
||||
WHERE NOT statuses.id = ANY(path) |
||||
) |
||||
SELECT id |
||||
FROM search_tree |
||||
ORDER BY path DESC |
||||
SQL |
||||
end |
||||
|
||||
def descendant_ids |
||||
descendants_without_self.pluck(:id) |
||||
end |
||||
|
||||
def descendants_without_self |
||||
descendant_statuses - [self] |
||||
end |
||||
|
||||
def descendant_statuses |
||||
Status.find_by_sql([<<-SQL.squish, id: id]) |
||||
WITH RECURSIVE search_tree(id, path) |
||||
AS ( |
||||
SELECT id, ARRAY[id] |
||||
FROM statuses |
||||
WHERE id = :id |
||||
UNION ALL |
||||
SELECT statuses.id, path || statuses.id |
||||
FROM search_tree |
||||
JOIN statuses ON statuses.in_reply_to_id = search_tree.id |
||||
WHERE NOT statuses.id = ANY(path) |
||||
) |
||||
SELECT id |
||||
FROM search_tree |
||||
ORDER BY path |
||||
SQL |
||||
end |
||||
|
||||
def find_statuses_from_tree_path(ids, account) |
||||
statuses = statuses_with_accounts(ids).to_a |
||||
|
||||
# FIXME: n+1 bonanza |
||||
statuses.reject! { |status| filter_from_context?(status, account) } |
||||
|
||||
# Order ancestors/descendants by tree path |
||||
statuses.sort_by! { |status| ids.index(status.id) } |
||||
end |
||||
|
||||
def statuses_with_accounts(ids) |
||||
Status.where(id: ids).includes(:account) |
||||
end |
||||
|
||||
def filter_from_context?(status, account) |
||||
StatusFilter.new(status, account).filtered? |
||||
end |
||||
end |
@ -0,0 +1,65 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
require 'rails_helper' |
||||
|
||||
describe StatusFilter do |
||||
describe '#filtered?' do |
||||
let(:status) { Fabricate(:status) } |
||||
|
||||
context 'without an account' do |
||||
subject { described_class.new(status, nil) } |
||||
|
||||
it { is_expected.not_to be_filtered } |
||||
end |
||||
|
||||
context 'with real account' do |
||||
let(:account) { Fabricate(:account) } |
||||
subject { described_class.new(status, account) } |
||||
|
||||
context 'when there are no connections' do |
||||
it { is_expected.not_to be_filtered } |
||||
end |
||||
|
||||
context 'when status account is blocked' do |
||||
before do |
||||
Fabricate(:block, account: account, target_account: status.account) |
||||
end |
||||
|
||||
it { is_expected.to be_filtered } |
||||
end |
||||
|
||||
context 'when status account domain is blocked' do |
||||
before do |
||||
status.account.update(domain: 'example.com') |
||||
Fabricate(:account_domain_block, account: account, domain: status.account_domain) |
||||
end |
||||
|
||||
it { is_expected.to be_filtered } |
||||
end |
||||
|
||||
context 'when status account is muted' do |
||||
before do |
||||
Fabricate(:mute, account: account, target_account: status.account) |
||||
end |
||||
|
||||
it { is_expected.to be_filtered } |
||||
end |
||||
|
||||
context 'when status account is silenced' do |
||||
before do |
||||
status.account.update(silenced: true) |
||||
end |
||||
|
||||
it { is_expected.to be_filtered } |
||||
end |
||||
|
||||
context 'when status policy does not allow show' do |
||||
before do |
||||
expect_any_instance_of(StatusPolicy).to receive(:show?).and_return(false) |
||||
end |
||||
|
||||
it { is_expected.to be_filtered } |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,100 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
require 'rails_helper' |
||||
|
||||
describe StatusThreadingConcern do |
||||
describe '#ancestors' do |
||||
let!(:alice) { Fabricate(:account, username: 'alice') } |
||||
let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } |
||||
let!(:jeff) { Fabricate(:account, username: 'jeff') } |
||||
let!(:status) { Fabricate(:status, account: alice) } |
||||
let!(:reply1) { Fabricate(:status, thread: status, account: jeff) } |
||||
let!(:reply2) { Fabricate(:status, thread: reply1, account: bob) } |
||||
let!(:reply3) { Fabricate(:status, thread: reply2, account: alice) } |
||||
let!(:viewer) { Fabricate(:account, username: 'viewer') } |
||||
|
||||
it 'returns conversation history' do |
||||
expect(reply3.ancestors).to include(status, reply1, reply2) |
||||
end |
||||
|
||||
it 'does not return conversation history user is not allowed to see' do |
||||
reply1.update(visibility: :private) |
||||
status.update(visibility: :direct) |
||||
|
||||
expect(reply3.ancestors(viewer)).to_not include(reply1, status) |
||||
end |
||||
|
||||
it 'does not return conversation history from blocked users' do |
||||
viewer.block!(jeff) |
||||
expect(reply3.ancestors(viewer)).to_not include(reply1) |
||||
end |
||||
|
||||
it 'does not return conversation history from muted users' do |
||||
viewer.mute!(jeff) |
||||
expect(reply3.ancestors(viewer)).to_not include(reply1) |
||||
end |
||||
|
||||
it 'does not return conversation history from silenced and not followed users' do |
||||
jeff.update(silenced: true) |
||||
expect(reply3.ancestors(viewer)).to_not include(reply1) |
||||
end |
||||
|
||||
it 'does not return conversation history from blocked domains' do |
||||
viewer.block_domain!('example.com') |
||||
expect(reply3.ancestors(viewer)).to_not include(reply2) |
||||
end |
||||
|
||||
it 'ignores deleted records' do |
||||
first_status = Fabricate(:status, account: bob) |
||||
second_status = Fabricate(:status, thread: first_status, account: alice) |
||||
|
||||
# Create cache and delete cached record |
||||
second_status.ancestors |
||||
first_status.destroy |
||||
|
||||
expect(second_status.ancestors).to eq([]) |
||||
end |
||||
end |
||||
|
||||
describe '#descendants' do |
||||
let!(:alice) { Fabricate(:account, username: 'alice') } |
||||
let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } |
||||
let!(:jeff) { Fabricate(:account, username: 'jeff') } |
||||
let!(:status) { Fabricate(:status, account: alice) } |
||||
let!(:reply1) { Fabricate(:status, thread: status, account: alice) } |
||||
let!(:reply2) { Fabricate(:status, thread: status, account: bob) } |
||||
let!(:reply3) { Fabricate(:status, thread: reply1, account: jeff) } |
||||
let!(:viewer) { Fabricate(:account, username: 'viewer') } |
||||
|
||||
it 'returns replies' do |
||||
expect(status.descendants).to include(reply1, reply2, reply3) |
||||
end |
||||
|
||||
it 'does not return replies user is not allowed to see' do |
||||
reply1.update(visibility: :private) |
||||
reply3.update(visibility: :direct) |
||||
|
||||
expect(status.descendants(viewer)).to_not include(reply1, reply3) |
||||
end |
||||
|
||||
it 'does not return replies from blocked users' do |
||||
viewer.block!(jeff) |
||||
expect(status.descendants(viewer)).to_not include(reply3) |
||||
end |
||||
|
||||
it 'does not return replies from muted users' do |
||||
viewer.mute!(jeff) |
||||
expect(status.descendants(viewer)).to_not include(reply3) |
||||
end |
||||
|
||||
it 'does not return replies from silenced and not followed users' do |
||||
jeff.update(silenced: true) |
||||
expect(status.descendants(viewer)).to_not include(reply3) |
||||
end |
||||
|
||||
it 'does not return replies from blocked domains' do |
||||
viewer.block_domain!('example.com') |
||||
expect(status.descendants(viewer)).to_not include(reply2) |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue