about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/api/v1/statuses/hides_controller.rb28
-rw-r--r--app/controllers/api/v1/statuses/mutes_controller.rb4
-rw-r--r--app/lib/feed_manager.rb19
-rw-r--r--app/models/concerns/account_interactions.rb30
-rw-r--r--app/models/conversation.rb1
-rw-r--r--app/models/conversation_mute.rb5
-rw-r--r--app/models/status.rb34
-rw-r--r--app/models/status_mute.rb14
-rw-r--r--app/presenters/status_relationships_presenter.rb8
-rw-r--r--app/serializers/rest/status_serializer.rb18
-rw-r--r--app/services/mute_conversation_service.rb10
-rw-r--r--app/services/mute_status_service.rb10
-rw-r--r--app/workers/mute_conversation_worker.rb11
-rw-r--r--config/routes.rb13
-rw-r--r--db/migrate/20200720211530_add_hidden_to_conversation_mute.rb7
-rw-r--r--db/migrate/20200720212317_create_status_mutes.rb10
-rw-r--r--db/schema.rb11
17 files changed, 210 insertions, 23 deletions
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
diff --git a/config/routes.rb b/config/routes.rb
index 6df812090..974a94e47 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -50,10 +50,10 @@ Rails.application.routes.draw do
 
   devise_for :users, path: 'auth', controllers: {
     omniauth_callbacks: 'auth/omniauth_callbacks',
-    sessions:           'auth/sessions',
-    registrations:      'auth/registrations',
-    passwords:          'auth/passwords',
-    confirmations:      'auth/confirmations',
+    sessions: 'auth/sessions',
+    registrations: 'auth/registrations',
+    passwords: 'auth/passwords',
+    confirmations: 'auth/confirmations',
   }
 
   get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
@@ -303,12 +303,15 @@ Rails.application.routes.draw do
           resource :bookmark, only: :create
           post :unbookmark, to: 'bookmarks#destroy'
 
-          resource :mute, only: :create
+          resource :mute, only: [:create, :update]
           post :unmute, to: 'mutes#destroy'
 
           resource :pin, only: :create
           post :unpin, to: 'pins#destroy'
 
+          resource :hide, only: :create
+          post :unhide, to: 'mutes#destroy'
+
           resource :publish, only: :create
         end
 
diff --git a/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb b/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb
new file mode 100644
index 000000000..aa7b31d8b
--- /dev/null
+++ b/db/migrate/20200720211530_add_hidden_to_conversation_mute.rb
@@ -0,0 +1,7 @@
+class AddHiddenToConversationMute < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_column :conversation_mutes, :hidden, :boolean, default: false, null: false
+    end
+  end
+end
diff --git a/db/migrate/20200720212317_create_status_mutes.rb b/db/migrate/20200720212317_create_status_mutes.rb
new file mode 100644
index 000000000..efd8f15c8
--- /dev/null
+++ b/db/migrate/20200720212317_create_status_mutes.rb
@@ -0,0 +1,10 @@
+class CreateStatusMutes < ActiveRecord::Migration[5.2]
+  def change
+    create_table :status_mutes do |t|
+      t.integer :account_id, null: false, index: true
+      t.bigint :status_id, null: false, index: true
+    end
+
+    add_index :status_mutes, [:account_id, :status_id], unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 57783dc3a..b1fcad7db 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_07_19_184152) do
+ActiveRecord::Schema.define(version: 2020_07_20_212317) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -278,6 +278,7 @@ ActiveRecord::Schema.define(version: 2020_07_19_184152) do
   create_table "conversation_mutes", force: :cascade do |t|
     t.bigint "conversation_id", null: false
     t.bigint "account_id", null: false
+    t.boolean "hidden", default: false, null: false
     t.index ["account_id", "conversation_id"], name: "index_conversation_mutes_on_account_id_and_conversation_id", unique: true
   end
 
@@ -749,6 +750,14 @@ ActiveRecord::Schema.define(version: 2020_07_19_184152) do
     t.index ["var"], name: "index_site_uploads_on_var", unique: true
   end
 
+  create_table "status_mutes", force: :cascade do |t|
+    t.integer "account_id", null: false
+    t.bigint "status_id", null: false
+    t.index ["account_id", "status_id"], name: "index_status_mutes_on_account_id_and_status_id", unique: true
+    t.index ["account_id"], name: "index_status_mutes_on_account_id"
+    t.index ["status_id"], name: "index_status_mutes_on_status_id"
+  end
+
   create_table "status_pins", force: :cascade do |t|
     t.bigint "account_id", null: false
     t.bigint "status_id", null: false