Merge branch 'main' into glitch-soc/merge-upstream

Conflicts:
- `app/lib/formatter.rb`:
  Upstream completely refactored the formatting code and removed that file,
  while glitch-soc had code for Markdown and HTML toots.
  Took upstream code, glitch-soc changes will be re-implemented on top of the
  refactored classes in a later commit.
- `app/models/status.rb`:
  Upstream refactored status edit handling and moved code to
  `app/models/concerns/status_snapshot_concern.rb`.
  Applied glitch-soc's changes to that file.
- `app/serializers/activitypub/note_serializer.rb`:
  Not really a conflict, just a line added too close to one modified by
  glitch-soc.
  Applied upstream changes while keeping the glitch-soc-modified one.
- `app/services/update_status_service.rb`:
  Not really a conflict, upstream modified a line adjacent to one added by
  glitch-soc.
  Applied upstream changes while keeping the glitch-soc line.
- `app/views/statuses/_simple_status.html.haml`:
  Upstream refactored formatting, glitch-soc changed the markup slightly.
  Applied upstream changes.
- `spec/lib/formatter_spec.rb`:
  Upstream completely refactored the formatting code and removed that file,
  while glitch-soc had code for Markdown and HTML toots.
  Took upstream code, glitch-soc changes will be re-implemented on top of the
  refactored classes in a later commit.
This commit is contained in:
Claire
2022-03-26 19:18:55 +01:00
103 changed files with 1586 additions and 1458 deletions

View File

@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ActivityPub::Activity::Create < ActivityPub::Activity
include FormattingHelper
def perform
dereference_object!
@@ -367,7 +369,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def converted_text
Formatter.instance.linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n"))
linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n"))
end
def unsupported_media_type?(mime_type)

View File

@@ -27,7 +27,9 @@ class ActivityPub::Parser::MediaAttachmentParser
end
def description
@json['summary'].presence || @json['name'].presence
str = @json['summary'].presence || @json['name'].presence
str = str.strip[0...MediaAttachment::MAX_DESCRIPTION_LENGTH] if str.present?
str
end
def focus

View File

@@ -5,6 +5,7 @@ class Admin::SystemCheck
Admin::SystemCheck::DatabaseSchemaCheck,
Admin::SystemCheck::SidekiqProcessCheck,
Admin::SystemCheck::RulesCheck,
Admin::SystemCheck::ElasticsearchCheck,
].freeze
def self.perform

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
def pass?
return true unless Chewy.enabled?
running_version.present? && compatible_version?
end
def message
if running_version.present?
Admin::SystemCheck::Message.new(:elasticsearch_version_check, I18n.t('admin.system_checks.elasticsearch_version_check.version_comparison', running_version: running_version, required_version: required_version))
else
Admin::SystemCheck::Message.new(:elasticsearch_running_check)
end
end
private
def running_version
@running_version ||= begin
Chewy.client.info['version']['number']
rescue Faraday::ConnectionFailed
nil
end
end
def required_version
'7.x'
end
def compatible_version?
Gem::Version.new(running_version) >= Gem::Version.new(required_version)
end
def missing_queues
@missing_queues ||= Sidekiq::ProcessSet.new.reduce(SIDEKIQ_QUEUES) { |queues, process| queues - process['queues'] }
end
end

View File

@@ -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

View File

@@ -5,18 +5,34 @@ module Extractor
module_function
# :yields: username, list_slug, start, end
def extract_entities_with_indices(text, options = {}, &block)
entities = begin
extract_urls_with_indices(text, options) +
extract_hashtags_with_indices(text, check_url_overlap: false) +
extract_mentions_or_lists_with_indices(text) +
extract_extra_uris_with_indices(text)
end
return [] if entities.empty?
entities = remove_overlapping_entities(entities)
entities.each(&block) if block_given?
entities
end
def extract_mentions_or_lists_with_indices(text)
return [] unless Twitter::TwitterText::Regex[:at_signs].match?(text)
return [] unless text && Twitter::TwitterText::Regex[:at_signs].match?(text)
possible_entries = []
text.to_s.scan(Account::MENTION_RE) do |screen_name, _|
text.scan(Account::MENTION_RE) do |screen_name, _|
match_data = $LAST_MATCH_INFO
after = $'
after = $'
unless Twitter::TwitterText::Regex[:end_mention_match].match?(after)
start_position = match_data.char_begin(1) - 1
end_position = match_data.char_end(1)
end_position = match_data.char_end(1)
possible_entries << {
screen_name: screen_name,
indices: [start_position, end_position],
@@ -29,36 +45,70 @@ module Extractor
yield mention[:screen_name], mention[:indices].first, mention[:indices].last
end
end
possible_entries
end
def extract_hashtags_with_indices(text, **)
return [] unless /#/.match?(text)
def extract_hashtags_with_indices(text, _options = {})
return [] unless text&.index('#')
possible_entries = []
tags = []
text.scan(Tag::HASHTAG_RE) do |hash_text, _|
match_data = $LAST_MATCH_INFO
match_data = $LAST_MATCH_INFO
start_position = match_data.char_begin(1) - 1
end_position = match_data.char_end(1)
after = $'
end_position = match_data.char_end(1)
after = $'
if %r{\A://}.match?(after)
hash_text.match(/(.+)(https?\Z)/) do |matched|
hash_text = matched[1]
hash_text = matched[1]
end_position -= matched[2].codepoint_length
end
end
tags << {
possible_entries << {
hashtag: hash_text,
indices: [start_position, end_position],
}
end
tags.each { |tag| yield tag[:hashtag], tag[:indices].first, tag[:indices].last } if block_given?
tags
if block_given?
possible_entries.each do |tag|
yield tag[:hashtag], tag[:indices].first, tag[:indices].last
end
end
possible_entries
end
def extract_cashtags_with_indices(_text)
[] # always returns empty array
[]
end
def extract_extra_uris_with_indices(text)
return [] unless text&.index(':')
possible_entries = []
text.scan(Twitter::TwitterText::Regex[:valid_extended_uri]) do
valid_uri_match_data = $LAST_MATCH_INFO
start_position = valid_uri_match_data.char_begin(3)
end_position = valid_uri_match_data.char_end(3)
possible_entries << {
url: valid_uri_match_data[3],
indices: [start_position, end_position],
}
end
if block_given?
possible_entries.each do |url|
yield url[:url], url[:indices].first, url[:indices].last
end
end
possible_entries
end
end

View File

@@ -5,6 +5,7 @@ require 'singleton'
class FeedManager
include Singleton
include Redisable
include FormattingHelper
# Maximum number of items stored in a single feed
MAX_ITEMS = 400
@@ -503,7 +504,7 @@ class FeedManager
status = status.reblog if status.reblog?
combined_text = [
Formatter.instance.plaintext(status),
extract_plain_text(status.text, status.local?),
status.spoiler_text,
status.preloadable_poll ? status.preloadable_poll.options.join("\n\n") : nil,
status.ordered_media_attachments.map(&:description).join("\n\n"),

View File

@@ -1,382 +0,0 @@
# frozen_string_literal: true
require 'singleton'
class HTMLRenderer < Redcarpet::Render::HTML
def block_code(code, language)
"<pre><code>#{encode(code).gsub("\n", "<br/>")}</code></pre>"
end
def autolink(link, link_type)
return link if link_type == :email
Formatter.instance.link_url(link)
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
encode(link)
end
private
def html_entities
@html_entities ||= HTMLEntities.new
end
def encode(html)
html_entities.encode(html)
end
end
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 = format_markdown(html) if status.content_type == 'text/markdown'
html = encode_and_link_urls(html, linkable_accounts, keep_html: %w(text/markdown text/html).include?(status.content_type))
html = reformat(html, true) if %w(text/markdown text/html).include?(status.content_type)
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
unless %w(text/markdown text/html).include?(status.content_type)
html = simple_format(html, {}, sanitize: false)
html = html.delete("\n")
end
html.html_safe # rubocop:disable Rails/OutputSafety
end
def format_markdown(html)
html = markdown_formatter.render(html)
html.delete("\r").delete("\n")
end
def reformat(html, outgoing = false)
sanitize(html, Sanitize::Config::MASTODON_STRICT.merge(outgoing: outgoing))
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
def link_url(url)
"<a href=\"#{encode(url)}\" target=\"blank\" rel=\"nofollow noopener noreferrer\">#{link_html(url)}</a>"
end
private
def markdown_formatter
extensions = {
autolink: true,
no_intra_emphasis: true,
fenced_code_blocks: true,
disable_indented_code_blocks: true,
strikethrough: true,
lax_spacing: true,
space_after_headers: true,
superscript: true,
underline: true,
highlight: true,
footnotes: false,
}
renderer = HTMLRenderer.new({
filter_html: false,
escape_html: false,
no_images: true,
no_styles: true,
safe_links_only: true,
hard_wrap: true,
link_attributes: { target: '_blank', rel: 'nofollow noopener' },
})
Redcarpet::Markdown.new(renderer, extensions)
end
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 = {})
if accounts.is_a?(Hash)
options = accounts
accounts = nil
end
entities = options[:keep_html] ? html_friendly_extractor(html) : utf8_friendly_extractor(html, extract_url_without_protocol: false)
rewrite(html.dup, entities, options[:keep_html]) 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, keep_html = false)
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 << (keep_html ? text[index...indices.first] : encode(text[index...indices.first]))
result << yield(entity)
indices.last
end
result << (keep_html ? text[last_index..-1] : 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 html_friendly_extractor(html, options = {})
gaps = []
total_offset = 0
escaped = html.gsub(/<[^>]*>|&#[0-9]+;/) do |match|
total_offset += match.length - 1
end_offset = Regexp.last_match.end(0)
gaps << [end_offset - total_offset, total_offset]
"\u200b"
end
entities = Extractor.extract_hashtags_with_indices(escaped, :check_url_overlap => false) +
Extractor.extract_mentions_or_lists_with_indices(escaped)
Extractor.remove_overlapping_entities(entities).map do |extract|
pos = extract[:indices].first
offset_idx = gaps.rindex { |gap| gap.first <= pos }
offset = offset_idx.nil? ? 0 : gaps[offset_idx].last
next extract.merge(
:indices => [extract[:indices].first + offset, extract[:indices].last + offset]
)
end
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

View File

@@ -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

View File

@@ -208,7 +208,7 @@ class LinkDetailsExtractor
end
def valid_url_or_nil(str, same_origin_only: false)
return if str.blank?
return if str.blank? || str == 'null'
url = @original_url + Addressable::URI.parse(str)

View File

@@ -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

View File

@@ -1,6 +1,8 @@
# frozen_string_literal: true
class RSS::Serializer
include FormattingHelper
private
def render_statuses(builder, statuses)
@@ -9,7 +11,7 @@ class RSS::Serializer
item.title(status_title(status))
.link(ActivityPub::TagManager.instance.url_for(status))
.pub_date(status.created_at)
.description(status.spoiler_text.presence || Formatter.instance.format(status, inline_poll_options: true).to_str)
.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)
@@ -19,9 +21,8 @@ class RSS::Serializer
end
def status_title(status)
return "#{status.account.acct} deleted status" if status.destroyed?
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] + '…'
@@ -35,4 +36,20 @@ class RSS::Serializer
"#{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('<br />')
after_html = "<p>#{poll_options_html}</p>"
end
"#{html}#{after_html}"
end
end
end

158
app/lib/text_formatter.rb Normal file
View File

@@ -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