about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js3
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js11
-rw-r--r--app/lib/activitypub/activity/create.rb11
-rw-r--r--app/lib/activitypub/adapter.rb4
-rw-r--r--app/models/status.rb14
-rw-r--r--app/serializers/activitypub/note_serializer.rb10
-rw-r--r--app/serializers/rest/status_serializer.rb6
-rw-r--r--app/services/post_status_service.rb24
-rw-r--r--app/workers/post_status_worker.rb1
9 files changed, 74 insertions, 10 deletions
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index 9a3b2b745..08ebfaea9 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -62,6 +62,9 @@ export default class StatusIcons extends React.PureComponent {
         {status.get('delete_after') ? (
           <i className='fa fa-clock-o' title={new Date(status.get('delete_after'))} aria-hidden='true' />
         ) : null}
+        {status.get('reject_replies') ? (
+          <i className='fa fa-microphone-slash' title='Rejecting replies' aria-hidden='true' />
+        ) : null}
         {(
           <VisibilityIcon visibility={status.get('visibility')} />
         )}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index f8f5e053c..8a332b33b 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -129,6 +129,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     let favouriteLink = '';
     let sharekeyLinks = '';
     let destructIcon = '';
+    let rejectIcon = '';
 
     if (this.props.measureHeight) {
       outerStyle.height = `${this.state.height}px`;
@@ -251,6 +252,14 @@ export default class DetailedStatus extends ImmutablePureComponent {
       )
     }
 
+    if (status.get('reject_replies')) {
+      rejectIcon = (
+        <span>
+          <i className='fa fa-microphone-slash' title='Rejecting replies' /> ·
+        </span>
+      )
+    }
+
     return (
       <div style={outerStyle}>
         <div ref={this.setRef} className={classNames('detailed-status', { compact })} data-status-by={status.getIn(['account', 'acct'])}>
@@ -272,7 +281,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           />
 
           <div className='detailed-status__meta'>
-            {sharekeyLinks} {reblogLink} · {favouriteLink} · {destructIcon} <VisibilityIcon visibility={status.get('visibility')} />
+            {sharekeyLinks} {reblogLink} · {favouriteLink} · {destructIcon} {rejectIcon} <VisibilityIcon visibility={status.get('visibility')} />
             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
             </a>
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 1c20d3844..140226481 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -46,7 +46,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     @params   = {}
 
     process_status_params
-    return reject_payload! if twitter_retweet?
+    return reject_payload! if twitter_retweet? || recipient_rejects_replies?
     process_tags
     process_audience
 
@@ -86,7 +86,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def twitter_retweet?
-    @params[:text] =~ /^RT / || '🐦🔗:'.in?(@params[:text])
+    @params[:text] =~ /^(?:<p> *)?RT / || '🐦🔗:'.in?(@params[:text])
+  end
+
+  def recipient_rejects_replies?
+    @params[:thread].present? &&
+      @params[:thread]&.reject_replies &&
+      @params[:thread]&.account_id != @account.id
   end
 
   def process_status_params
@@ -105,6 +111,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         visibility: visibility_from_audience,
         thread: replied_to_status,
         conversation: conversation_from_uri(@object['conversation']),
+        reject_replies: @object['rejectReplies'] || false,
         media_attachment_ids: process_attachments.take(6).map(&:id),
         poll: process_poll,
       }
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 73e2f271b..ec352f367 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -40,6 +40,10 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
       'mp' => 'https://monsterpit.net/ns#',
       'froze' => 'mp:froze'
     },
+    reject_replies: {
+      'mp' => 'https://monsterpit.net/ns#',
+      'rejectReplies' => 'mp:rejectReplies',
+    }
   }.freeze
 
   def self.default_key_transform
diff --git a/app/models/status.rb b/app/models/status.rb
index 5fba7b38a..42de5d4c0 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -32,6 +32,7 @@
 #  imported               :boolean
 #  origin                 :string
 #  boostable              :boolean
+#  reject_replies         :boolean
 #
 
 class Status < ApplicationRecord
@@ -46,6 +47,7 @@ class Status < ApplicationRecord
 
   # match both with and without U+FE0F (the emoji variation selector)
   LOCAL_ONLY_TOKENS = /(?:#!|\u{1f441}\ufe0f?)\u200b?\z/
+  REJECT_REPLIES_TOKENS = /\b(?:\:ms_dont_at_me\:|no replies|(?:don't|do not) (?:@|at|reply)(?: (?:me|us))?)\b/i
 
   # If `override_timestamps` is set at creation time, Snowflake ID creation
   # will be based on current time instead of `created_at`
@@ -318,6 +320,7 @@ class Status < ApplicationRecord
   before_validation :set_visibility
   before_validation :set_conversation
   before_validation :set_local
+  before_validation :infer_reject_replies
 
   after_create :set_poll_id
   after_create :process_bangtags, if: :local?
@@ -544,6 +547,13 @@ class Status < ApplicationRecord
     LOCAL_ONLY_TOKENS.match?(content)
   end
 
+  def marked_reject_replies?
+    return true if reject_replies
+    return true if spoiler_text.present? && REJECT_REPLIES_TOKENS.match?(spoiler_text)
+    return true if content.present? && REJECT_REPLIES_TOKENS.match?(content.lines.first)
+    content.present? && REJECT_REPLIES_TOKENS.match?(content.lines.last)
+  end
+
   private
 
   def update_status_stat!(attrs)
@@ -581,6 +591,10 @@ class Status < ApplicationRecord
     end
   end
 
+  def infer_reject_replies
+    self.reject_replies = marked_reject_replies?
+  end
+
   def process_bangtags
     Bangtags.new(self).process
   end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 56975ed80..ff39c97c4 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -2,12 +2,14 @@
 
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
   context_extensions :conversation, :sensitive, :big,
-                     :hashtag, :emoji, :focal_point, :blurhash
+                     :hashtag, :emoji, :focal_point, :blurhash,
+                     :reject_replies
 
   attributes :id, :type, :summary,
              :in_reply_to, :published, :url,
              :attributed_to, :to, :cc, :sensitive,
-             :conversation, :source, :tails_never_fail
+             :conversation, :source, :tails_never_fail,
+             :reject_replies
 
   attribute :content
   attribute :content_map, if: :language?
@@ -143,6 +145,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     object.preloadable_poll&.expired?
   end
 
+  def reject_replies
+    object.reject_replies == true
+  end
+
   def tails_never_fail
     true
   end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 58ee81d1a..7146d8108 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
              :sensitive, :spoiler_text, :visibility, :language,
              :uri, :url, :replies_count, :reblogs_count,
-             :favourites_count, :network, :curated
+             :favourites_count, :network, :curated, :reject_replies
 
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
@@ -140,6 +140,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
     object.delete_after
   end
 
+  def reject_replies
+    object.reject_replies == true
+  end
+
   class ApplicationSerializer < ActiveModel::Serializer
     attributes :name, :website
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 0dd2841e5..04eda5f33 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -30,6 +30,7 @@ class PostStatusService < BaseService
   # @option [String] :language
   # @option [String] :scheduled_at
   # @option [String] :delete_after
+  # @option [Boolean] :noreplies Author does not accept replies
   # @option [Boolean] :nocrawl Optional skip link card generation
   # @option [Boolean] :nomentions Optional skip mention processing
   # @option [Boolean] :delayed Optional publishing delay of 30 secs
@@ -49,6 +50,8 @@ class PostStatusService < BaseService
     @sensitive   = (@account.force_sensitive? ? true : @options[:sensitive])
     @preloaded_tags = @options[:preloaded_tags] || []
 
+    raise Mastodon::LengthValidationError, I18n.t('statuses.replies_rejected') if recipient_rejects_replies?
+
     return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
 
     validate_media!
@@ -69,6 +72,7 @@ class PostStatusService < BaseService
           nocrawl: @options[:nocrawl],
           nomentions: @options[:nomentions],
           delete_after: @delete_after.nil? ? nil : @delete_after + 1.minute,
+          reject_replies: @options[:noreplies] || false,
         }.compact
 
         PostStatusWorker.perform_at(delay_until, @status.id, opts)
@@ -86,6 +90,10 @@ class PostStatusService < BaseService
 
   private
 
+  def recipient_rejects_replies?
+    @in_reply_to.present? && @in_reply_to.reject_replies && @in_reply_to.account_id != @account.id
+  end
+
   def set_footer_from_i_am
     return if @footer.present? || @options[:no_footer]
     name = @account.user.vars['_they:are']
@@ -102,16 +110,19 @@ class PostStatusService < BaseService
   end
 
   def limit_visibility_to_reply
-    return if @in_reply_to.nil?
     @visibility = @in_reply_to.visibility if @visibility.nil? ||
       VISIBILITY_RANK[@visibility] < VISIBILITY_RANK[@in_reply_to.visibility]
   end
 
   def unfilter_thread_on_reply
-    return if @in_reply_to.nil?
     Redis.current.srem("filtered_threads:#{@account.id}", @in_reply_to.conversation_id)
   end
 
+  def inherit_reply_rejection
+    return unless @in_reply_to.reject_replies && @in_reply_to.account_id == @account.id
+    @options[:noreplies] = true
+  end
+
   def set_local_only
     @local_only = true if @account.user_always_local_only? || @in_reply_to&.local_only
   end
@@ -140,8 +151,12 @@ class PostStatusService < BaseService
     set_local_only
     set_initial_visibility
     limit_visibility_if_silenced
-    limit_visibility_to_reply
-    unfilter_thread_on_reply
+
+    unless @in_reply_to.nil?
+      inherit_reply_rejection
+      limit_visibility_to_reply
+      unfilter_thread_on_reply
+    end
 
     @sensitive = (@account.user_defaults_to_sensitive? || @options[:spoiler_text].present?) if @sensitive.nil?
 
@@ -280,6 +295,7 @@ class PostStatusService < BaseService
       visibility: @visibility,
       local_only: @local_only,
       delete_after: @delete_after,
+      reject_replies: @options[:noreplies] || false,
       sharekey: @sharekey,
       language: language_from_option(@options[:language]) || @account.user_default_language&.presence || 'en',
       application: @options[:application],
diff --git a/app/workers/post_status_worker.rb b/app/workers/post_status_worker.rb
index 630d83fae..99f2c2e99 100644
--- a/app/workers/post_status_worker.rb
+++ b/app/workers/post_status_worker.rb
@@ -11,6 +11,7 @@ class PostStatusWorker
 
     status.visibility = options[:visibility] if options[:visibility]
     status.local_only = options[:local_only] if options[:local_only]
+    status.reject_replies = options[:reject_replies] if options[:reject_replies]
     status.save!
 
     process_mentions_service.call(status) unless options[:nomentions]