From 2b8dc58b7ff7fb708687c08a75c99b3fb30efc49 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 9 May 2022 07:43:08 +0200 Subject: [PATCH] Change RSS feeds (#18356) * Change RSS feeds - Use date and time for titles instead of ellipsized text - Use full content in body, even when there is a content warning - Use media extensions * Change feed icons and add width and height attributes to custom emojis * Fix custom emoji animate on hover breaking * Fix tests --- app/controllers/accounts_controller.rb | 1 - app/controllers/tags_controller.rb | 1 - app/helpers/application_helper.rb | 4 +- app/helpers/formatting_helper.rb | 26 +++++ app/lib/emoji_formatter.rb | 28 ++++- app/lib/rss/builder.rb | 33 ++++++ app/lib/rss/channel.rb | 49 ++++++++ app/lib/rss/element.rb | 24 ++++ app/lib/rss/item.rb | 45 ++++++++ app/lib/rss/media_content.rb | 29 +++++ app/lib/rss/serializer.rb | 55 --------- app/lib/rss_builder.rb | 130 ---------------------- app/serializers/rss/account_serializer.rb | 28 ----- app/serializers/rss/tag_serializer.rb | 25 ----- app/views/accounts/show.rss.ruby | 37 ++++++ app/views/tags/show.rss.ruby | 36 ++++++ config/locales/en.yml | 5 + spec/lib/emoji_formatter_spec.rb | 6 +- spec/lib/rss/serializer_spec.rb | 56 ---------- 19 files changed, 311 insertions(+), 307 deletions(-) create mode 100644 app/lib/rss/builder.rb create mode 100644 app/lib/rss/channel.rb create mode 100644 app/lib/rss/element.rb create mode 100644 app/lib/rss/item.rb create mode 100644 app/lib/rss/media_content.rb delete mode 100644 app/lib/rss/serializer.rb delete mode 100644 app/lib/rss_builder.rb delete mode 100644 app/serializers/rss/account_serializer.rb delete mode 100644 app/serializers/rss/tag_serializer.rb create mode 100644 app/views/accounts/show.rss.ruby create mode 100644 app/views/tags/show.rss.ruby delete mode 100644 spec/lib/rss/serializer_spec.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index ddd38cbb01..fe7d934dc3 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -44,7 +44,6 @@ class AccountsController < ApplicationController limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE @statuses = filtered_statuses.without_reblogs.limit(limit) @statuses = cache_collection(@statuses, Status) - render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end format.json do diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 6616ba107c..b82da8f0cd 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -26,7 +26,6 @@ class TagsController < ApplicationController format.rss do expires_in 0, public: true - render xml: RSS::TagSerializer.render(@tag, @statuses) end format.json do diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 19dc0acd6d..bba7070d0d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -243,7 +243,7 @@ module ApplicationHelper end.values end - def prerender_custom_emojis(html, custom_emojis) - EmojiFormatter.new(html, custom_emojis, animate: prefers_autoplay?).to_s + def prerender_custom_emojis(html, custom_emojis, other_options = {}) + EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s end end diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index a58dd608f8..f5b8dbed87 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -18,6 +18,32 @@ module FormattingHelper html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) end + def rss_status_content_format(status) + html = status_content_format(status) + + before_html = begin + if status.spoiler_text? + "

#{I18n.t('rss.content_warning', locale: valid_locale_or_nil(status.language))} #{h(status.spoiler_text)}


" + else + '' + end + end.html_safe # rubocop:disable Rails/OutputSafety + + after_html = begin + if status.preloadable_poll + "

#{status.preloadable_poll.options.map { |o| " #{h(o)}" }.join('
')}

" + else + '' + end + end.html_safe # rubocop:disable Rails/OutputSafety + + prerender_custom_emojis( + safe_join([before_html, html, after_html]), + status.emojis, + style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' + ).to_str + end + def account_bio_format(account) html_aware_format(account.note, account.local?) end diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb index f808f3a226..194849c23d 100644 --- a/app/lib/emoji_formatter.rb +++ b/app/lib/emoji_formatter.rb @@ -11,6 +11,7 @@ class EmojiFormatter # @param [Array] custom_emojis # @param [Hash] options # @option options [Boolean] :animate + # @option options [String] :style def initialize(html, custom_emojis, options = {}) raise ArgumentError unless html.html_safe? @@ -85,14 +86,29 @@ class EmojiFormatter def image_for_emoji(shortcode, emoji) original_url, static_url = emoji - if animate? - image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:") - else - image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url }) - end + image_tag( + animate? ? original_url : static_url, + image_attributes.merge(alt: ":#{shortcode}:", title: ":#{shortcode}:", data: image_data_attributes(original_url, static_url)) + ) + end + + def image_attributes + { rel: 'emoji', draggable: false, width: 16, height: 16, class: image_class_names, style: image_style } + end + + def image_data_attributes(original_url, static_url) + { original: original_url, static: static_url } unless animate? + end + + def image_class_names + animate? ? 'emojione' : 'emojione custom-emoji' + end + + def image_style + @options[:style] end def animate? - @options[:animate] + @options[:animate] || @options.key?(:style) end end diff --git a/app/lib/rss/builder.rb b/app/lib/rss/builder.rb new file mode 100644 index 0000000000..a9b3f08c5a --- /dev/null +++ b/app/lib/rss/builder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class RSS::Builder + attr_reader :dsl + + def self.build + new.tap do |builder| + yield builder.dsl + end.to_xml + end + + def initialize + @dsl = RSS::Channel.new + end + + def to_xml + (''.dup << Ox.dump(wrap_in_document, effort: :tolerant)).force_encoding('UTF-8') + end + + private + + def wrap_in_document + Ox::Document.new(version: '1.0').tap do |document| + document << Ox::Element.new('rss').tap do |rss| + rss['version'] = '2.0' + rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0' + rss['xmlns:media'] = 'http://search.yahoo.com/mrss/' + + rss << @dsl.to_element + end + end + end +end diff --git a/app/lib/rss/channel.rb b/app/lib/rss/channel.rb new file mode 100644 index 0000000000..1dba94e47e --- /dev/null +++ b/app/lib/rss/channel.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class RSS::Channel < RSS::Element + def initialize + super() + + @root = create_element('channel') + end + + def title(str) + append_element('title', str) + end + + def link(str) + append_element('link', str) + end + + def last_build_date(date) + append_element('lastBuildDate', date.to_formatted_s(:rfc822)) + end + + def image(url, title, link) + append_element('image') do |image| + image << create_element('url', url) + image << create_element('title', title) + image << create_element('link', link) + end + end + + def description(str) + append_element('description', str) + end + + def generator(str) + append_element('generator', str) + end + + def icon(str) + append_element('webfeeds:icon', str) + end + + def logo(str) + append_element('webfeeds:logo', str) + end + + def item(&block) + @root << RSS::Item.with(&block) + end +end diff --git a/app/lib/rss/element.rb b/app/lib/rss/element.rb new file mode 100644 index 0000000000..7142fa0396 --- /dev/null +++ b/app/lib/rss/element.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class RSS::Element + def self.with(*args, &block) + new(*args).tap(&block).to_element + end + + def create_element(name, content = nil) + Ox::Element.new(name).tap do |element| + yield element if block_given? + element << content if content.present? + end + end + + def append_element(name, content = nil) + @root << create_element(name, content).tap do |element| + yield element if block_given? + end + end + + def to_element + @root + end +end diff --git a/app/lib/rss/item.rb b/app/lib/rss/item.rb new file mode 100644 index 0000000000..c02991ace2 --- /dev/null +++ b/app/lib/rss/item.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class RSS::Item < RSS::Element + def initialize + super() + + @root = create_element('item') + end + + def title(str) + append_element('title', str) + end + + def link(str) + append_element('guid', str) do |guid| + guid['isPermaLink'] = 'true' + end + + append_element('link', str) + end + + def pub_date(date) + append_element('pubDate', date.to_formatted_s(:rfc822)) + end + + def description(str) + append_element('description', str) + end + + def category(str) + append_element('category', str) + end + + def enclosure(url, type, size) + append_element('enclosure') do |enclosure| + enclosure['url'] = url + enclosure['length'] = size + enclosure['type'] = type + end + end + + def media_content(url, type, size, &block) + @root << RSS::MediaContent.with(url, type, size, &block) + end +end diff --git a/app/lib/rss/media_content.rb b/app/lib/rss/media_content.rb new file mode 100644 index 0000000000..7aefd8b40b --- /dev/null +++ b/app/lib/rss/media_content.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class RSS::MediaContent < RSS::Element + def initialize(url, type, size) + super() + + @root = create_element('media:content') do |content| + content['url'] = url + content['type'] = type + content['fileSize'] = size + end + end + + def medium(str) + @root['medium'] = str + end + + def rating(str) + append_element('media:rating', str) do |rating| + rating['scheme'] = 'urn:simple' + end + end + + def description(str) + append_element('media:description', str) do |description| + description['type'] = 'plain' + end + end +end diff --git a/app/lib/rss/serializer.rb b/app/lib/rss/serializer.rb deleted file mode 100644 index d44e94221b..0000000000 --- a/app/lib/rss/serializer.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -class RSS::Serializer - include FormattingHelper - - private - - def render_statuses(builder, statuses) - statuses.each do |status| - builder.item do |item| - item.title(status_title(status)) - .link(ActivityPub::TagManager.instance.url_for(status)) - .pub_date(status.created_at) - .description(status_description(status)) - - status.ordered_media_attachments.each do |media| - item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) - end - end - end - end - - def status_title(status) - preview = status.proper.spoiler_text.presence || status.proper.text - - if preview.length > 30 || preview[0, 30].include?("\n") - preview = preview[0, 30] - preview = preview[0, preview.index("\n").presence || 30] + '…' - end - - preview = "#{status.proper.spoiler_text.present? ? 'CW ' : ''}“#{preview}”#{status.proper.sensitive? ? ' (sensitive)' : ''}" - - if status.reblog? - "#{status.account.acct} boosted #{status.reblog.account.acct}: #{preview}" - else - "#{status.account.acct}: #{preview}" - end - end - - def status_description(status) - if status.proper.spoiler_text? - status.proper.spoiler_text - else - html = status_content_format(status.proper).to_str - after_html = '' - - if status.proper.preloadable_poll - poll_options_html = status.proper.preloadable_poll.options.map { |o| "[ ] #{o}" }.join('
') - after_html = "

#{poll_options_html}

" - end - - "#{html}#{after_html}" - end - end -end diff --git a/app/lib/rss_builder.rb b/app/lib/rss_builder.rb deleted file mode 100644 index 63ddba2e8e..0000000000 --- a/app/lib/rss_builder.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -class RSSBuilder - class ItemBuilder - def initialize - @item = Ox::Element.new('item') - end - - def title(str) - @item << (Ox::Element.new('title') << str) - - self - end - - def link(str) - @item << Ox::Element.new('guid').tap do |guid| - guid['isPermalink'] = 'true' - guid << str - end - - @item << (Ox::Element.new('link') << str) - - self - end - - def pub_date(date) - @item << (Ox::Element.new('pubDate') << date.to_formatted_s(:rfc822)) - - self - end - - def description(str) - @item << (Ox::Element.new('description') << str) - - self - end - - def enclosure(url, type, size) - @item << Ox::Element.new('enclosure').tap do |enclosure| - enclosure['url'] = url - enclosure['length'] = size - enclosure['type'] = type - end - - self - end - - def to_element - @item - end - end - - def initialize - @document = Ox::Document.new(version: '1.0') - @channel = Ox::Element.new('channel') - - @document << (rss << @channel) - end - - def title(str) - @channel << (Ox::Element.new('title') << str) - - self - end - - def link(str) - @channel << (Ox::Element.new('link') << str) - - self - end - - def image(str) - @channel << Ox::Element.new('image').tap do |image| - image << (Ox::Element.new('url') << str) - image << (Ox::Element.new('title') << '') - image << (Ox::Element.new('link') << '') - end - - @channel << (Ox::Element.new('webfeeds:icon') << str) - - self - end - - def cover(str) - @channel << Ox::Element.new('webfeeds:cover').tap do |cover| - cover['image'] = str - end - - self - end - - def logo(str) - @channel << (Ox::Element.new('webfeeds:logo') << str) - - self - end - - def accent_color(str) - @channel << (Ox::Element.new('webfeeds:accentColor') << str) - - self - end - - def description(str) - @channel << (Ox::Element.new('description') << str) - - self - end - - def item - @channel << ItemBuilder.new.tap do |item| - yield item - end.to_element - - self - end - - def to_xml - ('' + Ox.dump(@document, effort: :tolerant)).force_encoding('UTF-8') - end - - private - - def rss - Ox::Element.new('rss').tap do |rss| - rss['version'] = '2.0' - rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0' - end - end -end diff --git a/app/serializers/rss/account_serializer.rb b/app/serializers/rss/account_serializer.rb deleted file mode 100644 index 81e24af0d0..0000000000 --- a/app/serializers/rss/account_serializer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class RSS::AccountSerializer < RSS::Serializer - include ActionView::Helpers::NumberHelper - include AccountsHelper - include RoutingHelper - - def render(account, statuses, tag) - builder = RSSBuilder.new - - builder.title("#{display_name(account)} (@#{account.local_username_and_domain})") - .description(account_description(account)) - .link(tag.present? ? short_account_tag_url(account, tag) : short_account_url(account)) - .logo(full_pack_url('media/images/logo.svg')) - .accent_color('2b90d9') - - builder.image(full_asset_url(account.avatar.url(:original))) if account.avatar? - builder.cover(full_asset_url(account.header.url(:original))) if account.header? - - render_statuses(builder, statuses) - - builder.to_xml - end - - def self.render(account, statuses, tag) - new.render(account, statuses, tag) - end -end diff --git a/app/serializers/rss/tag_serializer.rb b/app/serializers/rss/tag_serializer.rb deleted file mode 100644 index e549ac3675..0000000000 --- a/app/serializers/rss/tag_serializer.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class RSS::TagSerializer < RSS::Serializer - include ActionView::Helpers::NumberHelper - include ActionView::Helpers::SanitizeHelper - include RoutingHelper - - def render(tag, statuses) - builder = RSSBuilder.new - - builder.title("##{tag.name}") - .description(strip_tags(I18n.t('about.about_hashtag_html', hashtag: tag.name))) - .link(tag_url(tag)) - .logo(full_pack_url('media/images/logo.svg')) - .accent_color('2b90d9') - - render_statuses(builder, statuses) - - builder.to_xml - end - - def self.render(tag, statuses) - new.render(tag, statuses) - end -end diff --git a/app/views/accounts/show.rss.ruby b/app/views/accounts/show.rss.ruby new file mode 100644 index 0000000000..73c1c51e07 --- /dev/null +++ b/app/views/accounts/show.rss.ruby @@ -0,0 +1,37 @@ +RSS::Builder.build do |doc| + doc.title(display_name(@account)) + doc.description(I18n.t('rss.descriptions.account', acct: @account.local_username_and_domain)) + doc.link(params[:tag].present? ? short_account_tag_url(@account, params[:tag]) : short_account_url(@account)) + doc.image(full_asset_url(@account.avatar.url(:original)), display_name(@account), params[:tag].present? ? short_account_tag_url(@account, params[:tag]) : short_account_url(@account)) + doc.last_build_date(@statuses.first.created_at) if @statuses.any? + doc.icon(full_asset_url(@account.avatar.url(:original))) + doc.logo(full_pack_url('media/images/logo_transparent_white.svg')) + doc.generator("Mastodon v#{Mastodon::Version.to_s}") + + @statuses.each do |status| + doc.item do |item| + item.title(l(status.created_at)) + item.link(ActivityPub::TagManager.instance.url_for(status)) + item.pub_date(status.created_at) + item.description(rss_status_content_format(status)) + + if status.ordered_media_attachments.first&.audio? + media = status.ordered_media_attachments.first + item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) + end + + status.ordered_media_attachments.each do |media| + item.media_content(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) do |media_content| + media_content.medium(media.gifv? ? 'image' : media.type.to_s) + media_content.rating(status.sensitive? ? 'adult' : 'nonadult') + media_content.description(media.description) if media.description.present? + media_content.thumbnail(media.thumbnail.url(:original, false)) if media.thumbnail? + end + end + + status.tags.each do |tag| + item.category(tag.name) + end + end + end +end diff --git a/app/views/tags/show.rss.ruby b/app/views/tags/show.rss.ruby new file mode 100644 index 0000000000..4152ecd24b --- /dev/null +++ b/app/views/tags/show.rss.ruby @@ -0,0 +1,36 @@ +RSS::Builder.build do |doc| + doc.title("##{@tag.name}") + doc.description(I18n.t('rss.descriptions.tag', hashtag: @tag.name)) + doc.link(tag_url(@tag)) + doc.last_build_date(@statuses.first.created_at) if @statuses.any? + doc.icon(full_asset_url(@account.avatar.url(:original))) + doc.logo(full_pack_url('media/images/logo_transparent_white.svg')) + doc.generator("Mastodon v#{Mastodon::Version.to_s}") + + @statuses.each do |status| + doc.item do |item| + item.title(l(status.created_at)) + item.link(ActivityPub::TagManager.instance.url_for(status)) + item.pub_date(status.created_at) + item.description(rss_status_content_format(status)) + + if status.ordered_media_attachments.first&.audio? + media = status.ordered_media_attachments.first + item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) + end + + status.ordered_media_attachments.each do |media| + item.media_content(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) do |media_content| + media_content.medium(media.gifv? ? 'image' : media.type.to_s) + media_content.rating(status.sensitive? ? 'adult' : 'nonadult') + media_content.description(media.description) if media.description.present? + media_content.thumbnail(media.thumbnail.url(:original, false)) if media.thumbnail? + end + end + + status.tags.each do |tag| + item.category(tag.name) + end + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2c8455d0ae..50e762db72 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1357,6 +1357,11 @@ en: reports: errors: invalid_rules: does not reference valid rules + rss: + content_warning: 'Content warning:' + descriptions: + account: Public posts from @%{acct} + tag: 'Public posts tagged #%{hashtag}' scheduled_statuses: over_daily_limit: You have exceeded the limit of %{limit} scheduled posts for today over_total_limit: You have exceeded the limit of %{limit} scheduled posts diff --git a/spec/lib/emoji_formatter_spec.rb b/spec/lib/emoji_formatter_spec.rb index 129445aa59..e1747bdd9d 100644 --- a/spec/lib/emoji_formatter_spec.rb +++ b/spec/lib/emoji_formatter_spec.rb @@ -24,7 +24,7 @@ RSpec.describe EmojiFormatter do let(:text) { preformat_text(':coolcat: Beep boop') } it 'converts the shortcode to an image tag' do - is_expected.to match(/