about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js2
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js40
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js1
-rw-r--r--app/javascript/flavours/glitch/styles/monsterfork/components/status.scss19
-rw-r--r--app/javascript/mastodon/locales/en-MP.json2
-rw-r--r--app/lib/command_tag/command/status_tools.rb22
-rw-r--r--app/lib/formatter.rb20
-rw-r--r--app/lib/sanitize_config.rb19
-rw-r--r--app/serializers/activitypub/note_serializer.rb10
-rw-r--r--app/serializers/rest/status_serializer.rb18
-rw-r--r--app/views/statuses/_detailed_status.html.haml4
11 files changed, 135 insertions, 22 deletions
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 70431dce3..729c8d700 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -61,6 +61,7 @@ export function normalizeStatus(status, normalOldStatus) {
   if (normalOldStatus && oldUpdatedAt === newUpdatedAt) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
+    normalStatus.articleHtml = normalOldStatus.get('articleHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
@@ -69,6 +70,7 @@ export function normalizeStatus(status, normalOldStatus) {
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
+    normalStatus.articleHtml  = normalStatus.article_content ? emojify(normalStatus.article_content, emojiMap) : normalStatus.contentHtml;
     normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
   }
 
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index d34da1a8f..0d861b9c0 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -78,11 +78,13 @@ export default class StatusContent extends React.PureComponent {
     onUpdate: PropTypes.func,
     tagLinks: PropTypes.bool,
     rewriteMentions: PropTypes.string,
+    article: PropTypes.bool,
   };
 
   static defaultProps = {
     tagLinks: true,
     rewriteMentions: 'no',
+    article: false,
   };
 
   state = {
@@ -272,6 +274,7 @@ export default class StatusContent extends React.PureComponent {
       disabled,
       tagLinks,
       rewriteMentions,
+      article,
     } = this.props;
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
@@ -324,12 +327,29 @@ export default class StatusContent extends React.PureComponent {
       </div>
     );
 
+    const article_content = status.get('article') && (
+      <div className='status__notice status__article-notice'>
+        <Icon id='file-text-o' />
+        <Permalink
+          href={status.get('url')}
+          to={`/statuses/${status.get('id')}`}
+        >
+          <FormattedMessage
+            id='status.article'
+            defaultMessage='Article'
+            key={`article-${status.get('id')}`}
+          />
+        </Permalink>
+      </div>
+    );
+
     const status_notice_wrapper = (
       <div className='status__notice-wrapper'>
         {unpublished}
         {quiet}
         {edited}
         {local_only}
+        {article_content}
       </div>
     );
 
@@ -402,7 +422,7 @@ export default class StatusContent extends React.PureComponent {
       </div>
     );
 
-    const content = { __html: status.get('contentHtml') };
+    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', {
@@ -429,11 +449,19 @@ export default class StatusContent extends React.PureComponent {
       )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
 
       const toggleText = hidden ? [
-        <FormattedMessage
-          id='status.show_more'
-          defaultMessage='Show more'
-          key='0'
-        />,
+        article ? (
+          <FormattedMessage
+            id='status.show_article'
+            defaultMessage='Show article'
+            key='0'
+          />
+        ) : (
+          <FormattedMessage
+            id='status.show_more'
+            defaultMessage='Show more'
+            key='0'
+          />
+        ),
         mediaIcon ? (
           <Icon
             fixedWidth
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 b042a825a..7f1fdb644 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -277,6 +277,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             onUpdate={this.handleChildUpdate}
             tagLinks={settings.get('tag_misleading_links')}
             rewriteMentions={settings.get('rewrite_mentions')}
+            article
             disabled
           />
 
diff --git a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
index 30cd5d668..73f883db1 100644
--- a/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/monsterfork/components/status.scss
@@ -8,19 +8,30 @@
 }
 
 .status__notice {
-  & > span {
-    color: $dark-text-color;
+  display: flex;
+  align-items: center;
+
+  & > span, & > a {
+    display: inline-flex;
+    align-items: center;
     line-height: normal;
     font-style: italic;
+    font-weight: bold;
     font-size: 12px;
     padding-left: 8px;
-    position: relative;
-    bottom: 0.2em;
+    height: 1.5em;
+  }
+
+  & > span {
+    color: $dark-text-color;
   }
 
   & > i {
+    display: inline-flex;
+    align-items: center;
     color: lighten($dark-text-color, 4%);
     width: 1.1em;
+    height: 1.5em;
   }
 }
 
diff --git a/app/javascript/mastodon/locales/en-MP.json b/app/javascript/mastodon/locales/en-MP.json
index 2818b7003..ec331be2c 100644
--- a/app/javascript/mastodon/locales/en-MP.json
+++ b/app/javascript/mastodon/locales/en-MP.json
@@ -125,6 +125,7 @@
   "settings.side_arm": "Secondary roar button:",
   "status.admin_account": "Moderate @{name}",
   "status.admin_status": "Moderate roar",
+  "status.article": "Article",
   "status.cannot_reblog": "This roar cannot be boosted",
   "status.copy": "Copy link to roar",
   "status.edit": "Edit",
@@ -142,6 +143,7 @@
   "status.publish": "Publish",
   "status.reblogged_by": "{name}",
   "status.reblogs.empty": "No one has boosted this roar yet. When someone does, they will show up here.",
+  "status.show_article": "Show article",
   "status.show_less_all": "Hide all",
   "status.show_less": "Hide",
   "status.show_more_all": "Reveal all",
diff --git a/app/lib/command_tag/command/status_tools.rb b/app/lib/command_tag/command/status_tools.rb
index 1727a956e..1cdb90e4a 100644
--- a/app/lib/command_tag/command/status_tools.rb
+++ b/app/lib/command_tag/command/status_tools.rb
@@ -1,11 +1,29 @@
 # frozen_string_literal: true
 module CommandTag::Command::StatusTools
+  def handle_article_before_save(args)
+    return unless author_of_status? && args.present?
+
+    case args.shift.downcase
+    when 'title', 'name', 't'
+      status.title = args.join(' ')
+    when 'summary', 'abstract', 'cw', 'cn', 's', 'a'
+      @status.title = @status.spoiler_text if @status.title.blank?
+      @status.spoiler_text = args.join(' ')
+    end
+  end
+
   def handle_title_before_save(args)
-    return unless author_of_status?
+    args.unshift('title')
+    handle_article_before_save(args)
+  end
 
-    @status.title = args[0]
+  def handle_summary_before_save(args)
+    args.unshift('summary')
+    handle_article_before_save(args)
   end
 
+  alias handle_abstract_before_save handle_summary_before_save
+
   def handle_visibility_before_save(args)
     return unless author_of_status? && args[0].present?
 
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index d85bbf0e0..bf99701b7 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -39,8 +39,14 @@ class Formatter
       prepend_reblog = false
     end
 
+    summary = nil
     raw_content = status.text
 
+    if status.title.present?
+      summary = status.spoiler_text.presence || status.text
+      raw_content = options[:article_content] ? status.text : summary
+    end
+
     if options[:inline_poll_options] && status.preloadable_poll
       raw_content = raw_content + "\n\n" + status.preloadable_poll.options.map { |title| "[ ] #{title}" }.join("\n")
     end
@@ -50,6 +56,7 @@ class Formatter
     unless status.local?
       html = reformat(raw_content)
       html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
+      html = format_article_content(summary, html) if options[:article_content] && summary.present?
       return html.html_safe # rubocop:disable Rails/OutputSafety
     end
 
@@ -68,6 +75,7 @@ class Formatter
       html = html.delete("\n")
     end
 
+    html = format_article_content(summary, html) if options[:article_content] && summary.present?
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
@@ -77,10 +85,14 @@ class Formatter
   end
 
   def format_article(text)
-    text = text.gsub(/>[\r\n]+</, "><")
+    text = text.gsub(/>[\r\n]+</, '><')
     text.html_safe # rubocop:disable Rails/OutputSafety
   end
 
+  def format_article_content(summary, html)
+    "<blockquote data-name=\"summary\">#{format_summary(summary, html)}</blockquote>#{html}"
+  end
+
   def reformat(html, outgoing = false)
     sanitize(html, Sanitize::Config::MASTODON_STRICT.merge(outgoing: outgoing))
   rescue ArgumentError
@@ -108,8 +120,12 @@ class Formatter
     Sanitize.fragment(html, config)
   end
 
+  def format_summary(summary, fallback)
+    summary&.strip.presence || fallback[/(?:<p>.*?<\/p>)/im].presence || '🗎❓'
+  end
+
   def format_spoiler(status, **options)
-    html = encode(status.spoiler_text)
+    html = encode(status.title.presence || status.spoiler_text)
     html = encode_custom_emojis(html, status.emojis, options[:autoplay])
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index 74a1d93fb..102dce2d2 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -30,12 +30,25 @@ class Sanitize
         next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
         next true if e =~ /^(mention|hashtag)$/ # semantic classes
         next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
-        next true if %w(center centered).include?(e)
+        next true if %w(center centered abstract).include?(e)
       end
 
       node['class'] = class_list.join(' ')
     end
 
+    DATA_NAME_ALLOWLIST_TRANSFORMER = lambda do |env|
+      node = env[:node]
+      name_list = node['data-name']&.split(/[\t\n\f\r ]/)
+
+      return unless name_list
+
+      name_list.keep_if do |name|
+        next true if %w(summary abstract).include?(name)
+      end
+
+      node['data-name'] = name_list.join(' ')
+    end
+
     LINK_REL_TRANSFORMER = lambda do |env|
       return unless env[:node_name] == 'a' and env[:node]['href']
 
@@ -71,10 +84,11 @@ class Sanitize
         'a'          => %w(href rel class title),
         'span'       => %w(class),
         'abbr'       => %w(title),
-        'blockquote' => %w(cite),
+        'blockquote' => %w(cite data-name),
         'ol'         => %w(start reversed),
         'li'         => %w(value),
         'img'        => %w(src alt title),
+        'p'          => %w(data-name),
       },
 
       add_attributes: {
@@ -90,6 +104,7 @@ class Sanitize
 
       transformers: [
         CLASS_WHITELIST_TRANSFORMER,
+        DATA_NAME_ALLOWLIST_TRANSFORMER,
         UNSUPPORTED_HREF_TRANSFORMER,
         LINK_REL_TRANSFORMER,
       ]
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 3d99e29c4..163f25560 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -51,6 +51,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def summary
+    return Formatter.instance.format(object) if title_present?
+
     object.spoiler_text.presence || (instance_options[:allow_local_only] ? nil : Setting.outgoing_spoilers.presence)
   end
 
@@ -67,11 +69,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def content
-    Formatter.instance.format(object)
+    Formatter.instance.format(object, article_content: true)
   end
 
   def content_map
-    { object.language => Formatter.instance.format(object) }
+    { object.language => Formatter.instance.format(object, article_content: true) }
   end
 
   def replies
@@ -193,7 +195,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def title_present?
-    object.title.present?
+    return @has_title if defined?(@has_title)
+
+    @has_title = object.title.present?
   end
 
   def server_metadata
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 081b42979..dec39ec24 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -7,7 +7,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
              :favourites_count
 
   # Monsterfork additions
-  attributes :updated_at, :edited, :nest_level, :title
+  attributes :updated_at, :edited, :nest_level
 
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
@@ -24,6 +24,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :hidden, if: :current_user?
   attribute :conversation_hidden, if: :current_user?
   attribute :notify, if: :locally_owned?
+  attribute :title?, key: :article
+  attribute :article_content, if: :title?
 
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application, if: :show_application?
@@ -63,6 +65,12 @@ class REST::StatusSerializer < ActiveModel::Serializer
     object.local? && owned?
   end
 
+  def title?
+    return @has_title if defined?(@has_title)
+
+    @has_title = object.title.present?
+  end
+
   def show_application?
     object.account.user_shows_application? || owned?
   end
@@ -82,10 +90,18 @@ class REST::StatusSerializer < ActiveModel::Serializer
     ActivityPub::TagManager.instance.uri_for(object)
   end
 
+  def spoiler_text
+    title? ? object.title : object.spoiler_text
+  end
+
   def content
     Formatter.instance.format(object)
   end
 
+  def article_content
+    Formatter.instance.format(object, article_content: true)
+  end
+
   def text
     object.original_text.presence || object.text
   end
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index b3e9c44fc..d3c538368 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -16,12 +16,12 @@
   = account_action_button(status.account)
 
   .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
-    - if status.spoiler_text?
+    - if status.title? || status.spoiler_text?
       %p<
         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
         %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)
+      = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay, article_content: true)
       - if status.preloadable_poll
         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
           = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }