From 263ead73616dba43a0337c2a3edaf96a6382d533 Mon Sep 17 00:00:00 2001 From: Fire Demon Date: Tue, 21 Jul 2020 01:44:16 -0500 Subject: [Feature] Add post and thread (un)hiding to backend --- .../api/v1/statuses/hides_controller.rb | 28 ++++++++++++++++++ .../api/v1/statuses/mutes_controller.rb | 4 ++- app/lib/feed_manager.rb | 19 ++++++++++++ app/models/concerns/account_interactions.rb | 30 +++++++++++++++---- app/models/conversation.rb | 1 + app/models/conversation_mute.rb | 5 ++-- app/models/status.rb | 34 +++++++++++++++++----- app/models/status_mute.rb | 14 +++++++++ app/presenters/status_relationships_presenter.rb | 8 +++++ app/serializers/rest/status_serializer.rb | 18 ++++++++++++ app/services/mute_conversation_service.rb | 10 +++++++ app/services/mute_status_service.rb | 10 +++++++ app/workers/mute_conversation_worker.rb | 11 +++++++ 13 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 app/controllers/api/v1/statuses/hides_controller.rb create mode 100644 app/models/status_mute.rb create mode 100644 app/services/mute_conversation_service.rb create mode 100644 app/services/mute_status_service.rb create mode 100644 app/workers/mute_conversation_worker.rb (limited to 'app') diff --git a/app/controllers/api/v1/statuses/hides_controller.rb b/app/controllers/api/v1/statuses/hides_controller.rb new file mode 100644 index 000000000..8c5457c82 --- /dev/null +++ b/app/controllers/api/v1/statuses/hides_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::HidesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:mutes' } + before_action :require_user! + before_action :set_status + + def create + MuteStatusService.new.call(current_account, @status) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + current_account.unmute_status!(@status) + render json: @status, serializer: REST::StatusSerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + end +end diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb index 87071a2b9..73d9df734 100644 --- a/app/controllers/api/v1/statuses/mutes_controller.rb +++ b/app/controllers/api/v1/statuses/mutes_controller.rb @@ -9,12 +9,14 @@ class Api::V1::Statuses::MutesController < Api::BaseController before_action :set_conversation def create - current_account.mute_conversation!(@conversation) + MuteConversationService.new.call(current_account, @status.conversation, hidden: truthy_param?(:hide)) @mutes_map = { @conversation.id => true } render json: @status, serializer: REST::StatusSerializer end + alias update create + def destroy current_account.unmute_conversation!(@conversation) @mutes_map = { @conversation.id => false } diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 2dc60092c..80f1f8926 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -82,6 +82,25 @@ class FeedManager redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) end + def unpush_status(account, status) + return if account.blank? || status.blank? + + unpush_from_home(account, status) + unpush_from_direct(account, status) if status.direct_visibility? + + account.lists_for_local_distribution.select(:id, :account_id).each do |list| + unpush_from_list(list, status) + end + end + + def unpush_conversation(account, conversation) + return if account.blank? || conversation.blank? + + conversation.statuses.reorder(nil).find_each do |status| + unpush_status(account, status) + end + end + def trim(type, account_id) timeline_key = key(type, account_id) reblog_key = key(type, account_id, 'reblogs') diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 01a711493..d5e2fe985 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -90,9 +90,10 @@ module AccountInteractions has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account - has_many :conversation_mutes, dependent: :destroy + has_many :conversation_mutes, inverse_of: :account, dependent: :destroy has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy has_many :announcement_mutes, dependent: :destroy + has_many :status_mutes, inverse_of: :account, dependent: :destroy end def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false) @@ -132,15 +133,15 @@ module AccountInteractions remove_potential_friendship(other_account) # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. - if mute.hide_notifications? != notifications - mute.update!(hide_notifications: notifications, timelines_only: timelines_only) - end + mute.update!(hide_notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? != notifications mute end - def mute_conversation!(conversation) - conversation_mutes.find_or_create_by!(conversation: conversation) + def mute_conversation!(conversation, hidden: false) + mute = conversation_mutes.find_or_create_by!(conversation: conversation) + mute.update(hidden: hidden) if mute.hidden? != hidden + mute end def block_domain!(other_domain) @@ -172,6 +173,15 @@ module AccountInteractions block&.destroy end + def mute_status!(status) + status_mutes.find_or_create_by!(status: status) + end + + def unmute_status!(status) + mute = status_mutes.find_by(status: status) + mute&.destroy + end + def following?(other_account) active_relationships.where(target_account: other_account).exists? end @@ -192,6 +202,10 @@ module AccountInteractions conversation_mutes.where(conversation: conversation).exists? end + def hiding_conversation?(conversation) + conversation_mutes.where(conversation: conversation, hidden: true).exists? + end + def muting_notifications?(other_account) mute_relationships.where(target_account: other_account, hide_notifications: true).exists? end @@ -200,6 +214,10 @@ module AccountInteractions active_relationships.where(target_account: other_account, show_reblogs: false).exists? end + def muting_status?(status) + status_mutes.where(status: status).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 4dfaea889..bbe3ada31 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -13,6 +13,7 @@ class Conversation < ApplicationRecord validates :uri, uniqueness: true, if: :uri? has_many :statuses + has_many :mutes, class_name: 'ConversationMute', inverse_of: :conversation, dependent: :destroy def local? uri.nil? diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb index 52c1a33e0..5d56a3172 100644 --- a/app/models/conversation_mute.rb +++ b/app/models/conversation_mute.rb @@ -6,9 +6,10 @@ # id :bigint(8) not null, primary key # conversation_id :bigint(8) not null # account_id :bigint(8) not null +# hidden :boolean default(FALSE), not null # class ConversationMute < ApplicationRecord - belongs_to :account - belongs_to :conversation + belongs_to :account, inverse_of: :conversation_mutes + belongs_to :conversation, inverse_of: :mutes end diff --git a/app/models/status.rb b/app/models/status.rb index b94aad633..74012c22e 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -69,6 +69,9 @@ class Status < ApplicationRecord has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status has_many :media_attachments, dependent: :nullify + has_many :mutes, class_name: 'StatusMute', inverse_of: :status, dependent: :destroy + belongs_to :conversation_mute, primary_key: 'conversation_id', foreign_key: 'conversation_id', inverse_of: :conversation, dependent: :destroy, optional: true + has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards @@ -120,6 +123,10 @@ class Status < ApplicationRecord scope :unpublished, -> { rewhere(published: false) } scope :published, -> { where(published: true) } + scope :not_hidden_by_account, ->(account) do + left_outer_joins(:mutes, :conversation_mute).where('(status_mutes.account_id IS NULL OR status_mutes.account_id != ?) AND (conversation_mutes.account_id IS NULL OR (conversation_mutes.account_id != ? AND conversation_mutes.hidden = TRUE))', account.id, account.id) + end + cache_associated :application, :media_attachments, :conversation, @@ -371,6 +378,14 @@ class Status < ApplicationRecord ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true } end + def hidden_conversations_map(conversation_ids, account_id) + ConversationMute.select('conversation_id').where(conversation_id: conversation_ids, hidden: true).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true } + end + + def hidden_statuses_map(status_ids, account_id) + StatusMute.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.status_id] = true } + end + def pins_map(status_ids, account_id) StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } end @@ -411,9 +426,11 @@ class Status < ApplicationRecord scope = left_outer_joins(:reblog).published - scope.where(visibility: visibility) - .or(scope.where(id: account.mentions.select(:status_id))) - .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }))) + scope = scope.where(visibility: visibility) + .or(scope.where(id: account.mentions.select(:status_id))) + .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }))) + + apply_timeline_filters(scope, account, false) end end @@ -463,6 +480,7 @@ class Status < ApplicationRecord query = query.not_excluded_by_account(account) query = query.not_domain_blocked_by_account(account) unless local_only query = query.in_chosen_languages(account) if account.chosen_languages.present? + query = query.not_hidden_by_account(account) query.merge(account_silencing_filter(account)) end @@ -556,11 +574,11 @@ class Status < ApplicationRecord def set_nest_level return if attribute_changed?(:nest_level) - if reply? - self.nest_level = [thread&.account_id == account_id ? thread&.nest_level.to_i : thread&.nest_level.to_i + 1, 127].min - else - self.nest_level = 0 - end + self.nest_level = if reply? + [thread&.account_id == account_id ? thread&.nest_level.to_i : thread&.nest_level.to_i + 1, 127].min + else + 0 + end end def update_statistics diff --git a/app/models/status_mute.rb b/app/models/status_mute.rb new file mode 100644 index 000000000..3bfd9d51f --- /dev/null +++ b/app/models/status_mute.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_mutes +# +# id :bigint(8) not null, primary key +# account_id :integer not null +# status_id :bigint(8) not null +# + +class StatusMute < ApplicationRecord + belongs_to :account, inverse_of: :status_mutes + belongs_to :status, inverse_of: :mutes +end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 3cc905a75..260ea48fe 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -4,6 +4,8 @@ class StatusRelationshipsPresenter attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, :bookmarks_map + attr_reader :hidden_conversations_map, :hidden_statuses_map + def initialize(statuses, current_account_id = nil, **options) if current_account_id.nil? @reblogs_map = {} @@ -11,6 +13,9 @@ class StatusRelationshipsPresenter @bookmarks_map = {} @mutes_map = {} @pins_map = {} + + @hidden_conversations_map = {} + @hidden_statuses_map = {} else statuses = statuses.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact @@ -22,6 +27,9 @@ class StatusRelationshipsPresenter @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) + + @hidden_conversations_map = Status.hidden_conversations_map(conversation_ids, current_account_id).merge(options[:hidden_conversations_map] || {}) + @hidden_statuses_map = Status.hidden_statuses_map(status_ids, current_account_id).merge(options[:hidden_statuses_map] || {}) end end end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 7a2dd6db9..1db3d50f1 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -21,6 +21,8 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :content_type, if: :source_requested? attribute :published if :local? + attribute :hidden, if: :current_user? + attribute :conversation_hidden, if: :current_user? belongs_to :reblog, serializer: REST::StatusSerializer belongs_to :application, if: :show_application? @@ -101,6 +103,22 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def conversation_hidden + if instance_options && instance_options[:relationships] + instance_options[:relationships].hidden_conversations_map[object.conversation_id] || false + else + current_user.account.hiding_conversation?(object.conversation) + end + end + + def hidden + if instance_options && instance_options[:relationships] + instance_options[:relationships].hidden_statuses_map[object.id] || false + else + current_user.account.muting_status?(object) + end + end + def bookmarked if instance_options && instance_options[:relationships] instance_options[:relationships].bookmarks_map[object.id] || false diff --git a/app/services/mute_conversation_service.rb b/app/services/mute_conversation_service.rb new file mode 100644 index 000000000..46adb98dc --- /dev/null +++ b/app/services/mute_conversation_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MuteConversationService < BaseService + def call(account, conversation, hidden: false) + return if account.blank? || conversation.blank? + + account.mute_conversation!(conversation, hidden: hidden) + MuteConversationWorker.perform_async(account.id, conversation.id) if hidden + end +end diff --git a/app/services/mute_status_service.rb b/app/services/mute_status_service.rb new file mode 100644 index 000000000..bdf99232c --- /dev/null +++ b/app/services/mute_status_service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MuteStatusService < BaseService + def call(account, status) + return if account.blank? || status.blank? + + account.mute_status!(status) + FeedManager.instance.unpush_status(account, status) + end +end diff --git a/app/workers/mute_conversation_worker.rb b/app/workers/mute_conversation_worker.rb new file mode 100644 index 000000000..efe6dd539 --- /dev/null +++ b/app/workers/mute_conversation_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class MuteConversationWorker + include Sidekiq::Worker + + def perform(account_id, conversation_id) + FeedManager.instance.unpush_conversation(Account.find(account_id), Conversation.find(conversation_id)) + rescue ActiveRecord::RecordNotFound + true + end +end -- cgit