about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb44
-rw-r--r--app/models/account_domain_block.rb2
-rw-r--r--app/models/block.rb2
-rw-r--r--app/models/concerns/account_finder_concern.rb2
-rw-r--r--app/models/concerns/account_interactions.rb43
-rw-r--r--app/models/conversation_mute.rb2
-rw-r--r--app/models/custom_emoji.rb3
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/email_domain_block.rb23
-rw-r--r--app/models/favourite.rb2
-rw-r--r--app/models/feed.rb23
-rw-r--r--app/models/follow.rb3
-rw-r--r--app/models/follow_request.rb9
-rw-r--r--app/models/glitch.rb7
-rw-r--r--app/models/glitch/keyword_mute.rb66
-rw-r--r--app/models/home_feed.rb25
-rw-r--r--app/models/import.rb2
-rw-r--r--app/models/list.rb22
-rw-r--r--app/models/list_account.rb24
-rw-r--r--app/models/list_feed.rb8
-rw-r--r--app/models/media_attachment.rb31
-rw-r--r--app/models/mention.rb2
-rw-r--r--app/models/mute.rb11
-rw-r--r--app/models/notification.rb2
-rw-r--r--app/models/report.rb2
-rw-r--r--app/models/session_activation.rb8
-rw-r--r--app/models/setting.rb2
-rw-r--r--app/models/status.rb19
-rw-r--r--app/models/stream_entry.rb4
-rw-r--r--app/models/subscription.rb2
-rw-r--r--app/models/user.rb80
-rw-r--r--app/models/web/push_subscription.rb4
-rw-r--r--app/models/web/setting.rb2
33 files changed, 387 insertions, 96 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index 3dc2a95ab..a4b8e1c0b 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -41,10 +41,11 @@
 #  shared_inbox_url        :string           default(""), not null
 #  followers_url           :string           default(""), not null
 #  protocol                :integer          default("ostatus"), not null
+#  memorial                :boolean          default(FALSE), not null
 #
 
 class Account < ApplicationRecord
-  MENTION_RE = /(?:^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
+  MENTION_RE = /(?<=^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
 
   include AccountAvatar
   include AccountFinderConcern
@@ -52,6 +53,9 @@ class Account < ApplicationRecord
   include AccountInteractions
   include Attachmentable
   include Remotable
+  include Paginable
+
+  MAX_NOTE_LENGTH = 500
 
   enum protocol: [:ostatus, :activitypub]
 
@@ -67,7 +71,7 @@ class Account < ApplicationRecord
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
   validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
-  validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
+  validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? }
 
   # Timelines
   has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -94,6 +98,10 @@ class Account < ApplicationRecord
   has_many :account_moderation_notes, dependent: :destroy
   has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy
 
+  # Lists
+  has_many :list_accounts, inverse_of: :account, dependent: :destroy
+  has_many :lists, through: :list_accounts
+
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where(followers_count: 0) }
@@ -114,6 +122,8 @@ class Account < ApplicationRecord
            :current_sign_in_at,
            :confirmed?,
            :admin?,
+           :moderator?,
+           :staff?,
            :locale,
            to: :user,
            prefix: true,
@@ -150,6 +160,20 @@ class Account < ApplicationRecord
     ResolveRemoteAccountService.new.call(acct)
   end
 
+  def unsuspend!
+    transaction do
+      user&.enable! if local?
+      update!(suspended: false)
+    end
+  end
+
+  def memorialize!
+    transaction do
+      user&.disable! if local?
+      update!(memorial: true)
+    end
+  end
+
   def keypair
     @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
@@ -292,6 +316,22 @@ class Account < ApplicationRecord
     self.public_key  = keypair.public_key.to_pem
   end
 
+  YAML_START = "---\r\n"
+  YAML_END = "\r\n...\r\n"
+
+  def note_length_does_not_exceed_length_limit
+    note_without_metadata = note
+    if note.start_with? YAML_START
+      idx = note.index YAML_END
+      unless idx.nil?
+        note_without_metadata = note[(idx + YAML_END.length) .. -1]
+      end
+    end
+    if note_without_metadata.mb_chars.grapheme_length > MAX_NOTE_LENGTH
+      errors.add(:note, "can't be longer than 500 graphemes")
+    end
+  end
+
   def normalize_domain
     return if local?
 
diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb
index fb695e473..35810b6c2 100644
--- a/app/models/account_domain_block.rb
+++ b/app/models/account_domain_block.rb
@@ -3,11 +3,11 @@
 #
 # Table name: account_domain_blocks
 #
+#  id         :integer          not null, primary key
 #  domain     :string
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
 #  account_id :integer
-#  id         :integer          not null, primary key
 #
 
 class AccountDomainBlock < ApplicationRecord
diff --git a/app/models/block.rb b/app/models/block.rb
index a913782ed..284abfe4c 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -3,10 +3,10 @@
 #
 # Table name: blocks
 #
+#  id                :integer          not null, primary key
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
 #  account_id        :integer          not null
-#  id                :integer          not null, primary key
 #  target_account_id :integer          not null
 #
 
diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb
index 561c7ab9f..2e8a7fb37 100644
--- a/app/models/concerns/account_finder_concern.rb
+++ b/app/models/concerns/account_finder_concern.rb
@@ -44,7 +44,7 @@ module AccountFinderConcern
     end
 
     def with_usernames
-      Account.where.not(username: [nil, ''])
+      Account.where.not(username: '')
     end
 
     def matching_username
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index b26520f5b..c41f92581 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -5,7 +5,11 @@ module AccountInteractions
 
   class_methods do
     def following_map(target_account_ids, account_id)
-      follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+      Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping|
+        mapping[follow.target_account_id] = {
+          reblogs: follow.show_reblogs?
+        }
+      end
     end
 
     def followed_by_map(target_account_ids, account_id)
@@ -17,11 +21,19 @@ module AccountInteractions
     end
 
     def muting_map(target_account_ids, account_id)
-      follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+      Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
+        mapping[mute.target_account_id] = {
+          notifications: mute.hide_notifications?,
+        }
+      end
     end
 
     def requested_map(target_account_ids, account_id)
-      follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+      FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping|
+        mapping[follow_request.target_account_id] = {
+          reblogs: follow_request.show_reblogs?
+        }
+      end
     end
 
     def domain_blocking_map(target_account_ids, account_id)
@@ -62,16 +74,25 @@ module AccountInteractions
     has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
   end
 
-  def follow!(other_account)
-    active_relationships.find_or_create_by!(target_account: other_account)
+  def follow!(other_account, reblogs: nil)
+    reblogs = true if reblogs.nil?
+    rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account)
+    rel.update!(show_reblogs: reblogs)
+
+    rel
   end
 
   def block!(other_account)
     block_relationships.find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account)
-    mute_relationships.find_or_create_by!(target_account: other_account)
+  def mute!(other_account, notifications: nil)
+    notifications = true if notifications.nil?
+    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
+    # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
+    if mute.hide_notifications? != notifications
+      mute.update!(hide_notifications: notifications)
+    end
   end
 
   def mute_conversation!(conversation)
@@ -127,6 +148,14 @@ module AccountInteractions
     conversation_mutes.where(conversation: conversation).exists?
   end
 
+  def muting_notifications?(other_account)
+    mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
+  end
+
+  def muting_reblogs?(other_account)
+    active_relationships.where(target_account: other_account, show_reblogs: false).exists?
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
index 8d2399adf..248cdfe6e 100644
--- a/app/models/conversation_mute.rb
+++ b/app/models/conversation_mute.rb
@@ -3,9 +3,9 @@
 #
 # Table name: conversation_mutes
 #
+#  id              :integer          not null, primary key
 #  conversation_id :integer          not null
 #  account_id      :integer          not null
-#  id              :integer          not null, primary key
 #
 
 class ConversationMute < ApplicationRecord
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 65d9840d5..a77b53c98 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -15,6 +15,7 @@
 #  disabled           :boolean          default(FALSE), not null
 #  uri                :string
 #  image_remote_url   :string
+#  visible_in_picker  :boolean          default(TRUE), not null
 #
 
 class CustomEmoji < ApplicationRecord
@@ -24,6 +25,8 @@ class CustomEmoji < ApplicationRecord
     :(#{SHORTCODE_RE_FRAGMENT}):
     (?=[^[:alnum:]:]|$)/x
 
+  has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
+
   has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
 
   validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes }
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 1268290bc..aea8919af 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -3,12 +3,12 @@
 #
 # Table name: domain_blocks
 #
+#  id           :integer          not null, primary key
 #  domain       :string           default(""), not null
 #  created_at   :datetime         not null
 #  updated_at   :datetime         not null
 #  severity     :integer          default("silence")
 #  reject_media :boolean          default(FALSE), not null
-#  id           :integer          not null, primary key
 #
 
 class DomainBlock < ApplicationRecord
diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb
index 839038bea..a104810d1 100644
--- a/app/models/email_domain_block.rb
+++ b/app/models/email_domain_block.rb
@@ -4,14 +4,33 @@
 # Table name: email_domain_blocks
 #
 #  id         :integer          not null, primary key
-#  domain     :string           not null
+#  domain     :string           default(""), not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
 #
 
 class EmailDomainBlock < ApplicationRecord
+  before_validation :normalize_domain
+
+  validates :domain, presence: true, uniqueness: true
+
   def self.block?(email)
-    domain = email.gsub(/.+@([^.]+)/, '\1')
+    _, domain = email.split('@', 2)
+
+    return true if domain.nil?
+
+    begin
+      domain = TagManager.instance.normalize_domain(domain)
+    rescue Addressable::URI::InvalidURIError
+      return true
+    end
+
     where(domain: domain).exists?
   end
+
+  private
+
+  def normalize_domain
+    self.domain = TagManager.instance.normalize_domain(domain)
+  end
 end
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index d28d5c05b..c38838f2a 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -3,10 +3,10 @@
 #
 # Table name: favourites
 #
+#  id         :integer          not null, primary key
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
 #  account_id :integer          not null
-#  id         :integer          not null, primary key
 #  status_id  :integer          not null
 #
 
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 5f7b7877a..d99f1ffb2 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -1,36 +1,27 @@
 # frozen_string_literal: true
 
 class Feed
-  def initialize(type, account)
-    @type    = type
-    @account = account
+  def initialize(type, id)
+    @type = type
+    @id   = id
   end
 
   def get(limit, max_id = nil, since_id = nil)
-    if redis.exists("account:#{@account.id}:regeneration")
-      from_database(limit, max_id, since_id)
-    else
-      from_redis(limit, max_id, since_id)
-    end
+    from_redis(limit, max_id, since_id)
   end
 
-  private
+  protected
 
   def from_redis(limit, max_id, since_id)
     max_id     = '+inf' if max_id.blank?
     since_id   = '-inf' if since_id.blank?
     unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
-    Status.where(id: unhydrated).cache_ids
-  end
 
-  def from_database(limit, max_id, since_id)
-    Status.as_home_timeline(@account)
-          .paginate_by_max_id(limit, max_id, since_id)
-          .reject { |status| FeedManager.instance.filter?(:home, status, @account.id) }
+    Status.where(id: unhydrated).cache_ids
   end
 
   def key
-    FeedManager.instance.key(@type, @account.id)
+    FeedManager.instance.key(@type, @id)
   end
 
   def redis
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 667720a88..3fb665afc 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -3,11 +3,12 @@
 #
 # Table name: follows
 #
+#  id                :integer          not null, primary key
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
 #  account_id        :integer          not null
-#  id                :integer          not null, primary key
 #  target_account_id :integer          not null
+#  show_reblogs      :boolean          default(TRUE), not null
 #
 
 class Follow < ApplicationRecord
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 60036d903..ebf6959ce 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -3,11 +3,12 @@
 #
 # Table name: follow_requests
 #
+#  id                :integer          not null, primary key
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
 #  account_id        :integer          not null
-#  id                :integer          not null, primary key
 #  target_account_id :integer          not null
+#  show_reblogs      :boolean          default(TRUE), not null
 #
 
 class FollowRequest < ApplicationRecord
@@ -21,13 +22,11 @@ class FollowRequest < ApplicationRecord
   validates :account_id, uniqueness: { scope: :target_account_id }
 
   def authorize!
-    account.follow!(target_account)
+    account.follow!(target_account, reblogs: show_reblogs)
     MergeWorker.perform_async(target_account.id, account.id)
 
     destroy!
   end
 
-  def reject!
-    destroy!
-  end
+  alias reject! destroy!
 end
diff --git a/app/models/glitch.rb b/app/models/glitch.rb
new file mode 100644
index 000000000..0e497babc
--- /dev/null
+++ b/app/models/glitch.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Glitch
+  def self.table_name_prefix
+    'glitch_'
+  end
+end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
new file mode 100644
index 000000000..009de1880
--- /dev/null
+++ b/app/models/glitch/keyword_mute.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: glitch_keyword_mutes
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  keyword    :string           not null
+#  whole_word :boolean          default(TRUE), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class Glitch::KeywordMute < ApplicationRecord
+  belongs_to :account, required: true
+
+  validates_presence_of :keyword
+
+  after_commit :invalidate_cached_matcher
+
+  def self.matcher_for(account_id)
+    Matcher.new(account_id)
+  end
+
+  private
+
+  def invalidate_cached_matcher
+    Rails.cache.delete("keyword_mutes:regex:#{account_id}")
+  end
+
+  class Matcher
+    attr_reader :account_id
+    attr_reader :regex
+
+    def initialize(account_id)
+      @account_id = account_id
+      regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account }
+      @regex = /#{regex_text}/
+    end
+
+    def =~(str)
+      regex =~ str
+    end
+
+    private
+
+    def keywords
+      Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word)
+    end
+
+    def regex_text_for_account
+      kws = keywords.find_each.with_object([]) do |kw, a|
+        a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword)
+      end
+
+      Regexp.union(kws).source
+    end
+
+    def boundary_regex_for_keyword(keyword)
+      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+
+      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    end
+  end
+end
diff --git a/app/models/home_feed.rb b/app/models/home_feed.rb
new file mode 100644
index 000000000..b943a34ce
--- /dev/null
+++ b/app/models/home_feed.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class HomeFeed < Feed
+  def initialize(account)
+    @type    = :home
+    @id      = account.id
+    @account = account
+  end
+
+  def get(limit, max_id = nil, since_id = nil)
+    if redis.exists("account:#{@account.id}:regeneration")
+      from_database(limit, max_id, since_id)
+    else
+      super
+    end
+  end
+
+  private
+
+  def from_database(limit, max_id, since_id)
+    Status.as_home_timeline(@account)
+          .paginate_by_max_id(limit, max_id, since_id)
+          .reject { |status| FeedManager.instance.filter?(:home, status, @account.id) }
+  end
+end
diff --git a/app/models/import.rb b/app/models/import.rb
index 8ae7e3a46..091fb3044 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -3,6 +3,7 @@
 #
 # Table name: imports
 #
+#  id                :integer          not null, primary key
 #  type              :integer          not null
 #  approved          :boolean          default(FALSE), not null
 #  created_at        :datetime         not null
@@ -12,7 +13,6 @@
 #  data_file_size    :integer
 #  data_updated_at   :datetime
 #  account_id        :integer          not null
-#  id                :integer          not null, primary key
 #
 
 class Import < ApplicationRecord
diff --git a/app/models/list.rb b/app/models/list.rb
new file mode 100644
index 000000000..5d7ba0065
--- /dev/null
+++ b/app/models/list.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: lists
+#
+#  id         :integer          not null, primary key
+#  account_id :integer
+#  title      :string           default(""), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class List < ApplicationRecord
+  include Paginable
+
+  belongs_to :account
+
+  has_many :list_accounts, inverse_of: :list, dependent: :destroy
+  has_many :accounts, through: :list_accounts
+
+  validates :title, presence: true
+end
diff --git a/app/models/list_account.rb b/app/models/list_account.rb
new file mode 100644
index 000000000..c08239aa0
--- /dev/null
+++ b/app/models/list_account.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: list_accounts
+#
+#  id         :integer          not null, primary key
+#  list_id    :integer          not null
+#  account_id :integer          not null
+#  follow_id  :integer          not null
+#
+
+class ListAccount < ApplicationRecord
+  belongs_to :list, required: true
+  belongs_to :account, required: true
+  belongs_to :follow, required: true
+
+  before_validation :set_follow
+
+  private
+
+  def set_follow
+    self.follow = Follow.find_by(account_id: list.account_id, target_account_id: account.id)
+  end
+end
diff --git a/app/models/list_feed.rb b/app/models/list_feed.rb
new file mode 100644
index 000000000..f371e4ed9
--- /dev/null
+++ b/app/models/list_feed.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class ListFeed < Feed
+  def initialize(list)
+    @type    = :list
+    @id      = list.id
+  end
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 60380198b..368ccef3a 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -10,12 +10,12 @@
 #  file_file_size    :integer
 #  file_updated_at   :datetime
 #  remote_url        :string           default(""), not null
-#  account_id        :integer
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
 #  shortcode         :string
 #  type              :integer          default("image"), not null
 #  file_meta         :json
+#  account_id        :integer
 #  description       :text
 #
 
@@ -24,15 +24,32 @@ require 'mime/types'
 class MediaAttachment < ApplicationRecord
   self.inheritance_column = nil
 
-  enum type: [:image, :gifv, :video, :unknown]
+  enum type: [:image, :gifv, :video, :audio, :unknown]
 
   IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze
   VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v'].freeze
+  AUDIO_FILE_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.ogg'].freeze
 
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
+  AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 
   IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  AUDIO_STYLES = {
+    original: {
+      format: 'mp4',
+      convert_options: {
+        output: {
+          filter_complex: '"[0:a]compand,showwaves=s=640x360:mode=line,format=yuv420p[v]"',
+          map: '"[v]" -map 0:a', 
+          threads: 2,
+          vcodec: 'libx264',
+          acodec: 'aac',
+          movflags: '+faststart',
+        },
+      },
+    },
+  }.freeze
   VIDEO_STYLES = {
     small: {
       convert_options: {
@@ -55,7 +72,7 @@ class MediaAttachment < ApplicationRecord
 
   include Remotable
 
-  validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
+  validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
   validates_attachment_size :file, less_than: 8.megabytes
 
   validates :account, presence: true
@@ -110,6 +127,8 @@ class MediaAttachment < ApplicationRecord
         }
       elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
         IMAGE_STYLES
+      elsif AUDIO_MIME_TYPES.include? f.instance.file_content_type
+        AUDIO_STYLES
       else
         VIDEO_STYLES
       end
@@ -120,6 +139,8 @@ class MediaAttachment < ApplicationRecord
         [:gif_transcoder]
       elsif VIDEO_MIME_TYPES.include? f.file_content_type
         [:video_transcoder]
+      elsif AUDIO_MIME_TYPES.include? f.file_content_type
+        [:audio_transcoder]
       else
         [:thumbnail]
       end
@@ -144,8 +165,8 @@ class MediaAttachment < ApplicationRecord
   end
 
   def set_type_and_extension
-    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
-    extension = appropriate_extension
+    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : AUDIO_MIME_TYPES.include?(file_content_type) ? :audio : :image
+    extension = AUDIO_MIME_TYPES.include?(file_content_type) ? '.mp4' : appropriate_extension
     basename  = Paperclip::Interpolations.basename(file, :original)
     file.instance_write :file_name, [basename, extension].delete_if(&:blank?).join('.')
   end
diff --git a/app/models/mention.rb b/app/models/mention.rb
index 3700c781c..14533e6a9 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -3,11 +3,11 @@
 #
 # Table name: mentions
 #
+#  id         :integer          not null, primary key
 #  status_id  :integer
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
 #  account_id :integer
-#  id         :integer          not null, primary key
 #
 
 class Mention < ApplicationRecord
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 6e64848c7..ca984641a 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -3,11 +3,12 @@
 #
 # Table name: mutes
 #
-#  created_at        :datetime         not null
-#  updated_at        :datetime         not null
-#  account_id        :integer          not null
-#  id                :integer          not null, primary key
-#  target_account_id :integer          not null
+#  id                 :integer          not null, primary key
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
+#  hide_notifications :boolean          default(TRUE), not null
+#  account_id         :integer          not null
+#  target_account_id  :integer          not null
 #
 
 class Mute < ApplicationRecord
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 0a5d987cf..a3ffb1f45 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -4,11 +4,11 @@
 # Table name: notifications
 #
 #  id              :integer          not null, primary key
-#  account_id      :integer
 #  activity_id     :integer
 #  activity_type   :string
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
+#  account_id      :integer
 #  from_account_id :integer
 #
 
diff --git a/app/models/report.rb b/app/models/report.rb
index bffb42b48..c36f8db0a 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -3,6 +3,7 @@
 #
 # Table name: reports
 #
+#  id                         :integer          not null, primary key
 #  status_ids                 :integer          default([]), not null, is an Array
 #  comment                    :text             default(""), not null
 #  action_taken               :boolean          default(FALSE), not null
@@ -10,7 +11,6 @@
 #  updated_at                 :datetime         not null
 #  account_id                 :integer          not null
 #  action_taken_by_account_id :integer
-#  id                         :integer          not null, primary key
 #  target_account_id          :integer          not null
 #
 
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index c1645223b..d19489b36 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -4,24 +4,24 @@
 # Table name: session_activations
 #
 #  id                       :integer          not null, primary key
-#  user_id                  :integer          not null
 #  session_id               :string           not null
 #  created_at               :datetime         not null
 #  updated_at               :datetime         not null
 #  user_agent               :string           default(""), not null
 #  ip                       :inet
 #  access_token_id          :integer
+#  user_id                  :integer          not null
 #  web_push_subscription_id :integer
 #
 
-#  id              :integer          not null, primary key
-#  user_id         :integer          not null
+#  id              :bigint           not null, primary key
+#  user_id         :bigint           not null
 #  session_id      :string           not null
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
 #  user_agent      :string           default(""), not null
 #  ip              :inet
-#  access_token_id :integer
+#  access_token_id :bigint
 #
 
 class SessionActivation < ApplicationRecord
diff --git a/app/models/setting.rb b/app/models/setting.rb
index a14f156a1..df93590ce 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -3,12 +3,12 @@
 #
 # Table name: settings
 #
+#  id         :integer          not null, primary key
 #  var        :string           not null
 #  value      :text
 #  thing_type :string
 #  created_at :datetime
 #  updated_at :datetime
-#  id         :integer          not null, primary key
 #  thing_id   :integer
 #
 
diff --git a/app/models/status.rb b/app/models/status.rb
index 5a7245613..172d3a665 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -5,7 +5,6 @@
 #
 #  id                     :integer          not null, primary key
 #  uri                    :string
-#  account_id             :integer          not null
 #  text                   :text             default(""), not null
 #  created_at             :datetime         not null
 #  updated_at             :datetime         not null
@@ -14,8 +13,6 @@
 #  url                    :string
 #  sensitive              :boolean          default(FALSE), not null
 #  visibility             :integer          default("public"), not null
-#  in_reply_to_account_id :integer
-#  application_id         :integer
 #  spoiler_text           :text             default(""), not null
 #  reply                  :boolean          default(FALSE), not null
 #  favourites_count       :integer          default(0), not null
@@ -23,6 +20,9 @@
 #  language               :string
 #  conversation_id        :integer
 #  local                  :boolean
+#  account_id             :integer          not null
+#  application_id         :integer
+#  in_reply_to_account_id :integer
 #
 
 class Status < ApplicationRecord
@@ -154,6 +154,14 @@ class Status < ApplicationRecord
       where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
     end
 
+    def as_direct_timeline(account)
+      query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
+              .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
+              .where(visibility: [:direct])
+
+      apply_timeline_filters(query, account, false)
+    end
+
     def as_public_timeline(account = nil, local_only = false)
       query = timeline_scope(local_only).without_replies
 
@@ -261,6 +269,11 @@ class Status < ApplicationRecord
     end
   end
 
+  def local_only?
+    # match both with and without U+FE0F (the emoji variation selector)
+    /👁\ufe0f?\z/.match?(content)
+  end
+
   private
 
   def store_uri
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index b51fe9ad7..36fe487dc 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -3,13 +3,13 @@
 #
 # Table name: stream_entries
 #
+#  id            :integer          not null, primary key
 #  activity_id   :integer
 #  activity_type :string
 #  created_at    :datetime         not null
 #  updated_at    :datetime         not null
 #  hidden        :boolean          default(FALSE), not null
 #  account_id    :integer
-#  id            :integer          not null, primary key
 #
 
 class StreamEntry < ApplicationRecord
@@ -27,7 +27,7 @@ class StreamEntry < ApplicationRecord
   scope :recent, -> { reorder(id: :desc) }
   scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
 
-  delegate :target, :title, :content, :thread,
+  delegate :target, :title, :content, :thread, :local_only?,
            to: :status,
            allow_nil: true
 
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 39860196b..7f2eeab91 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -3,6 +3,7 @@
 #
 # Table name: subscriptions
 #
+#  id                          :integer          not null, primary key
 #  callback_url                :string           default(""), not null
 #  secret                      :string
 #  expires_at                  :datetime
@@ -12,7 +13,6 @@
 #  last_successful_delivery_at :datetime
 #  domain                      :string
 #  account_id                  :integer          not null
-#  id                          :integer          not null, primary key
 #
 
 class Subscription < ApplicationRecord
diff --git a/app/models/user.rb b/app/models/user.rb
index 325e27f44..b9b228c00 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -5,7 +5,6 @@
 #
 #  id                        :integer          not null, primary key
 #  email                     :string           default(""), not null
-#  account_id                :integer          not null
 #  created_at                :datetime         not null
 #  updated_at                :datetime         not null
 #  encrypted_password        :string           default(""), not null
@@ -31,10 +30,14 @@
 #  last_emailed_at           :datetime
 #  otp_backup_codes          :string           is an Array
 #  filtered_languages        :string           default([]), not null, is an Array
+#  account_id                :integer          not null
+#  disabled                  :boolean          default(FALSE), not null
+#  moderator                 :boolean          default(FALSE), not null
 #
 
 class User < ApplicationRecord
   include Settings::Extend
+
   ACTIVE_DURATION = 14.days
 
   devise :registerable, :recoverable,
@@ -51,8 +54,10 @@ class User < ApplicationRecord
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
 
-  scope :recent,    -> { order(id: :desc) }
-  scope :admins,    -> { where(admin: true) }
+  scope :recent, -> { order(id: :desc) }
+  scope :admins, -> { where(admin: true) }
+  scope :moderators, -> { where(moderator: true) }
+  scope :staff, -> { admins.or(moderators) }
   scope :confirmed, -> { where.not(confirmed_at: nil) }
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended: false }) }
@@ -68,54 +73,71 @@ class User < ApplicationRecord
 
   has_many :session_activations, dependent: :destroy
 
+  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
+           :reduce_motion, :system_font_ui, :noindex, :theme,
+           to: :settings, prefix: :setting, allow_nil: false
+
   def confirmed?
     confirmed_at.present?
   end
 
-  def disable_two_factor!
-    self.otp_required_for_login = false
-    otp_backup_codes&.clear
-    save!
-  end
-
-  def setting_default_privacy
-    settings.default_privacy || (account.locked? ? 'private' : 'public')
+  def staff?
+    admin? || moderator?
   end
 
-  def setting_default_sensitive
-    settings.default_sensitive
+  def role
+    if admin?
+      'admin'
+    elsif moderator?
+      'moderator'
+    else
+      'user'
+    end
   end
 
-  def setting_unfollow_modal
-    settings.unfollow_modal
+  def disable!
+    update!(disabled: true,
+            last_sign_in_at: current_sign_in_at,
+            current_sign_in_at: nil)
   end
 
-  def setting_boost_modal
-    settings.boost_modal
+  def enable!
+    update!(disabled: false)
   end
 
-  def setting_delete_modal
-    settings.delete_modal
+  def confirm!
+    skip_confirmation!
+    save!
   end
 
-  def setting_auto_play_gif
-    settings.auto_play_gif
+  def promote!
+    if moderator?
+      update!(moderator: false, admin: true)
+    elsif !admin?
+      update!(moderator: true)
+    end
   end
 
-  def setting_reduce_motion
-    settings.reduce_motion
+  def demote!
+    if admin?
+      update!(admin: false, moderator: true)
+    elsif moderator?
+      update!(moderator: false)
+    end
   end
 
-  def setting_system_font_ui
-    settings.system_font_ui
+  def disable_two_factor!
+    self.otp_required_for_login = false
+    otp_backup_codes&.clear
+    save!
   end
 
-  def setting_noindex
-    settings.noindex
+  def active_for_authentication?
+    super && !disabled?
   end
 
-  def setting_theme
-    settings.theme
+  def setting_default_privacy
+    settings.default_privacy || (account.locked? ? 'private' : 'public')
   end
 
   def token_for_app(a)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index cb15dfa37..5aee92d27 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -24,12 +24,12 @@ class Web::PushSubscription < ApplicationRecord
   end
 
   def pushable?(notification)
-    data && data.key?('alerts') && data['alerts'][notification.type.to_s]
+    data&.key?('alerts') && data['alerts'][notification.type.to_s]
   end
 
   def as_payload
     payload = { id: id, endpoint: endpoint }
-    payload[:alerts] = data['alerts'] if data && data.key?('alerts')
+    payload[:alerts] = data['alerts'] if data&.key?('alerts')
     payload
   end
 
diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb
index 1b0bfb2b7..12b9d1226 100644
--- a/app/models/web/setting.rb
+++ b/app/models/web/setting.rb
@@ -3,10 +3,10 @@
 #
 # Table name: web_settings
 #
+#  id         :integer          not null, primary key
 #  data       :json
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
-#  id         :integer          not null, primary key
 #  user_id    :integer
 #