about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-06-27 14:22:30 -0500
committerFire Demon <firedemon@creature.cafe>2020-09-08 03:37:04 -0500
commit9d4f18b984d6699bdf96e5f5963edfe80063426c (patch)
treee00fb54963769a259cd9bbe97754a2a872d028be /app/models
parent437d71bddf967573df3912ee5976f7c5a5a7b4c7 (diff)
Monsterfork v2 Kaiju Commit 2020.06.27.1 - 2020.09.05.5
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb45
-rw-r--r--app/models/account_domain_permission.rb70
-rw-r--r--app/models/account_metadata.rb52
-rw-r--r--app/models/collection_item.rb21
-rw-r--r--app/models/collection_page.rb17
-rw-r--r--app/models/concerns/account_associations.rb18
-rw-r--r--app/models/concerns/account_interactions.rb39
-rw-r--r--app/models/concerns/status_threading_concern.rb2
-rw-r--r--app/models/conversation.rb5
-rw-r--r--app/models/conversation_mute.rb5
-rw-r--r--app/models/domain_allow.rb2
-rw-r--r--app/models/domain_block.rb1
-rw-r--r--app/models/follow_request.rb5
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/inline_media_attachment.rb20
-rw-r--r--app/models/invite.rb2
-rw-r--r--app/models/media_attachment.rb32
-rw-r--r--app/models/mute.rb1
-rw-r--r--app/models/queued_boost.rb15
-rw-r--r--app/models/status.rb308
-rw-r--r--app/models/status_domain_permission.rb69
-rw-r--r--app/models/status_mute.rb20
-rw-r--r--app/models/user.rb36
23 files changed, 717 insertions, 72 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index 0b3c48543..c7bf7bf80 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -50,6 +50,12 @@
 #  avatar_storage_schema_version :integer
 #  header_storage_schema_version :integer
 #  devices_url                   :string
+#  require_dereference           :boolean          default(FALSE), not null
+#  show_replies                  :boolean          default(TRUE), not null
+#  show_unlisted                 :boolean          default(TRUE), not null
+#  private                       :boolean          default(FALSE), not null
+#  require_auth                  :boolean          default(FALSE), not null
+#  last_synced_at                :datetime
 #
 
 class Account < ApplicationRecord
@@ -115,6 +121,7 @@ class Account < ApplicationRecord
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
+  scope :random, -> { reorder(Arel.sql('RANDOM()')).limit(1) }
 
   delegate :email,
            :unconfirmed_email,
@@ -357,6 +364,38 @@ class Account < ApplicationRecord
     shared_inbox_url.presence || inbox_url
   end
 
+  def max_visibility_for_domain(domain)
+    return 'public' if domain.blank?
+
+    domain_permissions.find_by(domain: [domain, '*'])&.visibility || 'public'
+  end
+
+  def visibility_for_domain(domain)
+    v = visibility.to_s
+    return v if domain.blank?
+
+    case max_visibility_for_domain(domain)
+    when 'public'
+      v
+    when 'unlisted'
+      v == 'public' ? 'unlisted' : v
+    when 'private'
+      %w(public unlisted).include?(v) ? 'private' : v
+    when 'direct'
+      'direct'
+    else
+      v != 'direct' ? 'limited' : 'direct'
+    end
+  end
+
+  def public_domain_permissions?
+    domain_permissions.where(visibility: [:public, :unlisted]).exists?
+  end
+
+  def private_domain_permissions?
+    domain_permissions.where(visibility: [:private, :direct, :limited]).exists?
+  end
+
   class Field < ActiveModelSerializers::Model
     attributes :name, :value, :verified_at, :account, :errors
 
@@ -525,6 +564,8 @@ class Account < ApplicationRecord
   before_validation :prepare_username, on: :create
   before_destroy :clean_feed_manager
 
+  after_create_commit :set_metadata, if: :local?
+
   private
 
   def prepare_contents
@@ -568,4 +609,8 @@ class Account < ApplicationRecord
       end
     end
   end
+
+  def set_metadata
+    self.metadata = AccountMetadata.new(account_id: id, fields: {}) if metadata.nil?
+  end
 end
diff --git a/app/models/account_domain_permission.rb b/app/models/account_domain_permission.rb
new file mode 100644
index 000000000..9e77950f2
--- /dev/null
+++ b/app/models/account_domain_permission.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_domain_permissions
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  domain     :string           default(""), not null
+#  visibility :integer          default("public"), not null
+#  sticky     :boolean          default(FALSE), not null
+#
+
+class AccountDomainPermission < ApplicationRecord
+  include Paginable
+  include Cacheable
+
+  validates :domain, presence: true, uniqueness: { scope: :account_id }
+  validates :visibility, presence: true
+
+  belongs_to :account, inverse_of: :domain_permissions
+  enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
+
+  default_scope { order(domain: :desc) }
+
+  cache_associated :account
+
+  class << self
+    def create_by_domains(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create
+      end
+    end
+
+    def create_by_domains!(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create!
+      end
+    end
+
+    def create_or_update(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s)
+      else
+        create(**domain_permissions)
+      end
+      permissions
+    end
+
+    def create_or_update!(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update!(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s)
+      else
+        create!(**domain_permissions)
+      end
+      permissions
+    end
+
+    private
+
+    def normalize(hash)
+      hash.symbolize_keys!
+      hash[:domain] = hash[:domain].strip.downcase
+      hash.compact
+    end
+  end
+end
diff --git a/app/models/account_metadata.rb b/app/models/account_metadata.rb
new file mode 100644
index 000000000..bb0f7676e
--- /dev/null
+++ b/app/models/account_metadata.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_metadata
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  fields     :jsonb            not null
+#
+
+class AccountMetadata < ApplicationRecord
+  include Cacheable
+
+  belongs_to :account, inverse_of: :metadata
+  cache_associated :account
+
+  def fields
+    self[:fields].presence || {}
+  end
+
+  def fields_json
+    fields.select { |name, _| name.start_with?('custom:') }
+          .map do |name, value|
+            {
+              '@context': {
+                schema: 'http://schema.org/',
+                name: 'schema:name',
+                value: 'schema:value',
+              },
+              type: 'PropertyValue',
+              name: name,
+              value: value.is_a?(Array) ? value.join("\r\n") : value,
+            }
+          end
+  end
+
+  def cached_fields_json
+    Rails.cache.fetch("custom_metadata:#{account_id}", expires_in: 1.hour) do
+      fields_json
+    end
+  end
+
+  class << self
+    def create_or_update(fields)
+      create(fields).presence || update(fields)
+    end
+
+    def create_or_update!(fields)
+      create(fields).presence || update!(fields)
+    end
+  end
+end
diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb
new file mode 100644
index 000000000..24aaf66d4
--- /dev/null
+++ b/app/models/collection_item.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: collection_items
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  uri        :string           not null
+#  processed  :boolean          default(FALSE), not null
+#  retries    :integer          default(0), not null
+#
+
+class CollectionItem < ApplicationRecord
+  belongs_to :account, inverse_of: :collection_items, optional: true
+
+  default_scope { order(id: :desc) }
+  scope :unprocessed, -> { where(processed: false) }
+  scope :joins_on_collection_pages, -> { joins('LEFT OUTER JOIN collection_pages ON collection_pages.account_id = collection_items.account_id') }
+  scope :inactive, -> { joins_on_collection_pages.where('collection_pages.account_id IS NULL') }
+  scope :active, -> { joins_on_collection_pages.where('collection_pages.account_id IS NOT NULL') }
+end
diff --git a/app/models/collection_page.rb b/app/models/collection_page.rb
new file mode 100644
index 000000000..e974e58a2
--- /dev/null
+++ b/app/models/collection_page.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: collection_pages
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  uri        :string           not null
+#  next       :string
+#
+
+class CollectionPage < ApplicationRecord
+  belongs_to :account, inverse_of: :collection_pages, optional: true
+
+  default_scope { order(id: :desc) }
+  scope :current, -> { where(next: nil) }
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index cca3a17fa..a8b024346 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -60,5 +60,23 @@ module AccountAssociations
     # Hashtags
     has_and_belongs_to_many :tags
     has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
+
+    # Threads
+    has_many :threads, class_name: 'Conversation', inverse_of: :account, dependent: :nullify
+
+    # Domain permissions
+    has_many :domain_permissions, class_name: 'AccountDomainPermission', inverse_of: :account, dependent: :destroy
+
+    # Custom metadata
+    has_one :metadata, class_name: 'AccountMetadata', inverse_of: :account, dependent: :destroy
+
+    # Queued boosts
+    has_many :queued_boosts, inverse_of: :account, dependent: :destroy
+
+    # Collection pages
+    has_many :collection_pages, inverse_of: :account, dependent: :destroy
+
+    # Collection items
+    has_many :collection_items, inverse_of: :account, dependent: :destroy
   end
 end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index be7211f2c..538e92f41 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -25,7 +25,7 @@ module AccountInteractions
     end
 
     def muting_map(target_account_ids, account_id)
-      Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
+      Mute.where(target_account_id: target_account_ids, account_id: account_id, timelines_only: false).each_with_object({}) do |mute, mapping|
         mapping[mute.target_account_id] = {
           notifications: mute.hide_notifications?,
         }
@@ -90,9 +90,10 @@ module AccountInteractions
     has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
     has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy
     has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
-    has_many :conversation_mutes, dependent: :destroy
+    has_many :conversation_mutes, inverse_of: :account, dependent: :destroy
     has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
     has_many :announcement_mutes, dependent: :destroy
+    has_many :status_mutes, inverse_of: :account, dependent: :destroy
   end
 
   def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
@@ -125,21 +126,22 @@ module AccountInteractions
                        .find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account, notifications: nil)
+  def mute!(other_account, notifications: nil, timelines_only: nil)
     notifications = true if notifications.nil?
-    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
+    timelines_only = false if timelines_only.nil?
+    mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_create_by!(target_account: other_account)
     remove_potential_friendship(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
+    mute.update!(hide_notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? != notifications
 
     mute
   end
 
-  def mute_conversation!(conversation)
-    conversation_mutes.find_or_create_by!(conversation: conversation)
+  def mute_conversation!(conversation, hidden: false)
+    mute = conversation_mutes.find_or_create_by!(conversation: conversation)
+    mute.update(hidden: hidden) if hidden.present? && mute.hidden? != hidden
+    mute
   end
 
   def block_domain!(other_domain)
@@ -171,6 +173,15 @@ module AccountInteractions
     block&.destroy
   end
 
+  def mute_status!(status)
+    status_mutes.find_or_create_by!(status: status)
+  end
+
+  def unmute_status!(status)
+    mute = status_mutes.find_by(status: status)
+    mute&.destroy
+  end
+
   def following?(other_account)
     active_relationships.where(target_account: other_account).exists?
   end
@@ -184,13 +195,17 @@ module AccountInteractions
   end
 
   def muting?(other_account)
-    mute_relationships.where(target_account: other_account).exists?
+    mute_relationships.where(target_account: other_account, timelines_only: false).exists?
   end
 
   def muting_conversation?(conversation)
     conversation_mutes.where(conversation: conversation).exists?
   end
 
+  def hiding_conversation?(conversation)
+    conversation_mutes.where(conversation: conversation, hidden: true).exists?
+  end
+
   def muting_notifications?(other_account)
     mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
   end
@@ -199,6 +214,10 @@ module AccountInteractions
     active_relationships.where(target_account: other_account, show_reblogs: false).exists?
   end
 
+  def muting_status?(status)
+    status_mutes.where(status: status).exists?
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index a0ead1995..50d081811 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -86,7 +86,7 @@ module StatusThreadingConcern
     domains     = statuses.map(&:account_domain).compact.uniq
     relations   = relations_map_for_account(account, account_ids, domains)
 
-    statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? }
+    statuses.reject! { |status| StatusFilter.new(status, account, false, relations).filtered? }
 
     # Order ancestors/descendants by tree path
     statuses.sort_by! { |status| ids.index(status.id) }
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 4dfaea889..e065c34c8 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -7,12 +7,17 @@
 #  uri        :string
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  account_id :bigint(8)
+#  public     :boolean          default(FALSE), not null
+#  root       :string
 #
 
 class Conversation < ApplicationRecord
   validates :uri, uniqueness: true, if: :uri?
 
   has_many :statuses
+  has_many :mutes, class_name: 'ConversationMute', inverse_of: :conversation, dependent: :destroy
+  belongs_to :account, inverse_of: :threads, optional: true
 
   def local?
     uri.nil?
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
index 52c1a33e0..5d56a3172 100644
--- a/app/models/conversation_mute.rb
+++ b/app/models/conversation_mute.rb
@@ -6,9 +6,10 @@
 #  id              :bigint(8)        not null, primary key
 #  conversation_id :bigint(8)        not null
 #  account_id      :bigint(8)        not null
+#  hidden          :boolean          default(FALSE), not null
 #
 
 class ConversationMute < ApplicationRecord
-  belongs_to :account
-  belongs_to :conversation
+  belongs_to :account, inverse_of: :conversation_mutes
+  belongs_to :conversation, inverse_of: :mutes
 end
diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb
index 5fe0e3a29..70f559f49 100644
--- a/app/models/domain_allow.rb
+++ b/app/models/domain_allow.rb
@@ -8,10 +8,12 @@
 #  domain     :string           default(""), not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  hidden     :boolean          default(FALSE), not null
 #
 
 class DomainAllow < ApplicationRecord
   include DomainNormalizable
+  include Paginable
 
   validates :domain, presence: true, uniqueness: true, domain: true
 
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 2b18e01fa..743e21a29 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -16,6 +16,7 @@
 
 class DomainBlock < ApplicationRecord
   include DomainNormalizable
+  include Paginable
 
   enum severity: [:silence, :suspend, :noop]
 
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 3325e264c..cdf0f4bda 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -29,7 +29,10 @@ class FollowRequest < ApplicationRecord
 
   def authorize!
     account.follow!(target_account, reblogs: show_reblogs, uri: uri)
-    MergeWorker.perform_async(target_account.id, account.id) if account.local?
+    if account.local?
+      MergeWorker.perform_async(target_account.id, account.id)
+      ActivityPub::SyncAccountWorker.perform_async(target_account.id, every_page: true) unless target_account.local?
+    end
     destroy!
   end
 
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index fcec3e686..e36974519 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -4,6 +4,8 @@ class Form::AdminSettings
   include ActiveModel::Model
 
   KEYS = %i(
+    show_domain_allows
+
     site_contact_username
     site_contact_email
     site_title
@@ -76,6 +78,8 @@ class Form::AdminSettings
 
   attr_accessor(*KEYS)
 
+  validates :show_domain_allows, inclusion: { in: %w(disabled users all) }
+
   validates :site_short_description, :site_description, html: { wrap_with: :p }
   validates :site_extended_description, :site_terms, :closed_registrations_message, html: true
   validates :registrations_mode, inclusion: { in: %w(open approved none) }
diff --git a/app/models/inline_media_attachment.rb b/app/models/inline_media_attachment.rb
new file mode 100644
index 000000000..faa8ca1ac
--- /dev/null
+++ b/app/models/inline_media_attachment.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: inline_media_attachments
+#
+#  id                  :bigint(8)        not null, primary key
+#  status_id           :bigint(8)
+#  media_attachment_id :bigint(8)
+#
+
+class InlineMediaAttachment < ApplicationRecord
+  include Cacheable
+
+  validates :status_id, uniqueness: { scope: :media_attachment_id }
+
+  belongs_to :status, inverse_of: :inlined_attachments
+  belongs_to :media_attachment, inverse_of: :inlines
+
+  cache_associated :status, :media_attachment
+end
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 29d25eae8..4695b4ebb 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -35,7 +35,7 @@ class Invite < ApplicationRecord
 
   def set_code
     loop do
-      self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join
+      self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(16).join
       break if Invite.find_by(code: code).nil?
     end
   end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index cc81b648c..a1fe76589 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -26,6 +26,7 @@
 #  thumbnail_file_size         :integer
 #  thumbnail_updated_at        :datetime
 #  thumbnail_remote_url        :string
+#  inline                      :boolean          default(FALSE), not null
 #
 
 class MediaAttachment < ApplicationRecord
@@ -34,7 +35,7 @@ class MediaAttachment < ApplicationRecord
   enum type: [:image, :gifv, :video, :unknown, :audio]
   enum processing: [:queued, :in_progress, :complete, :failed], _prefix: true
 
-  MAX_DESCRIPTION_LENGTH = 1_500
+  MAX_DESCRIPTION_LENGTH = 2_000
 
   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
@@ -59,12 +60,12 @@ class MediaAttachment < ApplicationRecord
 
   IMAGE_STYLES = {
     original: {
-      pixels: 1_638_400, # 1280x1280px
+      pixels: 16_777_216, # 4096x4096px
       file_geometry_parser: FastGeometryParser,
     }.freeze,
 
     small: {
-      pixels: 160_000, # 400x400px
+      pixels: 250_000, # 500x500px
       file_geometry_parser: FastGeometryParser,
       blurhash: BLURHASH_OPTIONS,
     }.freeze,
@@ -81,8 +82,8 @@ class MediaAttachment < ApplicationRecord
         'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
         'vsync' => 'cfr',
         'c:v' => 'h264',
-        'maxrate' => '1300K',
-        'bufsize' => '1300K',
+        'maxrate' => '2M',
+        'bufsize' => '2M',
         'frames:v' => 60 * 60 * 3,
         'crf' => 18,
         'map_metadata' => '-1',
@@ -112,7 +113,7 @@ class MediaAttachment < ApplicationRecord
       convert_options: {
         output: {
           'loglevel' => 'fatal',
-          vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+          vf: 'scale=\'min(500\, iw):min(500\, ih)\':force_original_aspect_ratio=decrease',
         }.freeze,
       }.freeze,
       format: 'png',
@@ -131,7 +132,7 @@ class MediaAttachment < ApplicationRecord
       convert_options: {
         output: {
           'loglevel' => 'fatal',
-          'q:a' => 2,
+          'q:a' => 0,
         }.freeze,
       }.freeze,
     }.freeze,
@@ -147,7 +148,7 @@ class MediaAttachment < ApplicationRecord
   }.freeze
 
   GLOBAL_CONVERT_OPTIONS = {
-    all: '-quality 90 -strip +set modify-date +set create-date',
+    all: '-quality 95 -strip +set modify-date +set create-date',
   }.freeze
 
   IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i
@@ -160,6 +161,8 @@ class MediaAttachment < ApplicationRecord
   belongs_to :status,           inverse_of: :media_attachments, optional: true
   belongs_to :scheduled_status, inverse_of: :media_attachments, optional: true
 
+  has_many :inlines, class_name: 'InlineMediaAttachment', inverse_of: :media_attachment, dependent: :destroy
+
   has_attached_file :file,
                     styles: ->(f) { file_styles f },
                     processors: ->(f) { file_processors f },
@@ -189,13 +192,16 @@ class MediaAttachment < ApplicationRecord
   validates :file, presence: true, if: :local?
   validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
 
-  scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
-  scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
-  scope :local,      -> { where(remote_url: '') }
-  scope :remote,     -> { where.not(remote_url: '') }
+  scope :attached,   -> { all_media.where.not(status_id: nil).or(all_media.where.not(scheduled_status_id: nil)) }
+  scope :unattached, -> { all_media.where(status_id: nil, scheduled_status_id: nil) }
+  scope :uninlined,  -> { where(inline: false) }
+  scope :inlined,    -> { rewhere(inline: true) }
+  scope :all_media,  -> { unscope(where: :inline) }
+  scope :local,      -> { all_media.where(remote_url: '') }
+  scope :remote,     -> { all_media.where.not(remote_url: '') }
   scope :cached,     -> { remote.where.not(file_file_name: nil) }
 
-  default_scope { order(id: :asc) }
+  default_scope { uninlined.order(id: :asc) }
 
   def local?
     remote_url.blank?
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 639120f7d..11f833d8e 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -9,6 +9,7 @@
 #  hide_notifications :boolean          default(TRUE), not null
 #  account_id         :bigint(8)        not null
 #  target_account_id  :bigint(8)        not null
+#  timelines_only     :boolean          default(FALSE), not null
 #
 
 class Mute < ApplicationRecord
diff --git a/app/models/queued_boost.rb b/app/models/queued_boost.rb
new file mode 100644
index 000000000..6eca3725f
--- /dev/null
+++ b/app/models/queued_boost.rb
@@ -0,0 +1,15 @@
+# == Schema Information
+#
+# Table name: queued_boosts
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
+#
+
+class QueuedBoost < ApplicationRecord
+  belongs_to :account, inverse_of: :queued_boosts
+  belongs_to :status, inverse_of: :queued_boosts
+
+  validates :account_id, uniqueness: { scope: :status_id }
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 594ae98c0..3d524dec5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -21,13 +21,23 @@
 #  account_id             :bigint(8)        not null
 #  application_id         :bigint(8)
 #  in_reply_to_account_id :bigint(8)
-#  local_only             :boolean
-#  full_status_text       :text             default(""), not null
+#  local_only             :boolean          default(FALSE), not null
 #  poll_id                :bigint(8)
 #  content_type           :string
 #  deleted_at             :datetime
+#  edited                 :integer          default(0), not null
+#  nest_level             :integer          default(0), not null
+#  published              :boolean          default(TRUE), not null
+#  title                  :text
+#  semiprivate            :boolean          default(FALSE), not null
+#  original_text          :text
+#  footer                 :text
+#  expires_at             :datetime
+#  publish_at             :datetime
+#  originally_local_only  :boolean          default(FALSE), not null
 #
 
+# rubocop:disable Metrics/ClassLength
 class Status < ApplicationRecord
   before_destroy :unlink_from_conversations
 
@@ -52,7 +62,7 @@ class Status < ApplicationRecord
   belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
 
   belongs_to :account, inverse_of: :statuses
-  belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true
+  belongs_to :in_reply_to_account, class_name: 'Account', optional: true
   belongs_to :conversation, optional: true
   belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true
 
@@ -65,8 +75,15 @@ class Status < ApplicationRecord
   has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy, inverse_of: :status
   has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
+  has_many :silent_mentions, -> { silent }, class_name: 'Mention', inverse_of: :status
   has_many :media_attachments, dependent: :nullify
 
+  has_many :inlined_attachments, class_name: 'InlineMediaAttachment', inverse_of: :status, dependent: :destroy
+  has_many :mutes, class_name: 'StatusMute', inverse_of: :status, dependent: :destroy
+  belongs_to :conversation_mute, primary_key: 'conversation_id', foreign_key: 'conversation_id', inverse_of: :conversation, dependent: :destroy, optional: true
+  has_many :domain_permissions, class_name: 'StatusDomainPermission', inverse_of: :status, dependent: :destroy
+  has_many :queued_boosts, inverse_of: :status, dependent: :destroy
+
   has_and_belongs_to_many :tags
   has_and_belongs_to_many :preview_cards
 
@@ -93,7 +110,8 @@ class Status < ApplicationRecord
   scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
   scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
-  scope :with_public_visibility, -> { where(visibility: :public) }
+  scope :with_public_visibility, -> { where(visibility: :public, published: true) }
+  scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) }
   scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
@@ -113,6 +131,22 @@ class Status < ApplicationRecord
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
 
+  scope :including_unpublished, -> { unscope(where: :published) }
+  scope :unpublished, -> { rewhere(published: false) }
+  scope :published, -> { where(published: true) }
+  scope :without_semiprivate, -> { where(semiprivate: false) }
+  scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') }
+  scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) }
+  scope :conversations_by, ->(account) { joins(:conversation).where(conversations: { account: account }) }
+  scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) }
+  scope :replies, -> { where(reply: true).where('statuses.in_reply_to_account_id != statuses.account_id') }
+  scope :expired, -> { published.where('statuses.expires_at IS NOT NULL AND statuses.expires_at < ?', Time.now.utc) }
+  scope :ready_to_publish, -> { unpublished.where('statuses.publish_at IS NOT NULL AND statuses.publish_at < ?', Time.now.utc) }
+
+  scope :not_hidden_by_account, ->(account) do
+    left_outer_joins(:mutes, :conversation_mute).where('(status_mutes.account_id IS NULL OR status_mutes.account_id != ?) AND (conversation_mutes.account_id IS NULL OR (conversation_mutes.account_id != ? AND conversation_mutes.hidden = TRUE))', account.id, account.id)
+  end
+
   cache_associated :application,
                    :media_attachments,
                    :conversation,
@@ -136,8 +170,20 @@ class Status < ApplicationRecord
                    thread: { account: :account_stat }
 
   delegate :domain, to: :account, prefix: true
+  delegate :max_visibility_for_domain, to: :account
 
   REAL_TIME_WINDOW = 6.hours
+  SORTED_VISIBILITY = {
+    direct: 0,
+    limited: 1,
+    private: 2,
+    unlisted: 3,
+    public: 4,
+  }.with_indifferent_access.freeze
+  TIMER_VALUES = [
+    0, 1, 2, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 4320, 7200,
+    10_080, 20_160, 30_240, 60_480, 120_960, 181_440, 241_920, 362_880, 524_160
+  ].freeze
 
   def searchable_by(preloaded = nil)
     ids = []
@@ -204,7 +250,7 @@ class Status < ApplicationRecord
   end
 
   def hidden?
-    !distributable?
+    !published? || !distributable?
   end
 
   def distributable?
@@ -228,7 +274,7 @@ class Status < ApplicationRecord
   def emojis
     return @emojis if defined?(@emojis)
 
-    fields  = [spoiler_text, text]
+    fields  = [spoiler_text, text, footer || '']
     fields += preloadable_poll.options unless preloadable_poll.nil?
 
     @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
@@ -262,24 +308,99 @@ class Status < ApplicationRecord
     update_status_stat!(key => [public_send(key) - 1, 0].max)
   end
 
+  def notify=(value)
+    Redis.current.set("status:#{id}:notify", value ? 1 : 0, ex: 1.hour)
+    @notify = value
+  end
+
+  def notify
+    return @notify if defined?(@notify)
+
+    value = Redis.current.get("status:#{id}:notify")
+    @notify = value.nil? ? true : value.to_i == 1
+  end
+
+  alias notify? notify
+
+  def less_private_than?(other_visibility)
+    return false if other_visibility.blank?
+
+    SORTED_VISIBILITY[visibility] > SORTED_VISIBILITY[other_visibility]
+  end
+
+  def more_private_than?(other_visibility)
+    return false if other_visibility.blank?
+
+    SORTED_VISIBILITY[visibility] < SORTED_VISIBILITY[other_visibility]
+  end
+
+  def visibility_for_domain(domain)
+    return visibility.to_s if domain.blank?
+
+    v = domain_permissions.find_by(domain: [domain, '*'])&.visibility || visibility.to_s
+
+    case max_visibility_for_domain(domain)
+    when 'public'
+      v
+    when 'unlisted'
+      v == 'public' ? 'unlisted' : v
+    when 'private'
+      %w(public unlisted).include?(v) ? 'private' : v
+    when 'direct'
+      'direct'
+    else
+      v != 'direct' ? 'limited' : 'direct'
+    end
+  end
+
+  def public_domain_permissions?
+    return @public_permissions if defined?(@public_permissions)
+    return @public_permissions = false unless account.local?
+
+    @public_permissions = domain_permissions.where(visibility: [:public, :unlisted]).exists?
+  end
+
+  def private_domain_permissions?
+    return @private_permissions if defined?(@private_permissions)
+    return @private_permissions = false unless account.local?
+
+    @private_permissions = domain_permissions.where(visibility: [:private, :direct, :limited]).exists?
+  end
+
+  def should_be_semiprivate?
+    return @should_be_semiprivate if defined?(@should_be_semiprivate)
+    return @should_be_semiprivate = true if distributable? && (private_domain_permissions? || account.private_domain_permissions?)
+
+    @should_be_semiprivate = !distributable? && (public_domain_permissions? || account.public_domain_permissions?)
+  end
+
+  def should_limit_visibility?
+    less_private_than?(thread&.visibility)
+  end
+
   after_create_commit  :increment_counter_caches
   after_destroy_commit :decrement_counter_caches
 
   after_create_commit :store_uri, if: :local?
+  after_create_commit :store_url, if: :local?
   after_create_commit :update_statistics, if: :local?
 
   around_create Mastodon::Snowflake::Callbacks
 
   before_create :set_locality
+  before_create :set_nest_level
 
   before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
-  before_validation :set_visibility
-  before_validation :set_conversation
+  before_validation :set_conversation_perms
   before_validation :set_local
 
   after_create :set_poll_id
 
+  after_save :set_domain_permissions, if: :local?
+  after_save :set_semiprivate, if: :local?
+  after_save :set_conversation_root
+
   class << self
     def selectable_visibilities
       visibilities.keys - %w(direct limited)
@@ -338,7 +459,7 @@ class Status < ApplicationRecord
     end
 
     def as_tag_timeline(tag, account = nil, local_only = false)
-      query = timeline_scope(local_only).tagged_with(tag)
+      query = timeline_scope(local_only, include_unlisted: true).tagged_with(tag)
 
       apply_timeline_filters(query, account, local_only)
     end
@@ -363,6 +484,14 @@ class Status < ApplicationRecord
       ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
     end
 
+    def hidden_conversations_map(conversation_ids, account_id)
+      ConversationMute.select('conversation_id').where(conversation_id: conversation_ids, hidden: true).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
+    end
+
+    def hidden_statuses_map(status_ids, account_id)
+      StatusMute.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.status_id] = true }
+    end
+
     def pins_map(status_ids, account_id)
       StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
     end
@@ -379,7 +508,7 @@ class Status < ApplicationRecord
 
       return if account_ids.empty?
 
-      accounts = Account.where(id: account_ids).includes(:account_stat).each_with_object({}) { |a, h| h[a.id] = a }
+      accounts = Account.where(id: account_ids).includes(:account_stat).index_by(&:id)
 
       cached_items.each do |item|
         item.account = accounts[item.account_id]
@@ -387,26 +516,26 @@ class Status < ApplicationRecord
       end
     end
 
-    def permitted_for(target_account, account)
+    def permitted_for(target_account, account, **options)
       visibility = [:public, :unlisted]
 
-      if account.nil?
-        where(visibility: visibility).not_local_only
-      elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps
-        none
-      elsif account.id == target_account.id # author can see own stuff
-        all
-      else
-        # followers can see followers-only stuff, but also things they are mentioned in.
-        # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in.
+      if account.present?
+        return none if target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain))
+        return apply_category_filters(all, target_account, account, **options) if account.id == target_account.id
+
         visibility.push(:private) if account.following?(target_account)
+      end
 
-        scope = left_outer_joins(:reblog)
+      visibility = :public if options[:public] || (account.blank? && !target_account.show_unlisted?)
 
-        scope.where(visibility: visibility)
-             .or(scope.where(id: account.mentions.select(:status_id)))
-             .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids })))
-      end
+      scope = where(visibility: visibility)
+      apply_category_filters(scope, target_account, account, **options)
+    end
+
+    def mentions_between(account, target_account)
+      return none if account.blank? || target_account.blank?
+
+      account.statuses.mentioning_account(target_account).or(target_account.statuses.mentioning_account(account))
     end
 
     def from_text(text)
@@ -426,21 +555,75 @@ class Status < ApplicationRecord
 
     private
 
-    def timeline_scope(scope = false)
+    # TODO: Cast cleanup spell.
+    # rubocop:disable Metrics/PerceivedComplexity
+    def apply_category_filters(query, target_account, account, **options)
+      options[:without_account_filters] ||= target_account.id == account&.id
+      query = apply_account_filters(query, account, **options)
+      return query if options[:without_category_filters]
+
+      query = query.published unless options[:include_unpublished]
+      query = query.without_semiprivate unless options[:include_semiprivate]
+
+      if options[:only_reblogs]
+        query = query.joins(:reblog)
+        if account.present? && account.excluded_from_timeline_account_ids.present?
+          query = query.where.not(
+            reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }
+          )
+        end
+      elsif target_account.id == account&.id
+        query = query.without_replies unless options[:include_replies] || options[:only_replies]
+        query = query.without_reblogs unless options[:include_reblogs] || options[:only_reblogs]
+        query = query.reblogs if options[:only_reblogs]
+        query = query.replies if options[:only_replies]
+      else
+        if options[:include_reblogs] && account.present? && account.excluded_from_timeline_account_ids.present?
+          query = query.left_outer_joins(:reblog).where(
+            '(statuses.reblog_of_id IS NULL OR reblogs_statuses.account_id NOT IN (?))',
+            account.excluded_from_timeline_account_ids
+          )
+        elsif !options[:include_reblogs]
+          query = query.without_reblogs
+        end
+
+        query = if options[:include_replies]
+                  query = query.replies if options[:only_replies]
+                  query.conversations_by(target_account)
+                else
+                  query.without_replies
+                end
+      end
+
+      return query if options[:tag].blank?
+
+      (tag = Tag.find_normalized(options[:tag])) ? query.merge(Status.tagged_with(tag.id)) : none
+    end
+    # rubocop:enable Metrics/PerceivedComplexity
+
+    def apply_account_filters(query, account, **options)
+      return query.not_local_only if account.blank?
+      return (!options[:exclude_local_only] && account.local? ? query : query.not_local_only) if options[:without_account_filters]
+
+      query = query.not_local_only unless !options[:exclude_local_only] && account.local?
+      query = query.not_hidden_by_account(account)
+      query = query.in_chosen_languages(account) if account.chosen_languages.present?
+      query
+    end
+
+    def timeline_scope(scope = false, include_unlisted: false)
       starting_scope = case scope
                        when :local, true
                          Status.local
                        when :remote
                          Status.remote
+                       when :local_reblogs
+                         Status.locally_reblogged
                        else
                          Status
                        end
-      starting_scope = starting_scope.with_public_visibility
-      if Setting.show_reblogs_in_public_timelines
-        starting_scope
-      else
-        starting_scope.without_reblogs
-      end
+      starting_scope = include_unlisted ? starting_scope.distributable : starting_scope.with_public_visibility
+      scope != :local_reblogs ? starting_scope.without_reblogs : starting_scope
     end
 
     def apply_timeline_filters(query, account, local_only)
@@ -455,6 +638,7 @@ class Status < ApplicationRecord
       query = query.not_excluded_by_account(account)
       query = query.not_domain_blocked_by_account(account) unless local_only
       query = query.in_chosen_languages(account) if account.chosen_languages.present?
+      query = query.not_hidden_by_account(account)
       query.merge(account_silencing_filter(account))
     end
 
@@ -497,9 +681,15 @@ class Status < ApplicationRecord
     update_column(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil?
   end
 
+  def store_url
+    update_column(:url, ActivityPub::TagManager.instance.url_for(self)) if url.nil?
+  end
+
   def prepare_contents
     text&.strip!
     spoiler_text&.strip!
+    title&.strip!
+    language&.gsub!('en-MP', 'en')
   end
 
   def set_reblog
@@ -510,31 +700,38 @@ class Status < ApplicationRecord
     update_column(:poll_id, poll.id) unless poll.nil?
   end
 
-  def set_visibility
-    self.visibility = reblog.visibility if reblog? && visibility.nil?
-    self.visibility = (account.locked? ? :private : :public) if visibility.nil?
-    self.sensitive  = false if sensitive.nil?
-  end
-
   def set_locality
     if account.domain.nil? && !attribute_changed?(:local_only)
-      self.local_only = marked_local_only?
+      self.local_only = true if marked_local_only?
     end
+    self.local_only = true if thread&.local_only? && local_only.nil?
+    self.local_only = reblog.local_only if reblog?
+
+    self.originally_local_only = local_only if attribute_changed?(:local_only) && !attribute_changed?(:originally_local_only)
   end
 
-  def set_conversation
+  def set_conversation_perms
     self.thread = thread.reblog if thread&.reblog?
-
     self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
+    self.visibility = reblog.visibility if reblog? && visibility.nil?
+    self.visibility = (account.locked? ? :private : :public) if visibility.nil?
+    self.visibility = thread.visibility if should_limit_visibility?
+    self.sensitive  = false if sensitive.nil?
 
     if reply? && !thread.nil?
       self.in_reply_to_account_id = carried_over_reply_to_account_id
       self.conversation_id        = thread.conversation_id if conversation_id.nil?
     elsif conversation_id.nil?
-      self.conversation = Conversation.new
+      self.conversation = reply? ? Conversation.new(account_id: nil) : Conversation.new(account_id: account_id)
+    elsif !reply? && account_id != conversation.account_id
+      conversation.update!(account_id: account_id)
     end
   end
 
+  def set_conversation_root
+    conversation.update!(root: uri, account_id: account_id) if !reply && conversation.root.blank?
+  end
+
   def carried_over_reply_to_account_id
     if thread.account_id == account_id && thread.reply?
       thread.in_reply_to_account_id
@@ -547,6 +744,32 @@ class Status < ApplicationRecord
     self.local = account.local?
   end
 
+  def set_nest_level
+    return if attribute_changed?(:nest_level)
+
+    self.nest_level = if reply?
+                        [thread&.account_id == account_id ? thread&.nest_level.to_i : thread&.nest_level.to_i + 1, 127].min
+                      else
+                        0
+                      end
+  end
+
+  def set_domain_permissions
+    return unless saved_change_to_visibility?
+
+    domain_permissions.transaction do
+      existing_domains = domain_permissions.select(:domain)
+      permissions = account.domain_permissions.where.not(domain: existing_domains)
+      permissions.find_each do |permission|
+        domain_permissions.create!(domain: permission.domain, visibility: permission.visibility) if less_private_than?(permission.visibility)
+      end
+    end
+  end
+
+  def set_semiprivate
+    update_column(:semiprivate, should_be_semiprivate?) if semiprivate != should_be_semiprivate?
+  end
+
   def update_statistics
     return unless distributable?
 
@@ -580,3 +803,4 @@ class Status < ApplicationRecord
     end
   end
 end
+# rubocop:enable Metrics/ClassLength
diff --git a/app/models/status_domain_permission.rb b/app/models/status_domain_permission.rb
new file mode 100644
index 000000000..be767a2b6
--- /dev/null
+++ b/app/models/status_domain_permission.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_domain_permissions
+#
+#  id         :bigint(8)        not null, primary key
+#  status_id  :bigint(8)        not null
+#  domain     :string           default(""), not null
+#  visibility :integer          default("public"), not null
+#
+
+class StatusDomainPermission < ApplicationRecord
+  include Paginable
+  include Cacheable
+
+  validates :domain, presence: true, uniqueness: { scope: :status_id }
+  validates :visibility, presence: true
+
+  belongs_to :status, inverse_of: :domain_permissions
+  enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
+
+  default_scope { order(domain: :desc) }
+
+  cache_associated :status
+
+  class << self
+    def create_by_domains(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create
+      end
+    end
+
+    def create_by_domains!(permissions_list)
+      Array(permissions_list).map(&method(:normalize)).map do |permissions|
+        where(**permissions).first_or_create!
+      end
+    end
+
+    def create_or_update(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update(**domain_permissions)
+      else
+        create(**domain_permissions)
+      end
+      permissions
+    end
+
+    def create_or_update!(domain_permissions)
+      domain_permissions = normalize(domain_permissions)
+      permissions = find_by(domain: domain_permissions[:domain])
+      if permissions.present?
+        permissions.update!(**domain_permissions)
+      else
+        create!(**domain_permissions)
+      end
+      permissions
+    end
+
+    private
+
+    def normalize(hash)
+      hash.symbolize_keys!
+      hash[:domain] = hash[:domain].strip.downcase
+      hash.compact
+    end
+  end
+end
diff --git a/app/models/status_mute.rb b/app/models/status_mute.rb
new file mode 100644
index 000000000..1e01f0278
--- /dev/null
+++ b/app/models/status_mute.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_mutes
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
+#
+
+class StatusMute < ApplicationRecord
+  include Cacheable
+
+  validates :account_id, uniqueness: { scope: :status_id }
+
+  belongs_to :account, inverse_of: :status_mutes
+  belongs_to :status, inverse_of: :mutes
+
+  cache_associated :account, :status
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 4467362e1..6fa6bb369 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,6 +41,8 @@
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
+#  username                  :string
+#  kobold                    :string
 #
 
 class User < ApplicationRecord
@@ -89,7 +91,7 @@ class User < ApplicationRecord
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 
   scope :recent, -> { order(id: :desc) }
-  scope :pending, -> { where(approved: false) }
+  scope :pending, -> { where(approved: false).where.not(kobold: '') }
   scope :approved, -> { where(approved: true) }
   scope :confirmed, -> { where.not(confirmed_at: nil) }
   scope :enabled, -> { where(disabled: false) }
@@ -116,6 +118,12 @@ class User < ApplicationRecord
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
            :default_content_type, :system_emoji_font,
+           :manual_publish, :style_dashed_nest, :style_underline_a, :style_css_profile,
+           :style_css_profile_errors, :style_css_webapp, :style_css_webapp_errors,
+           :style_wide_media,
+           :publish_in, :unpublish_in, :unpublish_delete, :boost_every, :boost_jitter,
+           :boost_random,
+           :filter_to_unknown, :filter_from_unknown, :unpublish_on_delete,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_reader :invite_code, :sign_in_token_attempt
@@ -149,7 +157,7 @@ class User < ApplicationRecord
 
     if new_user && approved?
       prepare_new_user!
-    elsif new_user
+    elsif new_user && user_might_not_be_a_spam_bot
       notify_staff_about_pending_account!
     end
   end
@@ -307,6 +315,17 @@ class User < ApplicationRecord
     super
   end
 
+  def send_confirmation_instructions
+    unless approved? || user_might_not_be_a_spam_bot
+      invite_request&.destroy
+      account&.destroy
+      destroy
+      return false
+    end
+
+    super
+  end
+
   def reset_password!(new_password, new_password_confirmation)
     return false if encrypted_password.blank?
 
@@ -433,4 +452,17 @@ class User < ApplicationRecord
   def validate_email_dns?
     email_changed? && !(Rails.env.test? || Rails.env.development?)
   end
+
+  def user_might_not_be_a_spam_bot
+    username == account.username && invite_request&.text.present? && kobold_hash_matches?
+  end
+
+  def kobold_hash_matches?
+    kobold.present? && kobold == kobold_hash
+  end
+
+  def kobold_hash
+    value = [account.username, username.downcase, email, invite_request.text].compact.map(&:downcase).join("\u{F0666}")
+    Digest::SHA512.hexdigest(value).upcase
+  end
 end