about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-08-23 09:38:27 -0500
committerFire Demon <firedemon@creature.cafe>2020-08-30 05:45:20 -0500
commit5622de4a785e6b7c9a4946af3efaf8b4c2bc5755 (patch)
treeb9378500aa449af54ac09c44b2aba8f6ee2a184b /app
parent5de7e2d667fa55f353a8b3d988cd047c118a4250 (diff)
[Feature] Support Misskey-compatible boosts with attached content notes
Diffstat (limited to 'app')
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb1
-rw-r--r--app/javascript/flavours/glitch/components/status.js2
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js38
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js5
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/status.scss24
-rw-r--r--app/lib/activitypub/activity/create.rb49
-rw-r--r--app/lib/activitypub/adapter.rb14
-rw-r--r--app/lib/activitypub/case_transform.rb4
-rw-r--r--app/lib/command_tag/command/status_tools.rb14
-rw-r--r--app/lib/command_tag/processor.rb15
-rw-r--r--app/lib/formatter.rb18
-rw-r--r--app/presenters/activitypub/activity_presenter.rb6
-rw-r--r--app/serializers/activitypub/note_serializer.rb16
-rw-r--r--app/services/process_mentions_service.rb4
-rw-r--r--app/services/reblog_service.rb2
-rw-r--r--app/services/remove_status_service.rb2
-rw-r--r--app/views/statuses/_detailed_status.html.haml15
-rw-r--r--app/views/statuses/_simple_status.html.haml21
-rw-r--r--app/views/statuses/_status.html.haml2
19 files changed, 192 insertions, 60 deletions
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index 5c4a15051..0346dcb1d 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -34,7 +34,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
       ActivityPub::CollectionPresenter.new(
         id: account_outbox_url(@account),
         type: :ordered,
-        size: @statuses.count,
         first: account_outbox_url(@account, page: true),
         last: account_outbox_url(@account, page: true, min_id: 0)
       )
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index a94142452..69f93a2f1 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -370,7 +370,7 @@ class Status extends ImmutablePureComponent {
   }
 
   handleExpandedToggle = () => {
-    if (this.props.status.get('spoiler_text')) {
+    if (this.props.status.get('spoiler_text') || this.props.status.get('reblogSpoilerHtml')) {
       this.setExpansion(!this.state.isExpanded);
     }
   };
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 40413b07b..e0a759499 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -422,19 +422,35 @@ export default class StatusContent extends React.PureComponent {
       </div>
     );
 
+    const reblog_spoiler_html = status.get('reblogSpoilerPresent') && { __html: status.get('reblogSpoilerHtml') };
+    const reblog_spoiler = reblog_spoiler_html && (
+      <div className='reblog-spoiler spoiler'>
+        <Icon id='retweet' />
+        <span dangerouslySetInnerHTML={reblog_spoiler_html} />
+      </div>
+    );
+
+    const spoiler_html = status.get('spoiler_text').length > 0 && { __html: status.get('spoilerHtml') };
+    const spoiler = spoiler_html && (
+      <div className='spoiler'>
+        <Icon id='info-circle' />
+        <span dangerouslySetInnerHTML={spoiler_html} />
+      </div>
+    );
+
+    const spoiler_present = status.get('spoiler_text').length > 0 || status.get('reblogSpoilerPresent');
     const content = { __html: article ? status.get('articleHtml') : status.get('contentHtml') };
-    const spoilerContent = { __html: status.get('spoilerHtml') };
     const directionStyle = { direction: 'ltr' };
     const classNames = classnames('status__content', {
       'status__content--with-action': parseClick && !disabled,
-      'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+      'status__content--with-spoiler': spoiler_present,
     });
 
     if (isRtl(status.get('search_index'))) {
       directionStyle.direction = 'rtl';
     }
 
-    if (status.get('spoiler_text').length > 0) {
+    if (spoiler_present) {
       let mentionsPlaceholder = '';
 
       const mentionLinks = status.get('mentions').map(item => (
@@ -486,15 +502,17 @@ export default class StatusContent extends React.PureComponent {
       return (
         <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} ref={this.setRef}>
           {status_notice_wrapper}
-          <p
+          <div
             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
           >
-            <span dangerouslySetInnerHTML={spoilerContent} />
-            {' '}
-            <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
-              {toggleText}
-            </button>
-          </p>
+            {reblog_spoiler}
+            {spoiler}
+            <div class='spoiler-actions'>
+              <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
+                {toggleText}
+              </button>
+            </div>
+          </div>
 
           {mentionsPlaceholder}
 
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index bb9180d12..3571aea3e 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -141,6 +141,11 @@ export const makeGetStatus = () => {
         }
       }
 
+      if (statusReblog) {
+        statusReblog = statusReblog.set('reblogSpoilerPresent', statusBase.get('spoiler_text').length > 0);
+        statusReblog = statusReblog.set('reblogSpoilerHtml', statusBase.get('spoilerHtml'));
+      }
+
       return statusBase.withMutations(map => {
         map.set('reblog', statusReblog);
         map.set('account', accountBase);
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
index 0486c9a80..65ac9286d 100644
--- a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
@@ -142,15 +142,25 @@ div[data-nest-deep="true"] {
     margin-bottom: 0px;
   }
 
-  p:first-child,
-  pre:first-child,
-  blockquote:first-child,
-  div.status__notice-wrapper + p {
-    margin-top: 0px;
+  .status__content__spoiler {
+    .status__content__spoiler--visible {
+      margin-top: 1em;
+    }
   }
 
-  .status__content__spoiler.status__content__spoiler--visible {
-    margin-top: 1em;
+  .spoiler {
+    & > i {
+      padding-right: 8px;
+      color: lighten($dark-text-color, 4%);
+    }
+  }
+
+  .reblog-spoiler {
+    font-style: italic;
+
+    & > span {
+      color: lighten($ui-highlight-color, 8%);
+    }
   }
 }
 
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 69480ccf6..9ab60107b 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -85,7 +85,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     @params   = {}
 
     unless @status.nil?
-      process_status_update_params
+      reblog_uri.blank? ? process_status_update_params : process_reblog_update_params
       process_tags
       process_audience
 
@@ -95,7 +95,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       return @status
     end
 
-    process_status_params
+    reblog_uri.blank? ? process_status_params : process_reblog_params
     process_tags
     process_audience
 
@@ -128,6 +128,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         language: detected_language,
         spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
         title: text_from_title,
+        reblog: reblogged_status,
         created_at: @object['published'],
         override_timestamps: @options[:override_timestamps],
         reply: @object['inReplyTo'].present?,
@@ -155,6 +156,40 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     end
   end
 
+  def process_reblog_params
+    @params = begin
+      {
+        uri: object_uri,
+        url: object_url || object_uri,
+        account: @account,
+        text: text_from_content || '',
+        language: detected_language,
+        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
+        title: text_from_title,
+        reblog: reblogged_status,
+        created_at: @object['published'],
+        override_timestamps: @options[:override_timestamps],
+        reply: @object['inReplyTo'].present?,
+        sensitive: @object['sensitive'] || false,
+        visibility: visibility_from_audience,
+        thread: replied_to_status,
+      }
+    end
+  end
+
+  def process_reblog_update_params
+    @params = begin
+      {
+        text: text_from_content || '',
+        language: detected_language,
+        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
+        title: text_from_title,
+        sensitive: @object['sensitive'] || false,
+        visibility: visibility_from_audience,
+      }
+    end
+  end
+
   def process_audience
     (as_array(audience_to) + as_array(audience_cc)).uniq.each do |audience|
       next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
@@ -459,6 +494,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     value_or_id(@object['inReplyTo'])
   end
 
+  def reblogged_status
+    FetchRemoteStatusService.new.call(reblog_uri) if reblog_uri.present?
+  end
+
+  def reblog_uri
+    return @reblog_uri if defined?(@reblog_uri)
+
+    @reblog_uri = @object['reblog'].presence || @object['_misskey_quote'].presence
+  end
+
   def text_from_content
     return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
 
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 107b93b44..33fa47d63 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -8,15 +8,17 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 
   CONTEXT_EXTENSION_MAP = {
     direct_message: { 'litepub': 'http://litepub.social/ns#', 'directMessage': 'litepub:directMessage' },
-    edited: { 'mp' => 'http://the.monsterpit.net/ns#', 'edited' => 'mp:edited' },
-    require_dereference: { 'mp' => 'http://the.monsterpit.net/ns#', 'requireDereference' => 'mp:requireDereference' },
-    show_replies: { 'mp' => 'http://the.monsterpit.net/ns#', 'showReplies' => 'mp:showReplies' },
-    show_unlisted: { 'mp' => 'http://the.monsterpit.net/ns#', 'showUnlisted' => 'mp:showUnlisted' },
-    private: { 'mp' => 'http://the.monsterpit.net/ns#', 'private' => 'mp:private' },
-    require_auth: { 'mp' => 'http://the.monsterpit.net/ns#', 'requireAuth' => 'mp:requireAuth' },
+    edited: { 'mp' => 'https://the.monsterpit.net/ns#', 'edited' => 'mp:edited' },
+    require_dereference: { 'mp' => 'https://the.monsterpit.net/ns#', 'requireDereference' => 'mp:requireDereference' },
+    show_replies: { 'mp' => 'https://the.monsterpit.net/ns#', 'showReplies' => 'mp:showReplies' },
+    show_unlisted: { 'mp' => 'https://the.monsterpit.net/ns#', 'showUnlisted' => 'mp:showUnlisted' },
+    private: { 'mp' => 'https://the.monsterpit.net/ns#', 'private' => 'mp:private' },
+    require_auth: { 'mp' => 'https://the.monsterpit.net/ns#', 'requireAuth' => 'mp:requireAuth' },
     metadata: { 'mp' => 'https://the.monsterpit.net/ns#', 'metadata' => { '@id' => 'mp:metadata', '@type' => '@id' } },
     server_metadata: { 'mp' => 'https://the.monsterpit.net/ns#', 'serverMetadata' => { '@id' => 'mp:serverMetadata', '@type' => '@id' } },
     root: { 'mp' => 'https://the.monsterpit.net/ns#', 'root' => { '@id' => 'mp:root', '@type' => '@id' } },
+    reblog: { 'mp' => 'https://the.monsterpit.net/ns#', 'reblog' => { '@id' => 'mp:reblog', '@type' => '@id' },
+              'misskey' => 'https://misskey.io/ns#', '_misskey_quote' => { '@id' => 'misskey:_misskey_quote', '@type' => '@id' } },
     manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' },
     sensitive: { 'sensitive' => 'as:sensitive' },
     hashtag: { 'Hashtag' => 'as:Hashtag' },
diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb
index 7f716f862..7f31fabda 100644
--- a/app/lib/activitypub/case_transform.rb
+++ b/app/lib/activitypub/case_transform.rb
@@ -14,8 +14,10 @@ module ActivityPub::CaseTransform
       when String
         camel_lower_cache[value] ||= if value.start_with?('_:')
                                        '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
-                                     else
+                                     elsif value != '_misskey_quote'
                                        value.underscore.camelize(:lower)
+                                     else
+                                       value
                                      end
       else value
       end
diff --git a/app/lib/command_tag/command/status_tools.rb b/app/lib/command_tag/command/status_tools.rb
index 1cdb90e4a..5cc11dde2 100644
--- a/app/lib/command_tag/command/status_tools.rb
+++ b/app/lib/command_tag/command/status_tools.rb
@@ -1,5 +1,19 @@
 # frozen_string_literal: true
 module CommandTag::Command::StatusTools
+  def handle_boost_once_at_start(args)
+    return unless @parent.present? && StatusPolicy.new(@account, @parent).reblog?
+
+    status = ReblogService.new.call(
+      @account, @parent,
+      visibility: @status.visibility,
+      spoiler_text: args.join(' ').presence || @status.spoiler_text
+    )
+  end
+
+  alias handle_reblog_at_start handle_boost_once_at_start
+  alias handle_rb_at_start handle_boost_once_at_start
+  alias handle_rt_at_start handle_boost_once_at_start
+
   def handle_article_before_save(args)
     return unless author_of_status? && args.present?
 
diff --git a/app/lib/command_tag/processor.rb b/app/lib/command_tag/processor.rb
index 2c33b5f83..9edcb58ba 100644
--- a/app/lib/command_tag/processor.rb
+++ b/app/lib/command_tag/processor.rb
@@ -12,6 +12,12 @@
 
 require_relative 'commands'
 
+class CommandTag::Break < Mastodon::Error
+  def initialize(msg = 'A handler stopped execution.')
+    super
+  end
+end
+
 class CommandTag::Processor
   include Redisable
   include ImgProxyHelper
@@ -83,6 +89,8 @@ class CommandTag::Processor
 
     execute_statements(:at_end)
     all_handlers!(:shutdown)
+  rescue CommandTag::Break
+    nil
   rescue StandardError => e
     @status.update(published: false)
     @status.destroy
@@ -247,6 +255,13 @@ class CommandTag::Processor
     @status.destroy
   end
 
+  def replace_status!(new_status)
+    return if new_status.blank?
+
+    destroy_status!
+    @status = new_status
+  end
+
   def normalize(text)
     text.to_s.strip.downcase
   end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 5559ddb73..6673f4b4b 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -32,15 +32,8 @@ class Formatter
   include ActionView::Helpers::TextHelper
 
   def format(status, **options)
-    if status.reblog?
-      prepend_reblog = status.reblog.account.acct
-      status         = status.proper
-    else
-      prepend_reblog = false
-    end
-
     summary = nil
-    raw_content = status.text
+    raw_content = status.proper.text
     summary_mode = false
 
     if status.title.present?
@@ -56,8 +49,13 @@ class Formatter
     return '' if raw_content.blank?
     return format_remote_content(raw_content, status.emojis, summary: summary, **options) unless status.local?
 
-    html = raw_content
-    html = "RT @#{prepend_reblog} #{html}" if prepend_reblog
+    if status.reblog?
+      html = "🔁 @#{status.reblog.account.acct}\n🔗 #{ActivityPub::TagManager.instance.url_for(status.reblog)}"
+      html += "\nℹ️ #{status.reblog.spoiler_text}" if status.reblog.spoiler_text.present?
+    else
+      html = raw_content
+    end
+
     html = "📄 #{html}" if summary_mode
     return html if options[:plaintext]
 
diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb
index 4d5d28611..7a19cc96a 100644
--- a/app/presenters/activitypub/activity_presenter.rb
+++ b/app/presenters/activitypub/activity_presenter.rb
@@ -8,7 +8,7 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
       new.tap do |presenter|
         default_activity    = update && status.edited.positive? ? 'Update' : 'Create'
         presenter.id        = ActivityPub::TagManager.instance.activity_uri_for(status)
-        presenter.type      = status.reblog? ? 'Announce' : default_activity
+        presenter.type      = (status.reblog? && status.spoiler_text.blank? ? 'Announce' : default_activity)
         presenter.actor     = ActivityPub::TagManager.instance.uri_for(status.account)
         presenter.published = status.created_at
         presenter.to        = ActivityPub::TagManager.instance.to(status)
@@ -20,14 +20,14 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
         end
 
         presenter.virtual_object = begin
-          if status.reblog?
+          if status.reblog? && status.spoiler_text.blank?
             if status.account == status.proper.account && status.proper.private_visibility? && status.local?
               status.proper
             else
               ActivityPub::TagManager.instance.uri_for(status.proper)
             end
           else
-            status.proper
+            status
           end
         end
       end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index acadce1b2..1ab2ed35a 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,7 +3,7 @@
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
   context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message
 
-  context_extensions :edited, :server_metadata, :root
+  context_extensions :edited, :server_metadata, :root, :reblog
 
   attributes :id, :type, :summary,
              :in_reply_to, :published, :url,
@@ -13,6 +13,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 
   attributes :updated, :root
   attribute :title, key: :name, if: :title_present?
+  attribute :reblog, if: :reblog_present?
+  attribute :renote, key: '_misskey_quote', if: :reblog_present?
 
   attribute :content
   attribute :content_map, if: :language?
@@ -208,6 +210,18 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     Mastodon::Version.server_metadata_json
   end
 
+  def reblog
+    ActivityPub::TagManager.instance.uri_for(object.reblog)
+  end
+
+  def renote
+    ActivityPub::TagManager.instance.uri_for(object.reblog)
+  end
+
+  def reblog_present?
+    object.reblog_of_id.present?
+  end
+
   class MediaAttachmentSerializer < ActivityPub::Serializer
     context_extensions :blurhash, :focal_point
 
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 2ec21f22b..ba61e4464 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -9,8 +9,8 @@ class ProcessMentionsService < BaseService
   # @param [Status] status
   # @option [Enumerable] :mentions Mentions to include
   # @option [Boolean] :deliver Deliver mention notifications
-  def call(status, mentions: [], reveal_implicit_mentions: true, deliver: true)
-    return unless status.local?
+  def call(status, mentions: [], deliver: true)
+    return unless status.local? && !status.frozen?
 
     @status = status
     @status.text, mentions = ResolveMentionsService.new.call(@status, mentions: mentions)
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index f4280e9b2..ddd22e379 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -28,7 +28,7 @@ class ReblogService < BaseService
       end
     end
 
-    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
+    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit], sensitive: true, spoiler_text: options[:spoiler_text] || '', published: true)
 
     DistributionWorker.perform_async(reblog.id)
     ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 72ef6a9ec..f5983fc40 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -121,7 +121,7 @@ class RemoveStatusService < BaseService
   end
 
   def signed_activity_json
-    @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
+    @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? && @status.spoiler_text.blank? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
   end
 
   def remove_reblogs
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index d3c538368..ade03df39 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -15,10 +15,13 @@
 
   = account_action_button(status.account)
 
-  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
+  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.title? || status.spoiler_text?) }
     - if status.title? || status.spoiler_text?
-      %p<
-        %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
+      %div.spoiler
+        = fa_icon 'info-circle fw'
+        %span.p-summary= Formatter.instance.format_spoiler(status, autoplay: autoplay)
+    - if status.title? || status.spoiler_text? || parent_status&.spoiler_text?
+      %div
         %button.status__content__spoiler-link= t('statuses.show_more')
     .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay, article_content: true)
@@ -29,17 +32,17 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
+      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive? || parent_status&.sensitive?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
       = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
-      = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
+      = react_component :media_gallery, height: 380, sensitive: status.sensitive? || parent_status&.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: status.sensitive? || parent_status&.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index f76d57ae4..d826d38e6 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -21,10 +21,17 @@
           %span.display-name__account
             = acct(status.account)
             = fa_icon('lock') if status.account.locked?
-  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
-    - if status.spoiler_text?
-      %p<
-        %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
+  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.title? || status.spoiler_text? || parent_status&.spoiler_text?) }<
+    - if parent_status&.spoiler_text?
+      %div.spoiler.reblog-spoiler
+        = fa_icon 'retweet fw'
+        %span.p-summary= Formatter.instance.format_spoiler(parent_status, autoplay: autoplay)
+    - if status.title? || status.spoiler_text?
+      %div.spoiler
+        = fa_icon 'info-circle fw'
+        %span.p-summary= Formatter.instance.format_spoiler(status, autoplay: autoplay)
+    - if status.title? || status.spoiler_text? || parent_status&.spoiler_text?
+      %div
         %button.status__content__spoiler-link= t('statuses.show_more')
     .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }<
       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay, article_content: true)
@@ -35,17 +42,17 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive?, width: 610, height: 343, inline: true, alt: video.description do
+      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive? || parent_status&.sensitive?, width: 610, height: 343, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
       = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
-      = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
+      = react_component :media_gallery, height: 343, sensitive: status.sensitive? || parent_status&.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: status.sensitive? || parent_status&.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   - if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id
     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
diff --git a/app/views/statuses/_status.html.haml b/app/views/statuses/_status.html.haml
index 5923316b4..1c8acadf2 100644
--- a/app/views/statuses/_status.html.haml
+++ b/app/views/statuses/_status.html.haml
@@ -32,7 +32,7 @@
       .status__prepend-icon-wrapper
         %i.status__prepend-icon.fa.fa-fw.fa-thumb-tack
 
-  = render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, autoplay: autoplay
+  = render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, autoplay: autoplay, parent_status: status
 
 - if include_threads
   - if @since_descendant_thread_id