about summary refs log tree commit diff
path: root/app/lib
diff options
context:
space:
mode:
Diffstat (limited to 'app/lib')
-rw-r--r--app/lib/activitypub/activity/create.rb10
-rw-r--r--app/lib/extractor.rb3
-rw-r--r--app/lib/feed_manager.rb93
-rw-r--r--app/lib/formatter.rb26
-rw-r--r--app/lib/frontmatter_handler.rb244
-rw-r--r--app/lib/language_detector.rb31
-rw-r--r--app/lib/themes.rb14
-rw-r--r--app/lib/user_settings_decorator.rb2
8 files changed, 370 insertions, 53 deletions
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index d6e9bc1de..66e4f7c5e 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -53,9 +53,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_tags(status)
-    return unless @object['tag'].is_a?(Array)
+    return if @object['tag'].nil?
 
-    @object['tag'].each do |tag|
+    as_array(@object['tag']).each do |tag|
       case tag['type']
       when 'Hashtag'
         process_hashtag tag, status
@@ -103,9 +103,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_attachments(status)
-    return unless @object['attachment'].is_a?(Array)
+    return if @object['attachment'].nil?
 
-    @object['attachment'].each do |attachment|
+    as_array(@object['attachment']).each do |attachment|
       next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
 
       href             = Addressable::URI.parse(attachment['url']).normalize.to_s
@@ -173,7 +173,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def language_from_content
-    return nil unless language_map?
+    return LanguageDetector.instance.detect(text_from_content, @account) unless language_map?
     @object['contentMap'].keys.first
   end
 
diff --git a/app/lib/extractor.rb b/app/lib/extractor.rb
index 957364293..738ec89a0 100644
--- a/app/lib/extractor.rb
+++ b/app/lib/extractor.rb
@@ -5,7 +5,8 @@ module Extractor
 
   module_function
 
-  def extract_mentions_or_lists_with_indices(text) # :yields: username, list_slug, start, end
+  # :yields: username, list_slug, start, end
+  def extract_mentions_or_lists_with_indices(text)
     return [] unless text =~ Twitter::Regex[:at_signs]
 
     possible_entries = []
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 58650efb6..76365c7d3 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -26,34 +26,42 @@ class FeedManager
     end
   end
 
-  def push(timeline_type, account, status)
-    return false unless add_to_feed(timeline_type, account, status)
-
-    trim(timeline_type, account.id)
-
-    PushUpdateWorker.perform_async(account.id, status.id) if push_update_required?(timeline_type, account.id)
-
+  def push_to_home(account, status)
+    return false unless add_to_feed(:home, account.id, status)
+    trim(:home, account.id)
+    PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}")
     true
   end
 
-  def unpush(timeline_type, account, status)
-    return false unless remove_from_feed(timeline_type, account, status)
+  def unpush_from_home(account, status)
+    return false unless remove_from_feed(:home, account.id, status)
+    Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
+    true
+  end
 
-    payload = Oj.dump(event: :delete, payload: status.id.to_s)
-    Redis.current.publish("timeline:#{account.id}", payload)
+  def push_to_list(list, status)
+    return false unless add_to_feed(:list, list.id, status)
+    trim(:list, list.id)
+    PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
+    true
+  end
 
+  def unpush_from_list(list, status)
+    return false unless remove_from_feed(:list, list.id, status)
+    Redis.current.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
     true
   end
 
   def trim(type, account_id)
     timeline_key = key(type, account_id)
-    reblog_key = key(type, account_id, 'reblogs')
+    reblog_key   = key(type, account_id, 'reblogs')
+
     # Remove any items past the MAX_ITEMS'th entry in our feed
     redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s)
 
     # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
     # tracking anything after it for deduplication purposes.
-    falloff_rank = FeedManager::REBLOG_FALLOFF - 1
+    falloff_rank  = FeedManager::REBLOG_FALLOFF - 1
     falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
     falloff_score = falloff_range&.first&.last&.to_i || 0
 
@@ -69,10 +77,6 @@ class FeedManager
     end
   end
 
-  def push_update_required?(timeline_type, account_id)
-    timeline_type != :home || redis.get("subscribed:timeline:#{account_id}").present?
-  end
-
   def merge_into_timeline(from_account, into_account)
     timeline_key = key(:home, into_account.id)
     query        = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4)
@@ -84,28 +88,28 @@ class FeedManager
 
     query.each do |status|
       next if status.direct_visibility? || filter?(:home, status, into_account)
-      add_to_feed(:home, into_account, status)
+      add_to_feed(:home, into_account.id, status)
     end
 
     trim(:home, into_account.id)
   end
 
   def unmerge_from_timeline(from_account, into_account)
-    timeline_key = key(:home, into_account.id)
+    timeline_key      = key(:home, into_account.id)
     oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
 
     from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
-      remove_from_feed(:home, into_account, status)
+      remove_from_feed(:home, into_account.id, status)
     end
   end
 
   def clear_from_timeline(account, target_account)
-    timeline_key = key(:home, account.id)
+    timeline_key        = key(:home, account.id)
     timeline_status_ids = redis.zrange(timeline_key, 0, -1)
-    target_statuses = Status.where(id: timeline_status_ids, account: target_account)
+    target_statuses     = Status.where(id: timeline_status_ids, account: target_account)
 
     target_statuses.each do |status|
-      unpush(:home, account, status)
+      unpush_from_home(account, status)
     end
   end
 
@@ -122,7 +126,7 @@ class FeedManager
 
       statuses.each do |status|
         next if filter_from_home?(status, account)
-        added += 1 if add_to_feed(:home, account, status)
+        added += 1 if add_to_feed(:home, account.id, status)
       end
 
       break unless added.zero?
@@ -137,11 +141,18 @@ class FeedManager
     Redis.current
   end
 
+  def push_update_required?(timeline_id)
+    redis.exists("subscribed:#{timeline_id}")
+  end
+
   def filter_from_home?(status, receiver_id)
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
 
+    return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))
+
     check_for_mutes = [status.account_id]
+    check_for_mutes.concat(status.mentions.pluck(:account_id))
     check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
 
     return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
@@ -157,7 +168,9 @@ class FeedManager
       should_filter &&= status.account_id != status.in_reply_to_account_id                                               # and it's not a self-reply
       return should_filter
     elsif status.reblog?                                                                                                 # Filter out a reblog
-      should_filter   = Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists?        # or if the author of the reblogged status is blocking me
+      src_id = status.account_id
+      should_filter   = Follow.where(account_id: receiver_id, target_account_id: src_id, show_reblogs: false).exists?    # if the reblogger's reblogs are suppressed
+      should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists?        # or if the author of the reblogged status is blocking me
       should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists?  # or the author's domain is blocked
       return should_filter
     end
@@ -165,6 +178,18 @@ class FeedManager
     false
   end
 
+  def keyword_filter?(status, matcher)
+    should_filter   = matcher =~ status.text
+    should_filter ||= matcher =~ status.spoiler_text
+
+    if status.reblog?
+      should_filter ||= matcher =~ status.reblog.text
+      should_filter ||= matcher =~ status.reblog.spoiler_text
+    end
+
+    !!should_filter
+  end
+
   def filter_from_mentions?(status, receiver_id)
     return true if receiver_id == status.account_id
 
@@ -174,6 +199,7 @@ class FeedManager
 
     should_filter   = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
+    should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))                                              # or if the mention contains a muted keyword
 
     should_filter
   end
@@ -182,9 +208,9 @@ class FeedManager
   # added, and false if it was not added to the feed. Note that this is
   # an internal helper: callers must call trim or push updates if
   # either action is appropriate.
-  def add_to_feed(timeline_type, account, status)
-    timeline_key = key(timeline_type, account.id)
-    reblog_key   = key(timeline_type, account.id, 'reblogs')
+  def add_to_feed(timeline_type, account_id, status)
+    timeline_key = key(timeline_type, account_id)
+    reblog_key   = key(timeline_type, account_id, 'reblogs')
 
     if status.reblog?
       # If the original status or a reblog of it is within
@@ -195,6 +221,7 @@ class FeedManager
       return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF
 
       reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id)
+
       if reblog_rank.nil?
         # This is not something we've already seen reblogged, so we
         # can just add it to the feed (and note that we're
@@ -205,7 +232,7 @@ class FeedManager
         # Another reblog of the same status was already in the
         # REBLOG_FALLOFF most recent statuses, so we note that this
         # is an "extra" reblog, by storing it in reblog_set_key.
-        reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}")
+        reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
         redis.sadd(reblog_set_key, status.id)
         return false
       end
@@ -220,8 +247,8 @@ class FeedManager
   # with reblogs, and returning true if a status was removed. As with
   # `add_to_feed`, this does not trigger push updates, so callers must
   # do so if appropriate.
-  def remove_from_feed(timeline_type, account, status)
-    timeline_key = key(timeline_type, account.id)
+  def remove_from_feed(timeline_type, account_id, status)
+    timeline_key = key(timeline_type, account_id)
 
     if status.reblog?
       # 1. If the reblogging status is not in the feed, stop.
@@ -229,7 +256,7 @@ class FeedManager
       return false if status_rank.nil?
 
       # 2. Remove reblog from set of this status's reblogs.
-      reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}")
+      reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
 
       redis.srem(reblog_set_key, status.id)
       # 3. Re-insert another reblog or original into the feed if one
@@ -244,7 +271,7 @@ class FeedManager
       # (outside conditional)
     else
       # If the original is getting deleted, no use for reblog references
-      redis.del(key(timeline_type, account.id, "reblogs:#{status.id}"))
+      redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
     end
 
     redis.zrem(timeline_key, status.id)
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 57f105da7..733a1c4b7 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -89,20 +89,28 @@ class Formatter
     end
   end
 
+  def count_tag_nesting(tag)
+    if tag[1] == '/' then -1
+    elsif tag[-2] == '/' then 0
+    else 1
+    end
+  end
+
   def encode_custom_emojis(html, emojis)
     return html if emojis.empty?
 
     emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url(:static))] }.to_h
 
     i                     = -1
-    inside_tag            = false
+    tag_open_index        = nil
     inside_shortname      = false
     shortname_start_index = -1
+    invisible_depth       = 0
 
     while i + 1 < html.size
       i += 1
 
-      if inside_shortname && html[i] == ':'
+      if invisible_depth.zero? && inside_shortname && html[i] == ':'
         shortcode = html[shortname_start_index + 1..i - 1]
         emoji     = emoji_map[shortcode]
 
@@ -116,12 +124,18 @@ class Formatter
         end
 
         inside_shortname = false
-      elsif inside_tag && html[i] == '>'
-        inside_tag = 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] == '<'
-        inside_tag       = true
+        tag_open_index   = i
         inside_shortname = false
-      elsif !inside_tag && html[i] == ':'
+      elsif !tag_open_index && html[i] == ':'
         inside_shortname      = true
         shortname_start_index = i
       end
diff --git a/app/lib/frontmatter_handler.rb b/app/lib/frontmatter_handler.rb
new file mode 100644
index 000000000..83e5f465e
--- /dev/null
+++ b/app/lib/frontmatter_handler.rb
@@ -0,0 +1,244 @@
+# frozen_string_literal: true
+
+require 'singleton'
+
+#  See also `app/javascript/features/account/util/bio_metadata.js`.
+
+class FrontmatterHandler
+  include Singleton
+
+  #  CONVENIENCE FUNCTIONS  #
+
+  def self.unirex(str)
+    Regexp.new str, Regexp::MULTILINE, 'u'
+  end
+  def self.rexstr(exp)
+    '(?:' + exp.source + ')'
+  end
+
+  #  CHARACTER CLASSES  #
+
+  DOCUMENT_START    = /^/
+  DOCUMENT_END      = /$/
+  ALLOWED_CHAR      =  #  c-printable` in the YAML 1.2 spec.
+    /[\t\n\r\u{20}-\u{7e}\u{85}\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u
+  WHITE_SPACE       = /[ \t]/
+  INDENTATION       = / */
+  LINE_BREAK        = /\r?\n|\r|<br\s*\/?>/
+  ESCAPE_CHAR       = /[0abt\tnvfre "\/\\N_LP]/
+  HEXADECIMAL_CHARS = /[0-9a-fA-F]/
+  INDICATOR         = /[-?:,\[\]{}&#*!|>'"%@`]/
+  FLOW_CHAR         = /[,\[\]{}]/
+
+  #  NEGATED CHARACTER CLASSES  #
+
+  NOT_WHITE_SPACE   = unirex '(?!' + rexstr(WHITE_SPACE) + ').'
+  NOT_LINE_BREAK    = unirex '(?!' + rexstr(LINE_BREAK) + ').'
+  NOT_INDICATOR     = unirex '(?!' + rexstr(INDICATOR) + ').'
+  NOT_FLOW_CHAR     = unirex '(?!' + rexstr(FLOW_CHAR) + ').'
+  NOT_ALLOWED_CHAR  = unirex '(?!' + rexstr(ALLOWED_CHAR) + ').'
+
+  #  BASIC CONSTRUCTS  #
+
+  ANY_WHITE_SPACE   = unirex rexstr(WHITE_SPACE) + '*'
+  ANY_ALLOWED_CHARS = unirex rexstr(ALLOWED_CHAR) + '*'
+  NEW_LINE          = unirex(
+    rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
+  )
+  SOME_NEW_LINES    = unirex(
+    '(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+'
+  )
+  POSSIBLE_STARTS   = unirex(
+    rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
+  )
+  POSSIBLE_ENDS     = unirex(
+    rexstr(SOME_NEW_LINES) + '|' +
+    rexstr(DOCUMENT_END) + '|' +
+    rexstr(/<\/p>/)
+  )
+  CHARACTER_ESCAPE  = unirex(
+    rexstr(/\\/) +
+    '(?:' +
+      rexstr(ESCAPE_CHAR) + '|' +
+      rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' +
+      rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' +
+      rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' +
+    ')'
+  )
+  ESCAPED_CHAR      = unirex(
+    rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' +
+    rexstr(CHARACTER_ESCAPE)
+  )
+  ANY_ESCAPED_CHARS = unirex(
+    rexstr(ESCAPED_CHAR) + '*'
+  )
+  ESCAPED_APOS      = unirex(
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
+  )
+  ANY_ESCAPED_APOS  = unirex(
+    rexstr(ESCAPED_APOS) + '*'
+  )
+  FIRST_KEY_CHAR    = unirex(
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    rexstr(NOT_INDICATOR) + '|' +
+    rexstr(/[?:-]/) +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    '(?=' + rexstr(NOT_FLOW_CHAR) + ')'
+  )
+  FIRST_VALUE_CHAR  = unirex(
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    rexstr(NOT_INDICATOR) + '|' +
+    rexstr(/[?:-]/) +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+    #  Flow indicators are allowed in values.
+  )
+  LATER_KEY_CHAR    = unirex(
+    rexstr(WHITE_SPACE) + '|' +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
+    rexstr(/[^:#]#?/) + '|' +
+    rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+  )
+  LATER_VALUE_CHAR  = unirex(
+    rexstr(WHITE_SPACE) + '|' +
+    '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+    '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+    #  Flow indicators are allowed in values.
+    rexstr(/[^:#]#?/) + '|' +
+    rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+  )
+
+  #  YAML CONSTRUCTS  #
+
+  YAML_START        = unirex(
+    rexstr(ANY_WHITE_SPACE) + rexstr(/---/)
+  )
+  YAML_END          = unirex(
+    rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/)
+  )
+  YAML_LOOKAHEAD    = unirex(
+    '(?=' +
+      rexstr(YAML_START) +
+      rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
+      rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +
+    ')'
+  )
+  YAML_DOUBLE_QUOTE = unirex(
+    rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/)
+  )
+  YAML_SINGLE_QUOTE = unirex(
+    rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/)
+  )
+  YAML_SIMPLE_KEY   = unirex(
+    rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
+  )
+  YAML_SIMPLE_VALUE = unirex(
+    rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
+  )
+  YAML_KEY          = unirex(
+    rexstr(YAML_DOUBLE_QUOTE) + '|' +
+    rexstr(YAML_SINGLE_QUOTE) + '|' +
+    rexstr(YAML_SIMPLE_KEY)
+  )
+  YAML_VALUE        = unirex(
+    rexstr(YAML_DOUBLE_QUOTE) + '|' +
+    rexstr(YAML_SINGLE_QUOTE) + '|' +
+    rexstr(YAML_SIMPLE_VALUE)
+  )
+  YAML_SEPARATOR    = unirex(
+    rexstr(ANY_WHITE_SPACE) +
+    ':' + rexstr(WHITE_SPACE) +
+    rexstr(ANY_WHITE_SPACE)
+  )
+  YAML_LINE         = unirex(
+    '(' + rexstr(YAML_KEY) + ')' +
+    rexstr(YAML_SEPARATOR) +
+    '(' + rexstr(YAML_VALUE) + ')'
+  )
+
+  #  FRONTMATTER REGEX  #
+
+  YAML_FRONTMATTER  = unirex(
+    rexstr(POSSIBLE_STARTS) +
+    rexstr(YAML_LOOKAHEAD) +
+    rexstr(YAML_START) + rexstr(SOME_NEW_LINES) +
+    '(?:' +
+      '(' + rexstr(INDENTATION) + ')' +
+      rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
+      '(?:' +
+        '\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
+      '){0,4}' +
+    ')?' +
+    rexstr(YAML_END) + rexstr(POSSIBLE_ENDS)
+  )
+
+  #  SEARCHES  #
+
+  FIND_YAML_LINES   = unirex(
+    rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE)
+  )
+
+  #  STRING PROCESSING  #
+
+  def process_string(str)
+    case str[0]
+    when '"'
+      str[1..-2]
+        .gsub(/\\0/, "\u{00}")
+        .gsub(/\\a/, "\u{07}")
+        .gsub(/\\b/, "\u{08}")
+        .gsub(/\\t/, "\u{09}")
+        .gsub(/\\\u{09}/, "\u{09}")
+        .gsub(/\\n/, "\u{0a}")
+        .gsub(/\\v/, "\u{0b}")
+        .gsub(/\\f/, "\u{0c}")
+        .gsub(/\\r/, "\u{0d}")
+        .gsub(/\\e/, "\u{1b}")
+        .gsub(/\\ /, "\u{20}")
+        .gsub(/\\"/, "\u{22}")
+        .gsub(/\\\//, "\u{2f}")
+        .gsub(/\\\\/, "\u{5c}")
+        .gsub(/\\N/, "\u{85}")
+        .gsub(/\\_/, "\u{a0}")
+        .gsub(/\\L/, "\u{2028}")
+        .gsub(/\\P/, "\u{2029}")
+        .gsub(/\\x([0-9a-fA-F]{2})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
+        .gsub(/\\u([0-9a-fA-F]{4})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
+        .gsub(/\\U([0-9a-fA-F]{8})/mu) {|s| $1.to_i.chr Encoding::UTF_8}
+    when "'"
+      str[1..-2].gsub(/''/, "'")
+    else
+      str
+    end
+  end
+
+  #  BIO PROCESSING  #
+
+  def process_bio content
+    result = {
+      text: content.gsub(/&quot;/, '"').gsub(/&apos;/, "'"),
+      metadata: []
+    }
+    yaml = YAML_FRONTMATTER.match(result[:text])
+    return result unless yaml
+    yaml = yaml[0]
+    start = YAML_START =~ result[:text]
+    ending = start + yaml.length - (YAML_START =~ yaml)
+    result[:text][start..ending - 1] = ''
+    metadata = nil
+    index = 0
+    while metadata = FIND_YAML_LINES.match(yaml, index) do
+      index = metadata.end(0)
+      result[:metadata].push [
+        process_string(metadata[1]), process_string(metadata[2])
+      ]
+    end
+    return result
+  end
+
+end
diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb
index a42460e10..c6f52f0c7 100644
--- a/app/lib/language_detector.rb
+++ b/app/lib/language_detector.rb
@@ -38,12 +38,31 @@ class LanguageDetector
   end
 
   def simplify_text(text)
-    text.dup.tap do |new_text|
-      new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
-      new_text.gsub!(Account::MENTION_RE, '')
-      new_text.gsub!(Tag::HASHTAG_RE, '')
-      new_text.gsub!(/\s+/, ' ')
-    end
+    new_text = remove_html(text)
+    new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
+    new_text.gsub!(Account::MENTION_RE, '')
+    new_text.gsub!(Tag::HASHTAG_RE, '')
+    new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '')
+    new_text.gsub!(/\s+/, ' ')
+    new_text
+  end
+
+  def new_scrubber
+    scrubber = Rails::Html::PermitScrubber.new
+    scrubber.tags = %w(br p)
+    scrubber
+  end
+
+  def scrubber
+    @scrubber ||= new_scrubber
+  end
+
+  def remove_html(text)
+    text = Loofah.fragment(text).scrub!(scrubber).to_s
+    text.gsub!('<br>', "\n")
+    text.gsub!('</p><p>', "\n\n")
+    text.gsub!(/(^<p>|<\/p>$)/, '')
+    text
   end
 
   def default_locale(account)
diff --git a/app/lib/themes.rb b/app/lib/themes.rb
index 243ffb9ab..f7ec22fd2 100644
--- a/app/lib/themes.rb
+++ b/app/lib/themes.rb
@@ -7,7 +7,19 @@ class Themes
   include Singleton
 
   def initialize
-    @conf = YAML.load_file(Rails.root.join('config', 'themes.yml'))
+    result = Hash.new
+    Dir.glob(Rails.root.join('app', 'javascript', 'themes', '*', 'theme.yml')) do |path|
+      data = YAML.load_file(path)
+      name = File.basename(File.dirname(path))
+      if data['pack']
+        result[name] = data
+      end
+    end
+    @conf = result
+  end
+
+  def get(name)
+    @conf[name]
   end
 
   def names
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index d48e1da65..d86959c0b 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -76,7 +76,7 @@ class UserSettingsDecorator
   def theme_preference
     settings['setting_theme']
   end
-
+  
   def boolean_cast_setting(key)
     settings[key] == '1'
   end