mirror of https://github.com/mastodon/mastodon
Refactor formatter (#17828)
* Refactor formatter * Move custom emoji pre-rendering logic to view helpers * Move more methods out of Formatter * Fix code style issues * Remove Formatter * Add inline poll options to RSS feeds * Remove unused helper method * Fix code style issues * Various fixes and improvements * Fix testpull/17875/head
parent
2dd30804b6
commit
cefa526c6d
@ -0,0 +1,19 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module FormattingHelper
|
||||||
|
def html_aware_format(text, local, options = {})
|
||||||
|
HtmlAwareFormatter.new(text, local, options).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def linkify(text, options = {})
|
||||||
|
TextFormatter.new(text, options).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_plain_text(text, local)
|
||||||
|
PlainTextFormatter.new(text, local).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def status_content_format(status)
|
||||||
|
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,98 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class EmojiFormatter
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
DISALLOWED_BOUNDING_REGEX = /[[:alnum:]:]/.freeze
|
||||||
|
|
||||||
|
attr_reader :html, :custom_emojis, :options
|
||||||
|
|
||||||
|
# @param [ActiveSupport::SafeBuffer] html
|
||||||
|
# @param [Array<CustomEmoji>] custom_emojis
|
||||||
|
# @param [Hash] options
|
||||||
|
# @option options [Boolean] :animate
|
||||||
|
def initialize(html, custom_emojis, options = {})
|
||||||
|
raise ArgumentError unless html.html_safe?
|
||||||
|
|
||||||
|
@html = html
|
||||||
|
@custom_emojis = custom_emojis
|
||||||
|
@options = options
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
return html if custom_emojis.empty? || html.blank?
|
||||||
|
|
||||||
|
i = -1
|
||||||
|
tag_open_index = nil
|
||||||
|
inside_shortname = false
|
||||||
|
shortname_start_index = -1
|
||||||
|
invisible_depth = 0
|
||||||
|
last_index = 0
|
||||||
|
result = ''.dup
|
||||||
|
|
||||||
|
while i + 1 < html.size
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if invisible_depth.zero? && inside_shortname && html[i] == ':'
|
||||||
|
inside_shortname = false
|
||||||
|
shortcode = html[shortname_start_index + 1..i - 1]
|
||||||
|
char_after = html[i + 1]
|
||||||
|
|
||||||
|
next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode])
|
||||||
|
|
||||||
|
result << html[last_index..shortname_start_index - 1] if shortname_start_index.positive?
|
||||||
|
result << image_for_emoji(shortcode, emoji)
|
||||||
|
last_index = i + 1
|
||||||
|
elsif tag_open_index && html[i] == '>'
|
||||||
|
tag = html[tag_open_index..i]
|
||||||
|
tag_open_index = nil
|
||||||
|
|
||||||
|
if invisible_depth.positive?
|
||||||
|
invisible_depth += count_tag_nesting(tag)
|
||||||
|
elsif tag == '<span class="invisible">'
|
||||||
|
invisible_depth = 1
|
||||||
|
end
|
||||||
|
elsif html[i] == '<'
|
||||||
|
tag_open_index = i
|
||||||
|
inside_shortname = false
|
||||||
|
elsif !tag_open_index && html[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(html[i - 1]))
|
||||||
|
inside_shortname = true
|
||||||
|
shortname_start_index = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
result << html[last_index..-1]
|
||||||
|
|
||||||
|
result.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def emoji_map
|
||||||
|
@emoji_map ||= custom_emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_tag_nesting(tag)
|
||||||
|
if tag[1] == '/'
|
||||||
|
-1
|
||||||
|
elsif tag[-2] == '/'
|
||||||
|
0
|
||||||
|
else
|
||||||
|
1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
def animate?
|
||||||
|
@options[:animate]
|
||||||
|
end
|
||||||
|
end
|
@ -1,294 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'singleton'
|
|
||||||
|
|
||||||
class Formatter
|
|
||||||
include Singleton
|
|
||||||
include RoutingHelper
|
|
||||||
|
|
||||||
include ActionView::Helpers::TextHelper
|
|
||||||
|
|
||||||
def format(status, **options)
|
|
||||||
if status.respond_to?(:reblog?) && status.reblog?
|
|
||||||
prepend_reblog = status.reblog.account.acct
|
|
||||||
status = status.proper
|
|
||||||
else
|
|
||||||
prepend_reblog = false
|
|
||||||
end
|
|
||||||
|
|
||||||
raw_content = status.text
|
|
||||||
|
|
||||||
if options[:inline_poll_options] && status.preloadable_poll
|
|
||||||
raw_content = raw_content + "\n\n" + status.preloadable_poll.options.map { |title| "[ ] #{title}" }.join("\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
return '' if raw_content.blank?
|
|
||||||
|
|
||||||
unless status.local?
|
|
||||||
html = reformat(raw_content)
|
|
||||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
|
||||||
return html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
linkable_accounts = status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []
|
|
||||||
linkable_accounts << status.account
|
|
||||||
|
|
||||||
html = raw_content
|
|
||||||
html = "RT @#{prepend_reblog} #{html}" if prepend_reblog
|
|
||||||
html = encode_and_link_urls(html, linkable_accounts)
|
|
||||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
|
||||||
html = simple_format(html, {}, sanitize: false)
|
|
||||||
html = html.delete("\n")
|
|
||||||
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
def reformat(html)
|
|
||||||
sanitize(html, Sanitize::Config::MASTODON_STRICT)
|
|
||||||
rescue ArgumentError
|
|
||||||
''
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaintext(status)
|
|
||||||
return status.text if status.local?
|
|
||||||
|
|
||||||
text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
|
|
||||||
strip_tags(text)
|
|
||||||
end
|
|
||||||
|
|
||||||
def simplified_format(account, **options)
|
|
||||||
return '' if account.note.blank?
|
|
||||||
|
|
||||||
html = account.local? ? linkify(account.note) : reformat(account.note)
|
|
||||||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
def sanitize(html, config)
|
|
||||||
Sanitize.fragment(html, config)
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_spoiler(status, **options)
|
|
||||||
html = encode(status.spoiler_text)
|
|
||||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay])
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_poll_option(status, option, **options)
|
|
||||||
html = encode(option.title)
|
|
||||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay])
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_display_name(account, **options)
|
|
||||||
html = encode(account.display_name.presence || account.username)
|
|
||||||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
def format_field(account, str, **options)
|
|
||||||
html = account.local? ? encode_and_link_urls(str, me: true, with_domain: true) : reformat(str)
|
|
||||||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
def linkify(text)
|
|
||||||
html = encode_and_link_urls(text)
|
|
||||||
html = simple_format(html, {}, sanitize: false)
|
|
||||||
html = html.delete("\n")
|
|
||||||
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def html_entities
|
|
||||||
@html_entities ||= HTMLEntities.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def encode(html)
|
|
||||||
html_entities.encode(html)
|
|
||||||
end
|
|
||||||
|
|
||||||
def encode_and_link_urls(html, accounts = nil, options = {})
|
|
||||||
entities = utf8_friendly_extractor(html, extract_url_without_protocol: false)
|
|
||||||
|
|
||||||
if accounts.is_a?(Hash)
|
|
||||||
options = accounts
|
|
||||||
accounts = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
rewrite(html.dup, entities) do |entity|
|
|
||||||
if entity[:url]
|
|
||||||
link_to_url(entity, options)
|
|
||||||
elsif entity[:hashtag]
|
|
||||||
link_to_hashtag(entity)
|
|
||||||
elsif entity[:screen_name]
|
|
||||||
link_to_mention(entity, accounts, options)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def count_tag_nesting(tag)
|
|
||||||
if tag[1] == '/' then -1
|
|
||||||
elsif tag[-2] == '/' then 0
|
|
||||||
else 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# rubocop:disable Metrics/BlockNesting
|
|
||||||
def encode_custom_emojis(html, emojis, animate = false)
|
|
||||||
return html if emojis.empty?
|
|
||||||
|
|
||||||
emoji_map = emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
|
|
||||||
|
|
||||||
i = -1
|
|
||||||
tag_open_index = nil
|
|
||||||
inside_shortname = false
|
|
||||||
shortname_start_index = -1
|
|
||||||
invisible_depth = 0
|
|
||||||
|
|
||||||
while i + 1 < html.size
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if invisible_depth.zero? && inside_shortname && html[i] == ':'
|
|
||||||
shortcode = html[shortname_start_index + 1..i - 1]
|
|
||||||
emoji = emoji_map[shortcode]
|
|
||||||
|
|
||||||
if emoji
|
|
||||||
original_url, static_url = emoji
|
|
||||||
replacement = begin
|
|
||||||
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
|
|
||||||
end
|
|
||||||
before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
|
|
||||||
html = before_html + replacement + html[i + 1..-1]
|
|
||||||
i += replacement.size - (shortcode.size + 2) - 1
|
|
||||||
else
|
|
||||||
i -= 1
|
|
||||||
end
|
|
||||||
|
|
||||||
inside_shortname = false
|
|
||||||
elsif tag_open_index && html[i] == '>'
|
|
||||||
tag = html[tag_open_index..i]
|
|
||||||
tag_open_index = nil
|
|
||||||
if invisible_depth.positive?
|
|
||||||
invisible_depth += count_tag_nesting(tag)
|
|
||||||
elsif tag == '<span class="invisible">'
|
|
||||||
invisible_depth = 1
|
|
||||||
end
|
|
||||||
elsif html[i] == '<'
|
|
||||||
tag_open_index = i
|
|
||||||
inside_shortname = false
|
|
||||||
elsif !tag_open_index && html[i] == ':'
|
|
||||||
inside_shortname = true
|
|
||||||
shortname_start_index = i
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
html
|
|
||||||
end
|
|
||||||
# rubocop:enable Metrics/BlockNesting
|
|
||||||
|
|
||||||
def rewrite(text, entities)
|
|
||||||
text = text.to_s
|
|
||||||
|
|
||||||
# Sort by start index
|
|
||||||
entities = entities.sort_by do |entity|
|
|
||||||
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
|
|
||||||
indices.first
|
|
||||||
end
|
|
||||||
|
|
||||||
result = []
|
|
||||||
|
|
||||||
last_index = entities.reduce(0) do |index, entity|
|
|
||||||
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
|
|
||||||
result << encode(text[index...indices.first])
|
|
||||||
result << yield(entity)
|
|
||||||
indices.last
|
|
||||||
end
|
|
||||||
|
|
||||||
result << encode(text[last_index..-1])
|
|
||||||
|
|
||||||
result.flatten.join
|
|
||||||
end
|
|
||||||
|
|
||||||
def utf8_friendly_extractor(text, options = {})
|
|
||||||
# Note: I couldn't obtain list_slug with @user/list-name format
|
|
||||||
# for mention so this requires additional check
|
|
||||||
special = Extractor.extract_urls_with_indices(text, options)
|
|
||||||
standard = Extractor.extract_entities_with_indices(text, options)
|
|
||||||
extra = Extractor.extract_extra_uris_with_indices(text, options)
|
|
||||||
|
|
||||||
Extractor.remove_overlapping_entities(special + standard + extra)
|
|
||||||
end
|
|
||||||
|
|
||||||
def link_to_url(entity, options = {})
|
|
||||||
url = Addressable::URI.parse(entity[:url])
|
|
||||||
html_attrs = { target: '_blank', rel: 'nofollow noopener noreferrer' }
|
|
||||||
|
|
||||||
html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
|
|
||||||
|
|
||||||
Twitter::TwitterText::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs)
|
|
||||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
|
||||||
encode(entity[:url])
|
|
||||||
end
|
|
||||||
|
|
||||||
def link_to_mention(entity, linkable_accounts, options = {})
|
|
||||||
acct = entity[:screen_name]
|
|
||||||
|
|
||||||
return link_to_account(acct, options) unless linkable_accounts
|
|
||||||
|
|
||||||
same_username_hits = 0
|
|
||||||
account = nil
|
|
||||||
username, domain = acct.split('@')
|
|
||||||
domain = nil if TagManager.instance.local_domain?(domain)
|
|
||||||
|
|
||||||
linkable_accounts.each do |item|
|
|
||||||
same_username = item.username.casecmp(username).zero?
|
|
||||||
same_domain = item.domain.nil? ? domain.nil? : item.domain.casecmp(domain)&.zero?
|
|
||||||
|
|
||||||
if same_username && !same_domain
|
|
||||||
same_username_hits += 1
|
|
||||||
elsif same_username && same_domain
|
|
||||||
account = item
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
account ? mention_html(account, with_domain: same_username_hits.positive? || options[:with_domain]) : "@#{encode(acct)}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def link_to_account(acct, options = {})
|
|
||||||
username, domain = acct.split('@')
|
|
||||||
|
|
||||||
domain = nil if TagManager.instance.local_domain?(domain)
|
|
||||||
account = EntityCache.instance.mention(username, domain)
|
|
||||||
|
|
||||||
account ? mention_html(account, with_domain: options[:with_domain]) : "@#{encode(acct)}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def link_to_hashtag(entity)
|
|
||||||
hashtag_html(entity[:hashtag])
|
|
||||||
end
|
|
||||||
|
|
||||||
def link_html(url)
|
|
||||||
url = Addressable::URI.parse(url).to_s
|
|
||||||
prefix = url.match(/\A(https?:\/\/(www\.)?|xmpp:)/).to_s
|
|
||||||
text = url[prefix.length, 30]
|
|
||||||
suffix = url[prefix.length + 30..-1]
|
|
||||||
cutoff = url[prefix.length..-1].length > 30
|
|
||||||
|
|
||||||
"<span class=\"invisible\">#{encode(prefix)}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{encode(text)}</span><span class=\"invisible\">#{encode(suffix)}</span>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def hashtag_html(tag)
|
|
||||||
"<a href=\"#{encode(tag_url(tag))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def mention_html(account, with_domain: false)
|
|
||||||
"<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(with_domain ? account.pretty_acct : account.username)}</span></a></span>"
|
|
||||||
end
|
|
||||||
end
|
|
@ -0,0 +1,38 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class HtmlAwareFormatter
|
||||||
|
attr_reader :text, :local, :options
|
||||||
|
|
||||||
|
alias local? local
|
||||||
|
|
||||||
|
# @param [String] text
|
||||||
|
# @param [Boolean] local
|
||||||
|
# @param [Hash] options
|
||||||
|
def initialize(text, local, options = {})
|
||||||
|
@text = text
|
||||||
|
@local = local
|
||||||
|
@options = options
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
return ''.html_safe if text.blank?
|
||||||
|
|
||||||
|
if local?
|
||||||
|
linkify
|
||||||
|
else
|
||||||
|
reformat.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
|
end
|
||||||
|
rescue ArgumentError
|
||||||
|
''.html_safe
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def reformat
|
||||||
|
Sanitize.fragment(text, Sanitize::Config::MASTODON_STRICT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def linkify
|
||||||
|
TextFormatter.new(text, options).to_s
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PlainTextFormatter
|
||||||
|
include ActionView::Helpers::TextHelper
|
||||||
|
|
||||||
|
NEWLINE_TAGS_RE = /(<br \/>|<br>|<\/p>)+/.freeze
|
||||||
|
|
||||||
|
attr_reader :text, :local
|
||||||
|
|
||||||
|
alias local? local
|
||||||
|
|
||||||
|
def initialize(text, local)
|
||||||
|
@text = text
|
||||||
|
@local = local
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
if local?
|
||||||
|
text
|
||||||
|
else
|
||||||
|
strip_tags(insert_newlines).chomp
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def insert_newlines
|
||||||
|
text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" }
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,158 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TextFormatter
|
||||||
|
include ActionView::Helpers::TextHelper
|
||||||
|
include ERB::Util
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
URL_PREFIX_REGEX = /\A(https?:\/\/(www\.)?|xmpp:)/.freeze
|
||||||
|
|
||||||
|
DEFAULT_REL = %w(nofollow noopener noreferrer).freeze
|
||||||
|
|
||||||
|
DEFAULT_OPTIONS = {
|
||||||
|
multiline: true,
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
attr_reader :text, :options
|
||||||
|
|
||||||
|
# @param [String] text
|
||||||
|
# @param [Hash] options
|
||||||
|
# @option options [Boolean] :multiline
|
||||||
|
# @option options [Boolean] :with_domains
|
||||||
|
# @option options [Boolean] :with_rel_me
|
||||||
|
# @option options [Array<Account>] :preloaded_accounts
|
||||||
|
def initialize(text, options = {})
|
||||||
|
@text = text
|
||||||
|
@options = DEFAULT_OPTIONS.merge(options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def entities
|
||||||
|
@entities ||= Extractor.extract_entities_with_indices(text, extract_url_without_protocol: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
return ''.html_safe if text.blank?
|
||||||
|
|
||||||
|
html = rewrite do |entity|
|
||||||
|
if entity[:url]
|
||||||
|
link_to_url(entity)
|
||||||
|
elsif entity[:hashtag]
|
||||||
|
link_to_hashtag(entity)
|
||||||
|
elsif entity[:screen_name]
|
||||||
|
link_to_mention(entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
html = simple_format(html, {}, sanitize: false).delete("\n") if multiline?
|
||||||
|
|
||||||
|
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def rewrite
|
||||||
|
entities.sort_by! do |entity|
|
||||||
|
entity[:indices].first
|
||||||
|
end
|
||||||
|
|
||||||
|
result = ''.dup
|
||||||
|
|
||||||
|
last_index = entities.reduce(0) do |index, entity|
|
||||||
|
indices = entity[:indices]
|
||||||
|
result << h(text[index...indices.first])
|
||||||
|
result << yield(entity)
|
||||||
|
indices.last
|
||||||
|
end
|
||||||
|
|
||||||
|
result << h(text[last_index..-1])
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_to_url(entity)
|
||||||
|
url = Addressable::URI.parse(entity[:url]).to_s
|
||||||
|
rel = with_rel_me? ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
|
||||||
|
|
||||||
|
prefix = url.match(URL_PREFIX_REGEX).to_s
|
||||||
|
display_url = url[prefix.length, 30]
|
||||||
|
suffix = url[prefix.length + 30..-1]
|
||||||
|
cutoff = url[prefix.length..-1].length > 30
|
||||||
|
|
||||||
|
<<~HTML.squish
|
||||||
|
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
|
||||||
|
HTML
|
||||||
|
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||||
|
h(entity[:url])
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_to_hashtag(entity)
|
||||||
|
hashtag = entity[:hashtag]
|
||||||
|
url = tag_url(hashtag)
|
||||||
|
|
||||||
|
<<~HTML.squish
|
||||||
|
<a href="#{h(url)}" class="mention hashtag" rel="tag">#<span>#{h(hashtag)}</span></a>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_to_mention(entity)
|
||||||
|
username, domain = entity[:screen_name].split('@')
|
||||||
|
domain = nil if local_domain?(domain)
|
||||||
|
account = nil
|
||||||
|
|
||||||
|
if preloaded_accounts?
|
||||||
|
same_username_hits = 0
|
||||||
|
|
||||||
|
preloaded_accounts.each do |other_account|
|
||||||
|
same_username = other_account.username.casecmp(username).zero?
|
||||||
|
same_domain = other_account.domain.nil? ? domain.nil? : other_account.domain.casecmp(domain)&.zero?
|
||||||
|
|
||||||
|
if same_username && !same_domain
|
||||||
|
same_username_hits += 1
|
||||||
|
elsif same_username && same_domain
|
||||||
|
account = other_account
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
account = entity_cache.mention(username, domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
return "@#{h(entity[:screen_name])}" if account.nil?
|
||||||
|
|
||||||
|
url = ActivityPub::TagManager.instance.url_for(account)
|
||||||
|
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
|
||||||
|
|
||||||
|
<<~HTML.squish
|
||||||
|
<span class="h-card"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
def entity_cache
|
||||||
|
@entity_cache ||= EntityCache.instance
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_manager
|
||||||
|
@tag_manager ||= TagManager.instance
|
||||||
|
end
|
||||||
|
|
||||||
|
delegate :local_domain?, to: :tag_manager
|
||||||
|
|
||||||
|
def multiline?
|
||||||
|
options[:multiline]
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_domains?
|
||||||
|
options[:with_domains]
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_rel_me?
|
||||||
|
options[:with_rel_me]
|
||||||
|
end
|
||||||
|
|
||||||
|
def preloaded_accounts
|
||||||
|
options[:preloaded_accounts]
|
||||||
|
end
|
||||||
|
|
||||||
|
def preloaded_accounts?
|
||||||
|
preloaded_accounts.present?
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,55 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe EmojiFormatter do
|
||||||
|
let!(:emoji) { Fabricate(:custom_emoji, shortcode: 'coolcat') }
|
||||||
|
|
||||||
|
def preformat_text(str)
|
||||||
|
TextFormatter.new(str).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#to_s' do
|
||||||
|
subject { described_class.new(text, emojis).to_s }
|
||||||
|
|
||||||
|
let(:emojis) { [emoji] }
|
||||||
|
|
||||||
|
context 'given text that is not marked as html-safe' do
|
||||||
|
let(:text) { 'Foo' }
|
||||||
|
|
||||||
|
it 'raises an argument error' do
|
||||||
|
expect { subject }.to raise_error ArgumentError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'given text with an emoji shortcode at the start' do
|
||||||
|
let(:text) { preformat_text(':coolcat: Beep boop') }
|
||||||
|
|
||||||
|
it 'converts the shortcode to an image tag' do
|
||||||
|
is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'given text with an emoji shortcode in the middle' do
|
||||||
|
let(:text) { preformat_text('Beep :coolcat: boop') }
|
||||||
|
|
||||||
|
it 'converts the shortcode to an image tag' do
|
||||||
|
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'given text with concatenated emoji shortcodes' do
|
||||||
|
let(:text) { preformat_text(':coolcat::coolcat:') }
|
||||||
|
|
||||||
|
it 'does not touch the shortcodes' do
|
||||||
|
is_expected.to match(/:coolcat::coolcat:/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'given text with an emoji shortcode at the end' do
|
||||||
|
let(:text) { preformat_text('Beep boop :coolcat:') }
|
||||||
|
|
||||||
|
it 'converts the shortcode to an image tag' do
|
||||||
|
is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,44 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe HtmlAwareFormatter do
|
||||||
|
describe '#to_s' do
|
||||||
|
subject { described_class.new(text, local).to_s }
|
||||||
|
|
||||||
|
context 'when local' do
|
||||||
|
let(:local) { true }
|
||||||
|
let(:text) { 'Foo bar' }
|
||||||
|
|
||||||
|
it 'returns formatted text' do
|
||||||
|
is_expected.to eq '<p>Foo bar</p>'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when remote' do
|
||||||
|
let(:local) { false }
|
||||||
|
|
||||||
|
context 'given plain text' do
|
||||||
|
let(:text) { 'Beep boop' }
|
||||||
|
|
||||||
|
it 'keeps the plain text' do
|
||||||
|
is_expected.to include 'Beep boop'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'given text containing script tags' do
|
||||||
|
let(:text) { '<script>alert("Hello")</script>' }
|
||||||
|
|
||||||
|
it 'strips the scripts' do
|
||||||
|
is_expected.to_not include '<script>alert("Hello")</script>'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'given text containing malicious classes' do
|
||||||
|
let(:text) { '<span class="mention status__content__spoiler-link">Show more</span>' }
|
||||||
|
|
||||||
|
it 'strips the malicious classes' do
|
||||||
|
is_expected.to_not include 'status__content__spoiler-link'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,24 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PlainTextFormatter do
|
||||||
|
describe '#to_s' do
|
||||||
|
subject { described_class.new(status.text, status.local?).to_s }
|
||||||
|
|
||||||
|
context 'given a post with local status' do
|
||||||
|
let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', uri: nil) }
|
||||||
|
|
||||||
|
it 'returns the raw text' do
|
||||||
|
is_expected.to eq '<p>a text by a nerd who uses an HTML tag in text</p>'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'given a post with remote status' do
|
||||||
|
let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') }
|
||||||
|
let(:status) { Fabricate(:status, account: remote_account, text: '<p>Hello</p><script>alert("Hello")</script>') }
|
||||||
|
|
||||||
|
it 'returns tag-stripped text' do
|
||||||
|
is_expected.to eq 'Hello'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue