about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2023-04-14 19:22:47 -0500
committerStarfall <us@starfall.systems>2023-04-14 19:22:47 -0500
commit4fe1689de43f4404eb9530fcfbcbfb26d6c1c13a (patch)
tree6811b845bb7f4966b10dcefa3dea404246f161c7 /app/models
parent65c1e53a32cabcdbb7bca57002bb0f6acdebe07e (diff)
parentbed63f6dae0879ac840066b031229e0d139089cd (diff)
Merge remote-tracking branch 'glitch/main' HEAD main
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb38
-rw-r--r--app/models/account/field.rb16
-rw-r--r--app/models/account_conversation.rb2
-rw-r--r--app/models/account_domain_block.rb1
-rw-r--r--app/models/account_filter.rb24
-rw-r--r--app/models/account_moderation_note.rb1
-rw-r--r--app/models/account_note.rb1
-rw-r--r--app/models/account_pin.rb1
-rw-r--r--app/models/account_stat.rb3
-rw-r--r--app/models/account_statuses_cleanup_policy.rb8
-rw-r--r--app/models/account_suggestions/setting_source.rb4
-rw-r--r--app/models/account_summary.rb1
-rw-r--r--app/models/account_warning.rb21
-rw-r--r--app/models/admin/account_action.rb12
-rw-r--r--app/models/admin/action_log.rb2
-rw-r--r--app/models/admin/import.rb44
-rw-r--r--app/models/admin/status_batch_action.rb9
-rw-r--r--app/models/announcement.rb14
-rw-r--r--app/models/backup.rb5
-rw-r--r--app/models/block.rb1
-rw-r--r--app/models/bookmark.rb1
-rw-r--r--app/models/canonical_email_block.rb1
-rw-r--r--app/models/concerns/account_finder_concern.rb6
-rw-r--r--app/models/concerns/account_interactions.rb23
-rw-r--r--app/models/concerns/account_merging.rb16
-rw-r--r--app/models/concerns/attachmentable.rb2
-rw-r--r--app/models/concerns/expireable.rb2
-rw-r--r--app/models/concerns/has_user_settings.rb173
-rw-r--r--app/models/concerns/ldap_authenticable.rb2
-rw-r--r--app/models/concerns/omniauthable.rb9
-rw-r--r--app/models/concerns/paginable.rb4
-rw-r--r--app/models/concerns/pam_authenticable.rb12
-rw-r--r--app/models/concerns/remotable.rb4
-rw-r--r--app/models/concerns/status_threading_concern.rb14
-rw-r--r--app/models/conversation.rb1
-rw-r--r--app/models/conversation_mute.rb1
-rw-r--r--app/models/custom_emoji.rb1
-rw-r--r--app/models/custom_filter.rb11
-rw-r--r--app/models/custom_filter_keyword.rb1
-rw-r--r--app/models/custom_filter_status.rb1
-rw-r--r--app/models/device.rb1
-rw-r--r--app/models/direct_feed.rb9
-rw-r--r--app/models/domain_block.rb3
-rw-r--r--app/models/email_domain_block.rb15
-rw-r--r--app/models/encrypted_message.rb1
-rw-r--r--app/models/favourite.rb2
-rw-r--r--app/models/featured_tag.rb1
-rw-r--r--app/models/follow.rb1
-rw-r--r--app/models/follow_recommendation.rb3
-rw-r--r--app/models/follow_recommendation_suppression.rb5
-rw-r--r--app/models/follow_request.rb1
-rw-r--r--app/models/form/account_batch.rb16
-rw-r--r--app/models/form/admin_settings.rb17
-rw-r--r--app/models/form/custom_emoji_batch.rb12
-rw-r--r--app/models/identity.rb1
-rw-r--r--app/models/import.rb3
-rw-r--r--app/models/instance.rb1
-rw-r--r--app/models/invite.rb1
-rw-r--r--app/models/ip_block.rb1
-rw-r--r--app/models/list.rb3
-rw-r--r--app/models/list_account.rb1
-rw-r--r--app/models/login_activity.rb1
-rw-r--r--app/models/media_attachment.rb17
-rw-r--r--app/models/mention.rb1
-rw-r--r--app/models/mute.rb1
-rw-r--r--app/models/notification.rb23
-rw-r--r--app/models/one_time_key.rb1
-rw-r--r--app/models/poll.rb8
-rw-r--r--app/models/poll_vote.rb2
-rw-r--r--app/models/preview_card.rb9
-rw-r--r--app/models/preview_card_provider.rb1
-rw-r--r--app/models/relay.rb3
-rw-r--r--app/models/remote_follow.rb12
-rw-r--r--app/models/report.rb5
-rw-r--r--app/models/report_note.rb1
-rw-r--r--app/models/session_activation.rb4
-rw-r--r--app/models/setting.rb7
-rw-r--r--app/models/site_upload.rb1
-rw-r--r--app/models/status.rb62
-rw-r--r--app/models/status_edit.rb18
-rw-r--r--app/models/status_pin.rb1
-rw-r--r--app/models/status_stat.rb1
-rw-r--r--app/models/system_key.rb2
-rw-r--r--app/models/tag.rb3
-rw-r--r--app/models/trends/history.rb4
-rw-r--r--app/models/trends/links.rb24
-rw-r--r--app/models/trends/statuses.rb26
-rw-r--r--app/models/trends/tag_filter.rb12
-rw-r--r--app/models/trends/tags.rb12
-rw-r--r--app/models/unavailable_domain.rb1
-rw-r--r--app/models/user.rb105
-rw-r--r--app/models/user_ip.rb3
-rw-r--r--app/models/user_role.rb2
-rw-r--r--app/models/user_settings.rb106
-rw-r--r--app/models/user_settings/dsl.rb37
-rw-r--r--app/models/user_settings/glue.rb23
-rw-r--r--app/models/user_settings/namespace.rb21
-rw-r--r--app/models/user_settings/setting.rb49
-rw-r--r--app/models/web/push_subscription.rb25
-rw-r--r--app/models/web/setting.rb1
-rw-r--r--app/models/webauthn_credential.rb3
-rw-r--r--app/models/webhook.rb3
102 files changed, 862 insertions, 370 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index 64a35aca0..215f8ce09 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: accounts
@@ -52,7 +53,7 @@
 #
 
 class Account < ApplicationRecord
-  self.ignored_columns = %w(
+  self.ignored_columns += %w(
     subscription_expires_at
     secret
     remote_url
@@ -82,14 +83,14 @@ class Account < ApplicationRecord
   MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
   DEFAULT_FIELDS_SIZE = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i
 
-  enum protocol: [:ostatus, :activitypub]
-  enum suspension_origin: [:local, :remote], _prefix: true
+  enum protocol: { ostatus: 0, activitypub: 1 }
+  enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
 
   validates :username, presence: true
   validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
 
-  # Remote user validations
-  validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { !local? && will_save_change_to_username? }
+  # Remote user validations, also applies to internal actors
+  validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
 
   # Local user validations
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
@@ -111,7 +112,7 @@ class Account < ApplicationRecord
   scope :bots, -> { where(actor_type: %w(Application Service)) }
   scope :groups, -> { where(actor_type: 'Group') }
   scope :alphabetic, -> { order(domain: :asc, username: :asc) }
-  scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
+  scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
   scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) }
@@ -125,6 +126,8 @@ class Account < ApplicationRecord
   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))) }
 
+  after_update_commit :trigger_update_webhooks
+
   delegate :email,
            :unconfirmed_email,
            :current_sign_in_at,
@@ -313,9 +316,7 @@ class Account < ApplicationRecord
 
         previous = old_fields.find { |item| item['value'] == attr[:value] }
 
-        if previous && previous['verified_at'].present?
-          attr[:verified_at] = previous['verified_at']
-        end
+        attr[:verified_at] = previous['verified_at'] if previous && previous['verified_at'].present?
 
         fields << attr
       end
@@ -457,13 +458,12 @@ class Account < ApplicationRecord
       return [] if text.blank?
 
       text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.filter_map do |(username, domain)|
-        domain = begin
-          if TagManager.instance.local_domain?(domain)
-            nil
-          else
-            TagManager.instance.normalize_domain(domain)
-          end
-        end
+        domain = if TagManager.instance.local_domain?(domain)
+                   nil
+                 else
+                   TagManager.instance.normalize_domain(domain)
+                 end
+
         EntityCache.instance.mention(username, domain)
       end
     end
@@ -538,6 +538,7 @@ class Account < ApplicationRecord
 
   def ensure_keys!
     return unless local? && private_key.blank? && public_key.blank?
+
     generate_keys
     save!
   end
@@ -590,4 +591,9 @@ class Account < ApplicationRecord
 
     CanonicalEmailBlock.where(reference_account: self).delete_all
   end
+
+  # NOTE: the `account.created` webhook is triggered by the `User` model, not `Account`.
+  def trigger_update_webhooks
+    TriggerWebhookWorker.perform_async('account.updated', 'Account', id) if local?
+  end
 end
diff --git a/app/models/account/field.rb b/app/models/account/field.rb
index 4db4cac30..2bada6954 100644
--- a/app/models/account/field.rb
+++ b/app/models/account/field.rb
@@ -14,8 +14,8 @@ class Account::Field < ActiveModelSerializers::Model
     @account        = account
 
     super(
-      name:        sanitize(attributes['name']),
-      value:       sanitize(attributes['value']),
+      name: sanitize(attributes['name']),
+      value: sanitize(attributes['value']),
       verified_at: attributes['verified_at']&.to_datetime,
     )
   end
@@ -25,13 +25,11 @@ class Account::Field < ActiveModelSerializers::Model
   end
 
   def value_for_verification
-    @value_for_verification ||= begin
-      if account.local?
-        value
-      else
-        extract_url_from_html
-      end
-    end
+    @value_for_verification ||= if account.local?
+                                  value
+                                else
+                                  extract_url_from_html
+                                end
   end
 
   def verifiable?
diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb
index 45e74bbeb..b3ddc04c1 100644
--- a/app/models/account_conversation.rb
+++ b/app/models/account_conversation.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_conversations
@@ -107,6 +108,7 @@ class AccountConversation < ApplicationRecord
 
   def push_to_streaming_api
     return if destroyed? || !subscribed_to_timeline?
+
     PushConversationWorker.perform_async(id)
   end
 
diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb
index 3aaffde9a..af1e6a68d 100644
--- a/app/models/account_domain_block.rb
+++ b/app/models/account_domain_block.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_domain_blocks
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index d27bb46fc..1666ea883 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -17,13 +17,13 @@ class AccountFilter
   attr_reader :params
 
   def initialize(params)
-    @params = params
+    @params = params.to_h.symbolize_keys
   end
 
   def results
     scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil)
 
-    params.each do |key, value|
+    relevant_params.each do |key, value|
       next if key.to_s == 'page'
 
       scope.merge!(scope_for(key, value)) if value.present?
@@ -34,6 +34,16 @@ class AccountFilter
 
   private
 
+  def relevant_params
+    params.tap do |args|
+      args.delete(:origin) if origin_is_remote_and_domain_present?
+    end
+  end
+
+  def origin_is_remote_and_domain_present?
+    params[:origin] == 'remote' && params[:by_domain].present?
+  end
+
   def scope_for(key, value)
     case key.to_s
     when 'origin'
@@ -94,7 +104,15 @@ class AccountFilter
   def order_scope(value)
     case value.to_s
     when 'active'
-      accounts_with_users.left_joins(:account_stat).order(Arel.sql('coalesce(users.current_sign_in_at, account_stats.last_status_at, to_timestamp(0)) desc, accounts.id desc'))
+      accounts_with_users
+        .left_joins(:account_stat)
+        .order(
+          Arel.sql(
+            <<~SQL.squish
+              COALESCE(users.current_sign_in_at, account_stats.last_status_at, to_timestamp(0)) DESC, accounts.id DESC
+            SQL
+          )
+        )
     when 'recent'
       Account.recent
     else
diff --git a/app/models/account_moderation_note.rb b/app/models/account_moderation_note.rb
index 22e312bb2..ff399bab0 100644
--- a/app/models/account_moderation_note.rb
+++ b/app/models/account_moderation_note.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_moderation_notes
diff --git a/app/models/account_note.rb b/app/models/account_note.rb
index b338bc92f..9bc704d98 100644
--- a/app/models/account_note.rb
+++ b/app/models/account_note.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_notes
diff --git a/app/models/account_pin.rb b/app/models/account_pin.rb
index b51d3d4cd..6c78e8c44 100644
--- a/app/models/account_pin.rb
+++ b/app/models/account_pin.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_pins
diff --git a/app/models/account_stat.rb b/app/models/account_stat.rb
index a5d71a5b8..0fea7732e 100644
--- a/app/models/account_stat.rb
+++ b/app/models/account_stat.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_stats
@@ -15,7 +16,7 @@
 
 class AccountStat < ApplicationRecord
   self.locking_column = nil
-  self.ignored_columns = %w(lock_version)
+  self.ignored_columns += %w(lock_version)
 
   belongs_to :account, inverse_of: :account_stat
 
diff --git a/app/models/account_statuses_cleanup_policy.rb b/app/models/account_statuses_cleanup_policy.rb
index 49adc6ad0..14ce00abb 100644
--- a/app/models/account_statuses_cleanup_policy.rb
+++ b/app/models/account_statuses_cleanup_policy.rb
@@ -122,9 +122,7 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
       # may need to be deleted, so we'll have to start again.
       redis.del("account_cleanup:#{account.id}")
     end
-    if EXCEPTION_THRESHOLDS.map { |name| attribute_change_to_be_saved(name) }.compact.any? { |old, new| old.present? && (new.nil? || new > old) }
-      redis.del("account_cleanup:#{account.id}")
-    end
+    redis.del("account_cleanup:#{account.id}") if EXCEPTION_THRESHOLDS.map { |name| attribute_change_to_be_saved(name) }.compact.any? { |old, new| old.present? && (new.nil? || new > old) }
   end
 
   def validate_local_account
@@ -141,9 +139,7 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
     # has switched to snowflake IDs significantly over 2 years ago anyway.
     snowflake_id = Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)
 
-    if max_id.nil? || snowflake_id < max_id
-      max_id = snowflake_id
-    end
+    max_id = snowflake_id if max_id.nil? || snowflake_id < max_id
 
     Status.where(Status.arel_table[:id].lteq(max_id))
   end
diff --git a/app/models/account_suggestions/setting_source.rb b/app/models/account_suggestions/setting_source.rb
index be9eff233..7b8873e0c 100644
--- a/app/models/account_suggestions/setting_source.rb
+++ b/app/models/account_suggestions/setting_source.rb
@@ -54,7 +54,7 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
 
       next if username.blank?
 
-      [username, domain]
+      [username.downcase, domain&.downcase]
     end.compact
   end
 
@@ -63,6 +63,6 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
   end
 
   def to_ordered_list_key(account)
-    [account.username, account.domain]
+    [account.username.downcase, account.domain&.downcase]
   end
 end
diff --git a/app/models/account_summary.rb b/app/models/account_summary.rb
index 3a3cebc55..0d8835b83 100644
--- a/app/models/account_summary.rb
+++ b/app/models/account_summary.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_summaries
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
index 961a078b9..4f8cc5320 100644
--- a/app/models/account_warning.rb
+++ b/app/models/account_warning.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: account_warnings
@@ -17,15 +18,17 @@
 
 class AccountWarning < ApplicationRecord
   enum action: {
-    none:                       0,
-    disable:                    1_000,
+    none: 0,
+    disable: 1_000,
     mark_statuses_as_sensitive: 1_250,
-    delete_statuses:            1_500,
-    sensitive:                  2_000,
-    silence:                    3_000,
-    suspend:                    4_000,
+    delete_statuses: 1_500,
+    sensitive: 2_000,
+    silence: 3_000,
+    suspend: 4_000,
   }, _suffix: :action
 
+  before_validation :before_validate
+
   belongs_to :account, inverse_of: :account_warnings
   belongs_to :target_account, class_name: 'Account', inverse_of: :strikes
   belongs_to :report, optional: true
@@ -47,4 +50,10 @@ class AccountWarning < ApplicationRecord
   def to_log_human_identifier
     target_account.acct
   end
+
+  private
+
+  def before_validate
+    self.text = '' if text.blank?
+  end
 end
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index bce0d6e17..1ce28f5c8 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -166,13 +166,11 @@ class Admin::AccountAction
   end
 
   def reports
-    @reports ||= begin
-      if type == 'none'
-        with_report? ? [report] : []
-      else
-        Report.where(target_account: target_account).unresolved
-      end
-    end
+    @reports ||= if type == 'none'
+                   with_report? ? [report] : []
+                 else
+                   Report.where(target_account: target_account).unresolved
+                 end
   end
 
   def warning_preset
diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb
index 4fa8008f5..f2c121d75 100644
--- a/app/models/admin/action_log.rb
+++ b/app/models/admin/action_log.rb
@@ -17,7 +17,7 @@
 #
 
 class Admin::ActionLog < ApplicationRecord
-  self.ignored_columns = %w(
+  self.ignored_columns += %w(
     recorded_changes
   )
 
diff --git a/app/models/admin/import.rb b/app/models/admin/import.rb
index 79c0722d5..0fd4bdb82 100644
--- a/app/models/admin/import.rb
+++ b/app/models/admin/import.rb
@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require 'csv'
+
 # A non-activerecord helper class for csv upload
 class Admin::Import
   include ActiveModel::Model
@@ -15,17 +17,47 @@ class Admin::Import
     data.original_filename
   end
 
+  def csv_rows
+    csv_data.rewind
+
+    csv_data.take(ROWS_PROCESSING_LIMIT + 1)
+  end
+
   private
 
-  def validate_data
-    return if data.blank?
+  def csv_data
+    return @csv_data if defined?(@csv_data)
 
-    csv_data = CSV.read(data.path, encoding: 'UTF-8')
+    csv_converter = lambda do |field, field_info|
+      case field_info.header
+      when '#domain', '#public_comment'
+        field&.strip
+      when '#severity'
+        field&.strip&.to_sym
+      when '#reject_media', '#reject_reports', '#obfuscate'
+        ActiveModel::Type::Boolean.new.cast(field)
+      else
+        field
+      end
+    end
 
-    row_count  = csv_data.size
-    row_count -= 1 if csv_data.first&.first == '#domain'
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
+    @csv_data.take(1) # Ensure the headers are read
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: ['#domain'], converters: csv_converter) unless @csv_data.headers&.first == '#domain'
+    @csv_data
+  end
+
+  def csv_row_count
+    return @csv_row_count if defined?(@csv_row_count)
+
+    csv_data.rewind
+    @csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
+  end
+
+  def validate_data
+    return if data.nil?
 
-    errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if row_count > ROWS_PROCESSING_LIMIT
+    errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
   rescue CSV::MalformedCSVError => e
     errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
   end
diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb
index 39cd7d0eb..b8bdec722 100644
--- a/app/models/admin/status_batch_action.rb
+++ b/app/models/admin/status_batch_action.rb
@@ -6,7 +6,8 @@ class Admin::StatusBatchAction
   include Authorization
 
   attr_accessor :current_account, :type,
-                :status_ids, :report_id
+                :status_ids, :report_id,
+                :text
 
   attr_reader :send_email_notification
 
@@ -57,7 +58,8 @@ class Admin::StatusBatchAction
         action: :delete_statuses,
         account: current_account,
         report: report,
-        status_ids: status_ids
+        status_ids: status_ids,
+        text: text
       )
 
       statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
@@ -95,7 +97,8 @@ class Admin::StatusBatchAction
       action: :mark_statuses_as_sensitive,
       account: current_account,
       report: report,
-      status_ids: status_ids
+      status_ids: status_ids,
+      text: text
     )
 
     UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
index 4b2cb4c6d..339f5ae70 100644
--- a/app/models/announcement.rb
+++ b/app/models/announcement.rb
@@ -20,7 +20,7 @@
 class Announcement < ApplicationRecord
   scope :unpublished, -> { where(published: false) }
   scope :published, -> { where(published: true) }
-  scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where('announcement_mutes.id IS NULL') }
+  scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where(announcement_mutes: { id: nil }) }
   scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) ASC')) }
   scope :reverse_chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) DESC')) }
 
@@ -54,13 +54,11 @@ class Announcement < ApplicationRecord
   end
 
   def statuses
-    @statuses ||= begin
-      if status_ids.nil?
-        []
-      else
-        Status.where(id: status_ids, visibility: [:public, :unlisted])
-      end
-    end
+    @statuses ||= if status_ids.nil?
+                    []
+                  else
+                    Status.where(id: status_ids, visibility: [:public, :unlisted])
+                  end
   end
 
   def tags
diff --git a/app/models/backup.rb b/app/models/backup.rb
index d242fd62c..5feb31d7d 100644
--- a/app/models/backup.rb
+++ b/app/models/backup.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: backups
@@ -17,6 +18,6 @@
 class Backup < ApplicationRecord
   belongs_to :user, inverse_of: :backups
 
-  has_attached_file :dump
-  do_not_validate_attachment_file_type :dump
+  has_attached_file :dump, s3_permissions: ->(*) { ENV['S3_PERMISSION'] == '' ? nil : 'private' }
+  validates_attachment_content_type :dump, content_type: /\Aapplication/
 end
diff --git a/app/models/block.rb b/app/models/block.rb
index bf3e07600..b42c1569b 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: blocks
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
index 6334ef0df..04b660372 100644
--- a/app/models/bookmark.rb
+++ b/app/models/bookmark.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: bookmarks
diff --git a/app/models/canonical_email_block.rb b/app/models/canonical_email_block.rb
index 1eb69ac67..d09df6f5e 100644
--- a/app/models/canonical_email_block.rb
+++ b/app/models/canonical_email_block.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: canonical_email_blocks
diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb
index e8b804934..37c3b8895 100644
--- a/app/models/concerns/account_finder_concern.rb
+++ b/app/models/concerns/account_finder_concern.rb
@@ -13,9 +13,11 @@ module AccountFinderConcern
     end
 
     def representative
-      Account.find(-99).tap(&:ensure_keys!)
+      actor = Account.find(-99).tap(&:ensure_keys!)
+      actor.update!(username: 'mastodon.internal') if actor.username.include?(':')
+      actor
     rescue ActiveRecord::RecordNotFound
-      Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain)
+      Account.create!(id: -99, actor_type: 'Application', locked: true, username: 'mastodon.internal')
     end
 
     def find_local(username)
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index de8bf338f..48ab1349d 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -151,9 +151,7 @@ module AccountInteractions
     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) if mute.hide_notifications? != notifications
 
     mute
   end
@@ -280,7 +278,7 @@ module AccountInteractions
       followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(url_prefix)}/%", false, true)).or(followers.where(uri: url_prefix)).pluck_each(:uri) do |uri|
         Xorcist.xor!(digest, Digest::SHA256.digest(uri))
       end
-      digest.unpack('H*')[0]
+      digest.unpack1('H*')
     end
   end
 
@@ -290,10 +288,25 @@ module AccountInteractions
       followers.where(domain: nil).pluck_each(:username) do |username|
         Xorcist.xor!(digest, Digest::SHA256.digest(ActivityPub::TagManager.instance.uri_for_username(username)))
       end
-      digest.unpack('H*')[0]
+      digest.unpack1('H*')
     end
   end
 
+  def relations_map(account_ids, domains = nil, **options)
+    relations = {
+      blocked_by: Account.blocked_by_map(account_ids, id),
+      following: Account.following_map(account_ids, id),
+    }
+
+    return relations if options[:skip_blocking_and_muting]
+
+    relations.merge!({
+      blocking: Account.blocking_map(account_ids, id),
+      muting: Account.muting_map(account_ids, id),
+      domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, id),
+    })
+  end
+
   private
 
   def remove_potential_friendship(other_account)
diff --git a/app/models/concerns/account_merging.rb b/app/models/concerns/account_merging.rb
index 8161761fb..41071633d 100644
--- a/app/models/concerns/account_merging.rb
+++ b/app/models/concerns/account_merging.rb
@@ -21,11 +21,9 @@ module AccountMerging
 
     owned_classes.each do |klass|
       klass.where(account_id: other_account.id).find_each do |record|
-        begin
-          record.update_attribute(:account_id, id)
-        rescue ActiveRecord::RecordNotUnique
-          next
-        end
+        record.update_attribute(:account_id, id)
+      rescue ActiveRecord::RecordNotUnique
+        next
       end
     end
 
@@ -36,11 +34,9 @@ module AccountMerging
 
     target_classes.each do |klass|
       klass.where(target_account_id: other_account.id).find_each do |record|
-        begin
-          record.update_attribute(:target_account_id, id)
-        rescue ActiveRecord::RecordNotUnique
-          next
-        end
+        record.update_attribute(:target_account_id, id)
+      rescue ActiveRecord::RecordNotUnique
+        next
       end
     end
 
diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb
index 01fae4236..d44c22438 100644
--- a/app/models/concerns/attachmentable.rb
+++ b/app/models/concerns/attachmentable.rb
@@ -5,7 +5,7 @@ require 'mime/types/columnar'
 module Attachmentable
   extend ActiveSupport::Concern
 
-  MAX_MATRIX_LIMIT = 16_777_216 # 4096x4096px or approx. 16MB
+  MAX_MATRIX_LIMIT = 33_177_600 # 7680x4320px or approx. 847MB in RAM
   GIF_MATRIX_LIMIT = 921_600    # 1280x720px
 
   # For some file extensions, there exist different content
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
index 4d902abcb..c64fc7d80 100644
--- a/app/models/concerns/expireable.rb
+++ b/app/models/concerns/expireable.rb
@@ -17,7 +17,7 @@ module Expireable
     end
 
     def expires_in=(interval)
-      self.expires_at = interval.present? ? interval.to_i.seconds.from_now : nil 
+      self.expires_at = interval.present? ? interval.to_i.seconds.from_now : nil
       @expires_in     = interval
     end
 
diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb
new file mode 100644
index 000000000..0e9d4e1cd
--- /dev/null
+++ b/app/models/concerns/has_user_settings.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+module HasUserSettings
+  extend ActiveSupport::Concern
+
+  included do
+    serialize :settings, UserSettingsSerializer
+  end
+
+  def settings_attributes=(attributes)
+    settings.update(attributes)
+  end
+
+  def prefers_noindex?
+    settings['noindex']
+  end
+
+  def preferred_posting_language
+    valid_locale_cascade(settings['default_language'], locale, I18n.locale)
+  end
+
+  def setting_auto_play_gif
+    settings['web.auto_play']
+  end
+
+  def setting_default_sensitive
+    settings['default_sensitive']
+  end
+
+  def setting_unfollow_modal
+    settings['web.unfollow_modal']
+  end
+
+  def setting_boost_modal
+    settings['web.reblog_modal']
+  end
+
+  def setting_delete_modal
+    settings['web.delete_modal']
+  end
+
+  def setting_favourite_modal
+    settings['web.favourite_modal']
+  end
+
+  def setting_reduce_motion
+    settings['web.reduce_motion']
+  end
+
+  def setting_system_font_ui
+    settings['web.use_system_font']
+  end
+
+  def setting_system_emoji_font
+    settings['web.use_system_emoji_font']
+  end
+
+  def setting_noindex
+    settings['noindex']
+  end
+
+  def setting_flavour
+    settings['flavour']
+  end
+
+  def setting_skin
+    settings['skin']
+  end
+
+  def setting_display_media
+    settings['web.display_media']
+  end
+
+  def setting_expand_spoilers
+    settings['web.expand_content_warnings']
+  end
+
+  def setting_default_language
+    settings['default_language']
+  end
+
+  def setting_aggregate_reblogs
+    settings['aggregate_reblogs']
+  end
+
+  def setting_show_application
+    settings['show_application']
+  end
+
+  def setting_advanced_layout
+    settings['web.advanced_layout']
+  end
+
+  def setting_use_blurhash
+    settings['web.use_blurhash']
+  end
+
+  def setting_use_pending_items
+    settings['web.use_pending_items']
+  end
+
+  def setting_trends
+    settings['web.trends']
+  end
+
+  def setting_crop_images
+    settings['web.crop_images']
+  end
+
+  def setting_disable_swiping
+    settings['web.disable_swiping']
+  end
+
+  def setting_always_send_emails
+    settings['always_send_emails']
+  end
+
+  def setting_default_privacy
+    settings['default_privacy'] || (account.locked? ? 'private' : 'public')
+  end
+
+  def setting_default_content_type
+    settings['default_content_type']
+  end
+
+  def setting_hide_followers_count
+    settings['hide_followers_count']
+  end
+
+  def allows_report_emails?
+    settings['notification_emails.report']
+  end
+
+  def allows_pending_account_emails?
+    settings['notification_emails.pending_account']
+  end
+
+  def allows_appeal_emails?
+    settings['notification_emails.appeal']
+  end
+
+  def allows_trends_review_emails?
+    settings['notification_emails.trends']
+  end
+
+  def allows_trending_tags_review_emails?
+    settings['notification_emails.trends']
+  end
+
+  def allows_trending_links_review_emails?
+    settings['notification_emails.link_trends']
+  end
+
+  def allows_trending_statuses_review_emails?
+    settings['notification_emails.status_trends']
+  end
+
+  def aggregates_reblogs?
+    settings['aggregate_reblogs']
+  end
+
+  def shows_application?
+    settings['show_application']
+  end
+
+  def show_all_media?
+    settings['web.display_media'] == 'show_all'
+  end
+
+  def hide_all_media?
+    settings['web.display_media'] == 'hide_all'
+  end
+end
diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb
index dc5abcd5a..775df0817 100644
--- a/app/models/concerns/ldap_authenticable.rb
+++ b/app/models/concerns/ldap_authenticable.rb
@@ -6,7 +6,7 @@ module LdapAuthenticable
   class_methods do
     def authenticate_with_ldap(params = {})
       ldap   = Net::LDAP.new(ldap_options)
-      filter = format(Devise.ldap_search_filter, uid: Devise.ldap_uid, mail: Devise.ldap_mail, email: params[:email])
+      filter = format(Devise.ldap_search_filter, uid: Devise.ldap_uid, mail: Devise.ldap_mail, email: Net::LDAP::Filter.escape(params[:email]))
 
       if (user_info = ldap.bind_as(base: Devise.ldap_base, filter: filter, password: params[:password]))
         ldap_get_user(user_info.first)
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
index feac0a1f5..41eae215b 100644
--- a/app/models/concerns/omniauthable.rb
+++ b/app/models/concerns/omniauthable.rb
@@ -56,14 +56,12 @@ module Omniauthable
       user = User.new(user_params_from_auth(email, auth))
 
       begin
-        if /\A#{URI::DEFAULT_PARSER.make_regexp(%w(http https))}\z/.match?(auth.info.image)
-          user.account.avatar_remote_url = auth.info.image
-        end
+        user.account.avatar_remote_url = auth.info.image if /\A#{URI::DEFAULT_PARSER.make_regexp(%w(http https))}\z/.match?(auth.info.image)
       rescue Mastodon::UnexpectedResponseError
         user.account.avatar_remote_url = nil
       end
 
-      user.skip_confirmation! if email_is_verified
+      user.confirm! if email_is_verified
       user.save!
       user
     end
@@ -97,8 +95,7 @@ module Omniauthable
     def ensure_valid_username(starting_username)
       starting_username = starting_username.split('@')[0]
       temp_username = starting_username.gsub(/[^a-z0-9_]+/i, '')
-      validated_username = temp_username.truncate(30, omission: '')
-      validated_username
+      temp_username.truncate(30, omission: '')
     end
   end
 end
diff --git a/app/models/concerns/paginable.rb b/app/models/concerns/paginable.rb
index 62e39f671..b76e78c1e 100644
--- a/app/models/concerns/paginable.rb
+++ b/app/models/concerns/paginable.rb
@@ -4,7 +4,7 @@ module Paginable
   extend ActiveSupport::Concern
 
   included do
-    scope :paginate_by_max_id, ->(limit, max_id = nil, since_id = nil) {
+    scope :paginate_by_max_id, lambda { |limit, max_id = nil, since_id = nil|
       query = order(arel_table[:id].desc).limit(limit)
       query = query.where(arel_table[:id].lt(max_id)) if max_id.present?
       query = query.where(arel_table[:id].gt(since_id)) if since_id.present?
@@ -14,7 +14,7 @@ module Paginable
     # Differs from :paginate_by_max_id in that it gives the results immediately following min_id,
     # whereas since_id gives the items with largest id, but with since_id as a cutoff.
     # Results will be in ascending order by id.
-    scope :paginate_by_min_id, ->(limit, min_id = nil, max_id = nil) {
+    scope :paginate_by_min_id, lambda { |limit, min_id = nil, max_id = nil|
       query = reorder(arel_table[:id]).limit(limit)
       query = query.where(arel_table[:id].gt(min_id)) if min_id.present?
       query = query.where(arel_table[:id].lt(max_id)) if max_id.present?
diff --git a/app/models/concerns/pam_authenticable.rb b/app/models/concerns/pam_authenticable.rb
index 6169d4dfa..f97f986a4 100644
--- a/app/models/concerns/pam_authenticable.rb
+++ b/app/models/concerns/pam_authenticable.rb
@@ -42,13 +42,11 @@ module PamAuthenticable
     def self.pam_get_user(attributes = {})
       return nil unless attributes[:email]
 
-      resource = begin
-        if Devise.check_at_sign && !attributes[:email].index('@')
-          joins(:account).find_by(accounts: { username: attributes[:email] })
-        else
-          find_by(email: attributes[:email])
-        end
-      end
+      resource = if Devise.check_at_sign && !attributes[:email].index('@')
+                   joins(:account).find_by(accounts: { username: attributes[:email] })
+                 else
+                   find_by(email: attributes[:email])
+                 end
 
       if resource.nil?
         resource = new(email: attributes[:email], agreement: true)
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index ffe8a7565..cb8f46e68 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -27,11 +27,11 @@ module Remotable
             public_send("#{attachment_name}=", ResponseWithLimit.new(response, limit))
           end
         rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e
-          Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
+          Rails.logger.debug { "Error fetching remote #{attachment_name}: #{e}" }
           public_send("#{attachment_name}=", nil) if public_send("#{attachment_name}_file_name").present?
           raise e unless suppress_errors
         rescue Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, Paperclip::Error, Mastodon::DimensionsValidationError, Mastodon::StreamValidationError => e
-          Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
+          Rails.logger.debug { "Error fetching remote #{attachment_name}: #{e}" }
           public_send("#{attachment_name}=", nil) if public_send("#{attachment_name}_file_name").present?
         end
 
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index 8b628beea..2ca3b66c2 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -79,7 +79,7 @@ module StatusThreadingConcern
     statuses    = Status.with_accounts(ids).to_a
     account_ids = statuses.map(&:account_id).uniq
     domains     = statuses.filter_map(&:account_domain).uniq
-    relations   = relations_map_for_account(account, account_ids, domains)
+    relations   = account&.relations_map(account_ids, domains) || {}
 
     statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? }
 
@@ -108,16 +108,4 @@ module StatusThreadingConcern
 
     arr
   end
-
-  def relations_map_for_account(account, account_ids, domains)
-    return {} if account.nil?
-
-    {
-      blocking: Account.blocking_map(account_ids, account.id),
-      blocked_by: Account.blocked_by_map(account_ids, account.id),
-      muting: Account.muting_map(account_ids, account.id),
-      following: Account.following_map(account_ids, account.id),
-      domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
-    }
-  end
 end
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 4dfaea889..5de259962 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: conversations
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
index 52c1a33e0..31f8e1966 100644
--- a/app/models/conversation_mute.rb
+++ b/app/models/conversation_mute.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: conversation_mutes
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index fc8d3aed3..b5a07a5a0 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: custom_emojis
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 5a4a974be..0f4fd78cb 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: custom_filters
@@ -14,7 +15,7 @@
 #
 
 class CustomFilter < ApplicationRecord
-  self.ignored_columns = %w(whole_word irreversible)
+  self.ignored_columns += %w(whole_word irreversible)
 
   alias_attribute :title, :phrase
   alias_attribute :filter_action, :action
@@ -30,11 +31,11 @@ class CustomFilter < ApplicationRecord
   include Expireable
   include Redisable
 
-  enum action: [:warn, :hide], _suffix: :action
+  enum action: { warn: 0, hide: 1 }, _suffix: :action
 
   belongs_to :account
-  has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
-  has_many :statuses, class_name: 'CustomFilterStatus', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
+  has_many :keywords, class_name: 'CustomFilterKeyword', inverse_of: :custom_filter, dependent: :destroy
+  has_many :statuses, class_name: 'CustomFilterStatus', inverse_of: :custom_filter, dependent: :destroy
   accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
 
   validates :title, :context, presence: true
@@ -101,6 +102,7 @@ class CustomFilter < ApplicationRecord
       status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present?
 
       next if keyword_matches.blank? && status_matches.blank?
+
       FilterResultPresenter.new(filter: filter, keyword_matches: keyword_matches, status_matches: status_matches)
     end
   end
@@ -111,6 +113,7 @@ class CustomFilter < ApplicationRecord
 
   def invalidate_cache!
     return unless @should_invalidate_cache
+
     @should_invalidate_cache = false
 
     Rails.cache.delete("filters:v3:#{account_id}")
diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb
index e0d0289ae..3158b3b79 100644
--- a/app/models/custom_filter_keyword.rb
+++ b/app/models/custom_filter_keyword.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: custom_filter_keywords
diff --git a/app/models/custom_filter_status.rb b/app/models/custom_filter_status.rb
index e748d6963..0a5650204 100644
--- a/app/models/custom_filter_status.rb
+++ b/app/models/custom_filter_status.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: custom_filter_statuses
diff --git a/app/models/device.rb b/app/models/device.rb
index 97d0d2774..5dc6cf1e6 100644
--- a/app/models/device.rb
+++ b/app/models/device.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: devices
diff --git a/app/models/direct_feed.rb b/app/models/direct_feed.rb
index 1f2448070..689a735b3 100644
--- a/app/models/direct_feed.rb
+++ b/app/models/direct_feed.rb
@@ -4,9 +4,8 @@ class DirectFeed < Feed
   include Redisable
 
   def initialize(account)
-    @type    = :direct
-    @id      = account.id
     @account = account
+    super(:direct, account.id)
   end
 
   def get(limit, max_id = nil, since_id = nil, min_id = nil)
@@ -19,10 +18,12 @@ class DirectFeed < Feed
 
   private
 
-  def from_database(limit, max_id, since_id, min_id)
+  # TODO: _min_id is not actually handled by `as_direct_timeline`
+  def from_database(limit, max_id, since_id, _min_id)
     loop do
-      statuses = Status.as_direct_timeline(@account, limit, max_id, since_id, min_id)
+      statuses = Status.as_direct_timeline(@account, limit, max_id, since_id)
       return statuses if statuses.empty?
+
       max_id = statuses.last.id
       statuses = statuses.reject { |status| FeedManager.instance.filter?(:direct, status, @account) }
       return statuses unless statuses.empty?
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 8e298ac9d..fbb045416 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: domain_blocks
@@ -20,7 +21,7 @@ class DomainBlock < ApplicationRecord
   include DomainNormalizable
   include DomainMaterializable
 
-  enum severity: [:silence, :suspend, :noop]
+  enum severity: { silence: 0, suspend: 1, noop: 2 }
 
   validates :domain, presence: true, uniqueness: true, domain: true
 
diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb
index 10a0e5102..3c9be51ca 100644
--- a/app/models/email_domain_block.rb
+++ b/app/models/email_domain_block.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: email_domain_blocks
@@ -11,7 +12,7 @@
 #
 
 class EmailDomainBlock < ApplicationRecord
-  self.ignored_columns = %w(
+  self.ignored_columns += %w(
     ips
     last_refresh_at
   )
@@ -69,13 +70,11 @@ class EmailDomainBlock < ApplicationRecord
 
     def extract_uris(domain_or_domains)
       Array(domain_or_domains).map do |str|
-        domain = begin
-          if str.include?('@')
-            str.split('@', 2).last
-          else
-            str
-          end
-        end
+        domain = if str.include?('@')
+                   str.split('@', 2).last
+                 else
+                   str
+                 end
 
         Addressable::URI.new.tap { |u| u.host = domain.strip } if domain.present?
       rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
diff --git a/app/models/encrypted_message.rb b/app/models/encrypted_message.rb
index 7b4e32283..3e7e95594 100644
--- a/app/models/encrypted_message.rb
+++ b/app/models/encrypted_message.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: encrypted_messages
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 2f355739a..042f72bea 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: favourites
@@ -38,6 +39,7 @@ class Favourite < ApplicationRecord
 
   def decrement_cache_counters
     return if association(:status).loaded? && status.marked_for_destruction?
+
     status&.decrement_count!(:favourites_count)
   end
 
diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb
index 70f949b6a..587dcf991 100644
--- a/app/models/featured_tag.rb
+++ b/app/models/featured_tag.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: featured_tags
diff --git a/app/models/follow.rb b/app/models/follow.rb
index e5cecbbc1..108f5c5d5 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: follows
diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb
index e552b5a88..602d32985 100644
--- a/app/models/follow_recommendation.rb
+++ b/app/models/follow_recommendation.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: follow_recommendations
@@ -12,7 +13,7 @@ class FollowRecommendation < ApplicationRecord
   self.primary_key = :account_id
 
   belongs_to :account_summary, foreign_key: :account_id
-  belongs_to :account, foreign_key: :account_id
+  belongs_to :account
 
   scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) }
 
diff --git a/app/models/follow_recommendation_suppression.rb b/app/models/follow_recommendation_suppression.rb
index 170506b85..e261a2fe3 100644
--- a/app/models/follow_recommendation_suppression.rb
+++ b/app/models/follow_recommendation_suppression.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: follow_recommendation_suppressions
@@ -19,9 +20,9 @@ class FollowRecommendationSuppression < ApplicationRecord
   private
 
   def remove_follow_recommendations
-    redis.pipelined do
+    redis.pipelined do |pipeline|
       I18n.available_locales.each do |locale|
-        redis.zrem("follow_recommendations:#{locale}", account_id)
+        pipeline.zrem("follow_recommendations:#{locale}", account_id)
       end
     end
   end
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 9034250c0..78f79c18f 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: follow_requests
diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb
index 473622edf..6a05f8163 100644
--- a/app/models/form/account_batch.rb
+++ b/app/models/form/account_batch.rb
@@ -17,8 +17,8 @@ class Form::AccountBatch
       unfollow!
     when 'remove_from_followers'
       remove_from_followers!
-    when 'block_domains'
-      block_domains!
+    when 'remove_domains_from_followers'
+      remove_domains_from_followers!
     when 'approve'
       approve!
     when 'reject'
@@ -35,9 +35,15 @@ class Form::AccountBatch
   private
 
   def follow!
+    error = nil
+
     accounts.each do |target_account|
       FollowService.new.call(current_account, target_account)
+    rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound => e
+      error ||= e
     end
+
+    raise error if error.present?
   end
 
   def unfollow!
@@ -50,10 +56,8 @@ class Form::AccountBatch
     RemoveFromFollowersService.new.call(current_account, account_ids)
   end
 
-  def block_domains!
-    AfterAccountDomainBlockWorker.push_bulk(account_domains) do |domain|
-      [current_account.id, domain]
-    end
+  def remove_domains_from_followers!
+    RemoveDomainsFromFollowersService.new.call(current_account, account_domains)
   end
 
   def account_domains
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index b595529f8..eaee142fa 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -28,6 +28,7 @@ class Form::AdminSettings
     show_reblogs_in_public_timelines
     show_replies_in_public_timelines
     trends
+    trends_as_landing_page
     trendable_by_default
     trending_status_cw
     show_domain_blocks
@@ -39,6 +40,7 @@ class Form::AdminSettings
     media_cache_retention_period
     content_cache_retention_period
     backups_retention_period
+    status_page_url
   ).freeze
 
   INTEGER_KEYS = %i(
@@ -57,6 +59,7 @@ class Form::AdminSettings
     show_reblogs_in_public_timelines
     show_replies_in_public_timelines
     trends
+    trends_as_landing_page
     trendable_by_default
     trending_status_cw
     noindex
@@ -83,19 +86,18 @@ class Form::AdminSettings
   validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) }
   validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
   validates :site_short_description, length: { maximum: 200 }, if: -> { defined?(@site_short_description) }
+  validates :status_page_url, url: true, allow_blank: true
   validate :validate_site_uploads
 
   KEYS.each do |key|
     define_method(key) do
       return instance_variable_get("@#{key}") if instance_variable_defined?("@#{key}")
 
-      stored_value = begin
-        if UPLOAD_KEYS.include?(key)
-          SiteUpload.where(var: key).first_or_initialize(var: key)
-        else
-          Setting.public_send(key)
-        end
-      end
+      stored_value = if UPLOAD_KEYS.include?(key)
+                       SiteUpload.where(var: key).first_or_initialize(var: key)
+                     else
+                       Setting.public_send(key)
+                     end
 
       instance_variable_set("@#{key}", stored_value)
     end
@@ -151,6 +153,7 @@ class Form::AdminSettings
   def validate_site_uploads
     UPLOAD_KEYS.each do |key|
       next unless instance_variable_defined?("@#{key}")
+
       upload = instance_variable_get("@#{key}")
       next if upload.valid?
 
diff --git a/app/models/form/custom_emoji_batch.rb b/app/models/form/custom_emoji_batch.rb
index f4fa84c10..484415f90 100644
--- a/app/models/form/custom_emoji_batch.rb
+++ b/app/models/form/custom_emoji_batch.rb
@@ -36,13 +36,11 @@ class Form::CustomEmojiBatch
   def update!
     custom_emojis.each { |custom_emoji| authorize(custom_emoji, :update?) }
 
-    category = begin
-      if category_id.present?
-        CustomEmojiCategory.find(category_id)
-      elsif category_name.present?
-        CustomEmojiCategory.find_or_create_by!(name: category_name)
-      end
-    end
+    category = if category_id.present?
+                 CustomEmojiCategory.find(category_id)
+               elsif category_name.present?
+                 CustomEmojiCategory.find_or_create_by!(name: category_name)
+               end
 
     custom_emojis.each do |custom_emoji|
       custom_emoji.update(category_id: category&.id)
diff --git a/app/models/identity.rb b/app/models/identity.rb
index 8cc65aef4..6f10fed4d 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: identities
diff --git a/app/models/import.rb b/app/models/import.rb
index 00a54892e..21634005e 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: imports
@@ -24,7 +25,7 @@ class Import < ApplicationRecord
 
   belongs_to :account
 
-  enum type: [:following, :blocking, :muting, :domain_blocking, :bookmarks]
+  enum type: { following: 0, blocking: 1, muting: 2, domain_blocking: 3, bookmarks: 4 }
 
   validates :type, presence: true
   validates_with ImportValidator, on: :create
diff --git a/app/models/instance.rb b/app/models/instance.rb
index edbf02a6d..1f96d3728 100644
--- a/app/models/instance.rb
+++ b/app/models/instance.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: instances
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 7ea4e2f98..8e816cef0 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: invites
diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb
index 31343f0e1..99783050b 100644
--- a/app/models/ip_block.rb
+++ b/app/models/ip_block.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: ip_blocks
diff --git a/app/models/list.rb b/app/models/list.rb
index cdc6ebdb3..bd1bdbd24 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: lists
@@ -16,7 +17,7 @@ class List < ApplicationRecord
 
   PER_ACCOUNT_LIMIT = 50
 
-  enum replies_policy: [:list, :followed, :none], _prefix: :show
+  enum replies_policy: { list: 0, followed: 1, none: 2 }, _prefix: :show
 
   belongs_to :account, optional: true
 
diff --git a/app/models/list_account.rb b/app/models/list_account.rb
index 785923c4c..a5767d3d8 100644
--- a/app/models/list_account.rb
+++ b/app/models/list_account.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: list_accounts
diff --git a/app/models/login_activity.rb b/app/models/login_activity.rb
index 52a0fd01d..2b7b37f8e 100644
--- a/app/models/login_activity.rb
+++ b/app/models/login_activity.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: login_activities
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 4dd3042ab..0367b4af7 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: media_attachments
@@ -33,16 +34,16 @@ class MediaAttachment < ApplicationRecord
 
   include Attachmentable
 
-  enum type: [:image, :gifv, :video, :unknown, :audio]
-  enum processing: [:queued, :in_progress, :complete, :failed], _prefix: true
+  enum type: { :image => 0, :gifv => 1, :video => 2, :unknown => 3, :audio => 4 }
+  enum processing: { :queued => 0, :in_progress => 1, :complete => 2, :failed => 3 }, _prefix: true
 
   MAX_DESCRIPTION_LENGTH = 1_500
 
-  IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i
-  VIDEO_LIMIT = (ENV['MAX_VIDEO_SIZE'] || 40.megabytes).to_i
+  IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 16.megabytes).to_i
+  VIDEO_LIMIT = (ENV['MAX_VIDEO_SIZE'] || 99.megabytes).to_i
 
-  MAX_VIDEO_MATRIX_LIMIT = 2_304_000 # 1920x1200px
-  MAX_VIDEO_FRAME_RATE   = 60
+  MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px
+  MAX_VIDEO_FRAME_RATE   = 120
 
   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze
   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
@@ -68,7 +69,7 @@ class MediaAttachment < ApplicationRecord
 
   IMAGE_STYLES = {
     original: {
-      pixels: 2_073_600, # 1920x1080px
+      pixels: 8_294_400, # 3840x2160px
       file_geometry_parser: FastGeometryParser,
     }.freeze,
 
@@ -372,7 +373,7 @@ class MediaAttachment < ApplicationRecord
     return {} if width.nil?
 
     {
-      width:  width,
+      width: width,
       height: height,
       size: "#{width}x#{height}",
       aspect: width.to_f / height,
diff --git a/app/models/mention.rb b/app/models/mention.rb
index d01a88e32..2348b2905 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: mentions
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 578345ef6..8fc542262 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: mutes
diff --git a/app/models/notification.rb b/app/models/notification.rb
index bbc63c1c0..3eaf557b0 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: notifications
@@ -19,12 +20,12 @@ class Notification < ApplicationRecord
   include Paginable
 
   LEGACY_TYPE_CLASS_MAP = {
-    'Mention'       => :mention,
-    'Status'        => :reblog,
-    'Follow'        => :follow,
+    'Mention' => :mention,
+    'Status' => :reblog,
+    'Follow' => :follow,
     'FollowRequest' => :follow_request,
-    'Favourite'     => :favourite,
-    'Poll'          => :poll,
+    'Favourite' => :favourite,
+    'Poll' => :poll,
   }.freeze
 
   TYPES = %i(
@@ -87,13 +88,11 @@ class Notification < ApplicationRecord
 
   class << self
     def browserable(types: [], exclude_types: [], from_account_id: nil)
-      requested_types = begin
-        if types.empty?
-          TYPES
-        else
-          types.map(&:to_sym) & TYPES
-        end
-      end
+      requested_types = if types.empty?
+                          TYPES
+                        else
+                          types.map(&:to_sym) & TYPES
+                        end
 
       requested_types -= exclude_types.map(&:to_sym)
 
diff --git a/app/models/one_time_key.rb b/app/models/one_time_key.rb
index 8ada34824..23604e2f7 100644
--- a/app/models/one_time_key.rb
+++ b/app/models/one_time_key.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: one_time_keys
diff --git a/app/models/poll.rb b/app/models/poll.rb
index af3b09315..dd35e953b 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: polls
@@ -74,9 +75,9 @@ class Poll < ApplicationRecord
 
     def initialize(poll, id, title, votes_count)
       super(
-        poll:        poll,
-        id:          id,
-        title:       title,
+        poll: poll,
+        id: id,
+        title: title,
         votes_count: votes_count,
       )
     end
@@ -105,6 +106,7 @@ class Poll < ApplicationRecord
 
   def reset_parent_cache
     return if status_id.nil?
+
     Rails.cache.delete("statuses/#{status_id}")
   end
 
diff --git a/app/models/poll_vote.rb b/app/models/poll_vote.rb
index ad24eb691..92d74a6db 100644
--- a/app/models/poll_vote.rb
+++ b/app/models/poll_vote.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: poll_votes
@@ -22,6 +23,7 @@ class PollVote < ApplicationRecord
   after_create_commit :increment_counter_cache
 
   delegate :local?, to: :account
+  delegate :multiple?, :expired?, to: :poll, prefix: true
 
   def object_type
     :vote
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index 56ca62d5e..a738940be 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: preview_cards
@@ -35,7 +36,7 @@ class PreviewCard < ApplicationRecord
   include Attachmentable
 
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
-  LIMIT = 1.megabytes
+  LIMIT = 2.megabytes
 
   BLURHASH_OPTIONS = {
     x_comp: 4,
@@ -44,8 +45,8 @@ class PreviewCard < ApplicationRecord
 
   self.inheritance_column = false
 
-  enum type: [:link, :photo, :video, :rich]
-  enum link_type: [:unknown, :article]
+  enum type: { link: 0, photo: 1, video: 2, rich: 3 }
+  enum link_type: { unknown: 0, article: 1 }
 
   has_and_belongs_to_many :statuses
   has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
@@ -120,7 +121,7 @@ class PreviewCard < ApplicationRecord
     def image_styles(file)
       styles = {
         original: {
-          geometry: '400x400>',
+          pixels: 230_400, # 640x360px
           file_geometry_parser: FastGeometryParser,
           convert_options: '-coalesce',
           blurhash: BLURHASH_OPTIONS,
diff --git a/app/models/preview_card_provider.rb b/app/models/preview_card_provider.rb
index d61fe6020..1dd95fc91 100644
--- a/app/models/preview_card_provider.rb
+++ b/app/models/preview_card_provider.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: preview_card_providers
diff --git a/app/models/relay.rb b/app/models/relay.rb
index c66bfe4ff..a5fa03a99 100644
--- a/app/models/relay.rb
+++ b/app/models/relay.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: relays
@@ -14,7 +15,7 @@
 class Relay < ApplicationRecord
   validates :inbox_url, presence: true, uniqueness: true, url: true, if: :will_save_change_to_inbox_url?
 
-  enum state: [:idle, :pending, :accepted, :rejected]
+  enum state: { idle: 0, pending: 1, accepted: 2, rejected: 3 }
 
   scope :enabled, -> { accepted }
 
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 911c06713..10715ac97 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -36,13 +36,11 @@ class RemoteFollow
 
     username, domain = value.strip.gsub(/\A@/, '').split('@')
 
-    domain = begin
-      if TagManager.instance.local_domain?(domain)
-        nil
-      else
-        TagManager.instance.normalize_domain(domain)
-      end
-    end
+    domain = if TagManager.instance.local_domain?(domain)
+               nil
+             else
+               TagManager.instance.normalize_domain(domain)
+             end
 
     [username, domain].compact.join('@')
   rescue Addressable::URI::InvalidURIError
diff --git a/app/models/report.rb b/app/models/report.rb
index 525d22ad5..c3a0c4c8b 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: reports
@@ -20,7 +21,7 @@
 #
 
 class Report < ApplicationRecord
-  self.ignored_columns = %w(action_taken)
+  self.ignored_columns += %w(action_taken)
 
   include Paginable
   include RateLimitable
@@ -32,7 +33,7 @@ class Report < ApplicationRecord
   belongs_to :action_taken_by_account, class_name: 'Account', optional: true
   belongs_to :assigned_account, class_name: 'Account', optional: true
 
-  has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy
+  has_many :notes, class_name: 'ReportNote', inverse_of: :report, dependent: :destroy
   has_many :notifications, as: :activity, dependent: :destroy
 
   scope :unresolved, -> { where(action_taken_at: nil) }
diff --git a/app/models/report_note.rb b/app/models/report_note.rb
index 6f39df623..8307badd6 100644
--- a/app/models/report_note.rb
+++ b/app/models/report_note.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: report_notes
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 3a59bad93..10c3a6c25 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: session_activations
@@ -51,6 +52,7 @@ class SessionActivation < ApplicationRecord
 
     def deactivate(id)
       return unless id
+
       where(session_id: id).destroy_all
     end
 
@@ -59,7 +61,7 @@ class SessionActivation < ApplicationRecord
     end
 
     def exclusive(id)
-      where('session_id != ?', id).destroy_all
+      where.not(session_id: id).destroy_all
     end
   end
 
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 4bcaa060f..3bdc6ffb4 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: settings
@@ -23,19 +24,19 @@ class Setting < RailsSettings::Base
     def [](key)
       return super(key) unless rails_initialized?
 
-      val = Rails.cache.fetch(cache_key(key, nil)) do
+      Rails.cache.fetch(cache_key(key, nil)) do
         db_val = object(key)
 
         if db_val
           default_value = default_settings[key]
 
           return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash)
+
           db_val.value
         else
           default_settings[key]
         end
       end
-      val
     end
 
     def all_as_records
@@ -44,6 +45,7 @@ class Setting < RailsSettings::Base
 
       default_settings.each do |key, default_value|
         next if records.key?(key) || default_value.is_a?(Hash)
+
         records[key] = Setting.new(var: key, value: default_value)
       end
 
@@ -52,6 +54,7 @@ class Setting < RailsSettings::Base
 
     def default_settings
       return {} unless RailsSettings::Default.enabled?
+
       RailsSettings::Default.instance
     end
   end
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
index 167131fdd..e17668110 100644
--- a/app/models/site_upload.rb
+++ b/app/models/site_upload.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: site_uploads
diff --git a/app/models/status.rb b/app/models/status.rb
index 14b7a39fe..8a58e5d68 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: statuses
@@ -50,12 +51,12 @@ class Status < ApplicationRecord
 
   update_index('statuses', :proper)
 
-  enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
+  enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4 }, _suffix: :visibility
 
   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
 
@@ -97,24 +98,27 @@ class Status < ApplicationRecord
   scope :local,  -> { where(local: true).or(where(uri: nil)) }
   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 :without_reblogs, -> { where(statuses: { reblog_of_id: nil }) }
   scope :with_public_visibility, -> { where(visibility: :public) }
   scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
   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 }) }
   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
-  scope :tagged_with_all, ->(tag_ids) {
+  scope :tagged_with_all, lambda { |tag_ids|
     Array(tag_ids).map(&:to_i).reduce(self) do |result, id|
       result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
     end
   }
-  scope :tagged_with_none, ->(tag_ids) {
+  scope :tagged_with_none, lambda { |tag_ids|
     where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
   }
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
 
+  after_create_commit :trigger_create_webhooks
+  after_update_commit :trigger_update_webhooks
+
   cache_associated :application,
                    :media_attachments,
                    :conversation,
@@ -122,7 +126,7 @@ class Status < ApplicationRecord
                    :tags,
                    :preview_cards,
                    :preloadable_poll,
-                   account: [:account_stat, :user],
+                   account: [:account_stat, user: :role],
                    active_mentions: { account: :account_stat },
                    reblog: [
                      :application,
@@ -132,7 +136,7 @@ class Status < ApplicationRecord
                      :conversation,
                      :status_stat,
                      :preloadable_poll,
-                     account: [:account_stat, :user],
+                     account: [:account_stat, user: :role],
                      active_mentions: { account: :account_stat },
                    ],
                    thread: { account: :account_stat }
@@ -141,6 +145,10 @@ class Status < ApplicationRecord
 
   REAL_TIME_WINDOW = 6.hours
 
+  def cache_key
+    "v2:#{super}"
+  end
+
   def searchable_by(preloaded = nil)
     ids = []
 
@@ -330,7 +338,7 @@ class Status < ApplicationRecord
       visibilities.keys - %w(direct limited)
     end
 
-    def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
+    def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil)
       # direct timeline is mix of direct message from_me and to_me.
       # 2 queries are executed with pagination.
       # constant expression using arel_table is required for partial index
@@ -361,14 +369,9 @@ class Status < ApplicationRecord
         query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
       end
 
-      if cache_ids
-        # returns array of cache_ids object that have id and updated_at
-        (query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
-      else
-        # returns ActiveRecord.Relation
-        items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
-        Status.where(id: items.map(&:id))
-      end
+      # returns ActiveRecord.Relation
+      items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+      Status.where(id: items.map(&:id))
     end
 
     def favourites_map(status_ids, account_id)
@@ -415,13 +418,12 @@ class Status < ApplicationRecord
       return [] if text.blank?
 
       text.scan(FetchLinkCardService::URL_PATTERN).map(&:second).uniq.filter_map do |url|
-        status = begin
-          if TagManager.instance.local_url?(url)
-            ActivityPub::TagManager.instance.uri_to_resource(url, Status)
-          else
-            EntityCache.instance.status(url)
-          end
-        end
+        status = if TagManager.instance.local_url?(url)
+                   ActivityPub::TagManager.instance.uri_to_resource(url, Status)
+                 else
+                   EntityCache.instance.status(url)
+                 end
+
         status&.distributable? ? status : nil
       end
     end
@@ -546,9 +548,9 @@ class Status < ApplicationRecord
   end
 
   def set_locality
-    if account.domain.nil? && !attribute_changed?(:local_only)
-      self.local_only = marked_local_only?
-    end
+    return unless account.domain.nil? && !attribute_changed?(:local_only)
+
+    self.local_only = marked_local_only?
   end
 
   def set_conversation
@@ -597,4 +599,12 @@ class Status < ApplicationRecord
     reblog&.decrement_count!(:reblogs_count) if reblog?
     thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && distributable?
   end
+
+  def trigger_create_webhooks
+    TriggerWebhookWorker.perform_async('status.created', 'Status', id) if local?
+  end
+
+  def trigger_update_webhooks
+    TriggerWebhookWorker.perform_async('status.updated', 'Status', id) if local?
+  end
 end
diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb
index c2330c04f..fa35e38ac 100644
--- a/app/models/status_edit.rb
+++ b/app/models/status_edit.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: status_edits
@@ -20,7 +21,7 @@
 class StatusEdit < ApplicationRecord
   include RateLimitable
 
-  self.ignored_columns = %w(
+  self.ignored_columns += %w(
     media_attachments_changed
   )
 
@@ -46,20 +47,19 @@ class StatusEdit < ApplicationRecord
 
   def emojis
     return @emojis if defined?(@emojis)
+
     @emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain)
   end
 
   def ordered_media_attachments
     return @ordered_media_attachments if defined?(@ordered_media_attachments)
 
-    @ordered_media_attachments = begin
-      if ordered_media_attachment_ids.nil?
-        []
-      else
-        map = status.media_attachments.index_by(&:id)
-        ordered_media_attachment_ids.map.with_index { |media_attachment_id, index| PreservedMediaAttachment.new(media_attachment: map[media_attachment_id], description: media_descriptions[index]) }
-      end
-    end
+    @ordered_media_attachments = if ordered_media_attachment_ids.nil?
+                                   []
+                                 else
+                                   map = status.media_attachments.index_by(&:id)
+                                   ordered_media_attachment_ids.map.with_index { |media_attachment_id, index| PreservedMediaAttachment.new(media_attachment: map[media_attachment_id], description: media_descriptions[index]) }
+                                 end
   end
 
   def proper
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
index 93a0ea1c0..dae4a5b4e 100644
--- a/app/models/status_pin.rb
+++ b/app/models/status_pin.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: status_pins
diff --git a/app/models/status_stat.rb b/app/models/status_stat.rb
index 437861d1c..d101cc178 100644
--- a/app/models/status_stat.rb
+++ b/app/models/status_stat.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: status_stats
diff --git a/app/models/system_key.rb b/app/models/system_key.rb
index f17db7c2d..1be399dd6 100644
--- a/app/models/system_key.rb
+++ b/app/models/system_key.rb
@@ -14,7 +14,7 @@ class SystemKey < ApplicationRecord
 
   before_validation :set_key
 
-  scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - ROTATION_PERIOD * 3)) }
+  scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - (ROTATION_PERIOD * 3))) }
 
   class << self
     def current_key
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 47a05d00a..554a92d90 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: tags
@@ -49,7 +50,7 @@ class Tag < ApplicationRecord
   scope :listable, -> { where(listable: [true, nil]) }
   scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) }
   scope :not_trendable, -> { where(trendable: false) }
-  scope :recently_used, ->(account) {
+  scope :recently_used, lambda { |account|
                           joins(:statuses)
                             .where(statuses: { id: account.statuses.select(:id).limit(1000) })
                             .group(:id).order(Arel.sql('count(*) desc'))
diff --git a/app/models/trends/history.rb b/app/models/trends/history.rb
index 74723e35c..83532e7bc 100644
--- a/app/models/trends/history.rb
+++ b/app/models/trends/history.rb
@@ -87,8 +87,8 @@ class Trends::History
   end
 
   def each(&block)
-    if block_given?
-      (0...7).map { |i| block.call(get(i.days.ago)) }
+    if block
+      (0...7).map { |i| yield(get(i.days.ago)) }
     else
       to_enum(:each)
     end
diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb
index 8808b3ab6..c94f7c023 100644
--- a/app/models/trends/links.rb
+++ b/app/models/trends/links.rb
@@ -113,13 +113,11 @@ class Trends::Links < Trends::Base
       max_score = preview_card.max_score
       max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown])
 
-      score = begin
-        if expected > observed || observed < options[:threshold]
-          0
-        else
-          ((observed - expected)**2) / expected
-        end
-      end
+      score = if expected > observed || observed < options[:threshold]
+                0
+              else
+                ((observed - expected)**2) / expected
+              end
 
       if score > max_score
         max_score = score
@@ -129,13 +127,11 @@ class Trends::Links < Trends::Base
         preview_card.update_columns(max_score: max_score, max_score_at: max_time)
       end
 
-      decaying_score = begin
-        if max_score.zero? || !valid_locale?(preview_card.language)
-          0
-        else
-          max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
-        end
-      end
+      decaying_score = if max_score.zero? || !valid_locale?(preview_card.language)
+                         0
+                       else
+                         max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
+                       end
 
       [decaying_score, preview_card]
     end
diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb
index 14a05e6d8..d4d5b1c24 100644
--- a/app/models/trends/statuses.rb
+++ b/app/models/trends/statuses.rb
@@ -99,21 +99,17 @@ class Trends::Statuses < Trends::Base
       expected  = 1.0
       observed  = (status.reblogs_count + status.favourites_count).to_f
 
-      score = begin
-        if expected > observed || observed < options[:threshold]
-          0
-        else
-          ((observed - expected)**2) / expected
-        end
-      end
-
-      decaying_score = begin
-        if score.zero? || !eligible?(status)
-          0
-        else
-          score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
-        end
-      end
+      score = if expected > observed || observed < options[:threshold]
+                0
+              else
+                ((observed - expected)**2) / expected
+              end
+
+      decaying_score = if score.zero? || !eligible?(status)
+                         0
+                       else
+                         score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
+                       end
 
       [decaying_score, status]
     end
diff --git a/app/models/trends/tag_filter.rb b/app/models/trends/tag_filter.rb
index 3b142efc4..46b747819 100644
--- a/app/models/trends/tag_filter.rb
+++ b/app/models/trends/tag_filter.rb
@@ -13,13 +13,11 @@ class Trends::TagFilter
   end
 
   def results
-    scope = begin
-      if params[:status] == 'pending_review'
-        Tag.unscoped
-      else
-        trending_scope
-      end
-    end
+    scope = if params[:status] == 'pending_review'
+              Tag.unscoped
+            else
+              trending_scope
+            end
 
     params.each do |key, value|
       next if key.to_s == 'page'
diff --git a/app/models/trends/tags.rb b/app/models/trends/tags.rb
index 19ade52ba..931532990 100644
--- a/app/models/trends/tags.rb
+++ b/app/models/trends/tags.rb
@@ -63,13 +63,11 @@ class Trends::Tags < Trends::Base
       max_score = tag.max_score
       max_score = 0 if max_time.nil? || max_time < (at_time - options[:max_score_cooldown])
 
-      score = begin
-        if expected > observed || observed < options[:threshold]
-          0
-        else
-          ((observed - expected)**2) / expected
-        end
-      end
+      score = if expected > observed || observed < options[:threshold]
+                0
+              else
+                ((observed - expected)**2) / expected
+              end
 
       if score > max_score
         max_score = score
diff --git a/app/models/unavailable_domain.rb b/app/models/unavailable_domain.rb
index dfc0ef14e..c3f2f20e9 100644
--- a/app/models/unavailable_domain.rb
+++ b/app/models/unavailable_domain.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: unavailable_domains
diff --git a/app/models/user.rb b/app/models/user.rb
index 2e3c067ec..daf8768e8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: users
@@ -38,10 +39,11 @@
 #  webauthn_id               :string
 #  sign_up_ip                :inet
 #  role_id                   :bigint(8)
+#  settings                  :text
 #
 
 class User < ApplicationRecord
-  self.ignored_columns = %w(
+  self.ignored_columns += %w(
     remember_created_at
     remember_token
     current_sign_in_ip
@@ -50,9 +52,9 @@ class User < ApplicationRecord
     filtered_languages
   )
 
-  include Settings::Extend
   include Redisable
   include LanguagesHelper
+  include HasUserSettings
 
   # The home and list feeds will be stored in Redis for this amount
   # of time, and status fan-out to followers will include only people
@@ -131,13 +133,6 @@ class User < ApplicationRecord
 
   has_many :session_activations, dependent: :destroy
 
-  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
-           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_followers_count,
-           :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
-           :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
-           :disable_swiping, :always_send_emails, :default_content_type, :system_emoji_font,
-           to: :settings, prefix: :setting, allow_nil: false
-
   delegate :can?, to: :role
 
   attr_reader :invite_code
@@ -195,10 +190,16 @@ class User < ApplicationRecord
 
     super
 
-    if new_user && approved?
-      prepare_new_user!
-    elsif new_user
-      notify_staff_about_pending_account!
+    if new_user
+      # Avoid extremely unlikely race condition when approving and confirming
+      # the user at the same time
+      reload unless approved?
+
+      if approved?
+        prepare_new_user!
+      else
+        notify_staff_about_pending_account!
+      end
     end
   end
 
@@ -209,7 +210,13 @@ class User < ApplicationRecord
     skip_confirmation!
     save!
 
-    prepare_new_user! if new_user && approved?
+    if new_user
+      # Avoid extremely unlikely race condition when approving and confirming
+      # the user at the same time
+      reload unless approved?
+
+      prepare_new_user! if approved?
+    end
   end
 
   def update_sign_in!(new_sign_in: false)
@@ -237,7 +244,6 @@ class User < ApplicationRecord
   end
 
   def functional?
-
     functional_or_moved?
   end
 
@@ -254,14 +260,18 @@ class User < ApplicationRecord
   end
 
   def inactive_message
-    !approved? ? :pending : super
+    approved? ? super : :pending
   end
 
   def approve!
     return if approved?
 
     update!(approved: true)
-    prepare_new_user!
+
+    # Avoid extremely unlikely race condition when approving and confirming
+    # the user at the same time
+    reload unless confirmed?
+    prepare_new_user! if confirmed?
   end
 
   def otp_enabled?
@@ -286,50 +296,6 @@ class User < ApplicationRecord
     save!
   end
 
-  def prefers_noindex?
-    setting_noindex
-  end
-
-  def preferred_posting_language
-    valid_locale_cascade(settings.default_language, locale, I18n.locale)
-  end
-
-  def setting_default_privacy
-    settings.default_privacy || (account.locked? ? 'private' : 'public')
-  end
-
-  def allows_report_emails?
-    settings.notification_emails['report']
-  end
-
-  def allows_pending_account_emails?
-    settings.notification_emails['pending_account']
-  end
-
-  def allows_appeal_emails?
-    settings.notification_emails['appeal']
-  end
-
-  def allows_trending_tags_review_emails?
-    settings.notification_emails['trending_tag']
-  end
-
-  def allows_trending_links_review_emails?
-    settings.notification_emails['trending_link']
-  end
-
-  def allows_trending_statuses_review_emails?
-    settings.notification_emails['trending_status']
-  end
-
-  def aggregates_reblogs?
-    @aggregates_reblogs ||= settings.aggregate_reblogs
-  end
-
-  def shows_application?
-    @shows_application ||= settings.show_application
-  end
-
   def token_for_app(app)
     return nil if app.nil? || app.owner != self
 
@@ -409,14 +375,6 @@ class User < ApplicationRecord
     send_reset_password_instructions
   end
 
-  def show_all_media?
-    setting_display_media == 'show_all'
-  end
-
-  def hide_all_media?
-    setting_display_media == 'hide_all'
-  end
-
   protected
 
   def send_devise_notification(notification, *args, **kwargs)
@@ -485,23 +443,29 @@ class User < ApplicationRecord
 
   def sanitize_languages
     return if chosen_languages.nil?
-    chosen_languages.reject!(&:blank?)
+
+    chosen_languages.compact_blank!
+
     self.chosen_languages = nil if chosen_languages.empty?
   end
 
   def sanitize_role
     return if role.nil?
+
     self.role = nil if role.everyone?
   end
 
   def prepare_new_user!
     BootstrapTimelineWorker.perform_async(account_id)
     ActivityTracker.increment('activity:accounts:local')
+    ActivityTracker.record('activity:logins', id)
     UserMailer.welcome(self).deliver_later
     TriggerWebhookWorker.perform_async('account.approved', 'Account', account_id)
   end
 
   def prepare_returning_user!
+    return unless confirmed?
+
     ActivityTracker.record('activity:logins', id)
     regenerate_feed! if needs_feed_update?
   end
@@ -509,6 +473,7 @@ class User < ApplicationRecord
   def notify_staff_about_pending_account!
     User.those_who_can(:manage_users).includes(:account).find_each do |u|
       next unless u.allows_pending_account_emails?
+
       AdminMailer.new_pending_account(u.account, self).deliver_later
     end
   end
diff --git a/app/models/user_ip.rb b/app/models/user_ip.rb
index a8e802e13..38287c2a6 100644
--- a/app/models/user_ip.rb
+++ b/app/models/user_ip.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: user_ips
@@ -11,7 +12,7 @@
 class UserIp < ApplicationRecord
   self.primary_key = :user_id
 
-  belongs_to :user, foreign_key: :user_id
+  belongs_to :user
 
   def readonly?
     true
diff --git a/app/models/user_role.rb b/app/models/user_role.rb
index 74dfdc220..a1b91dc0f 100644
--- a/app/models/user_role.rb
+++ b/app/models/user_role.rb
@@ -163,6 +163,7 @@ class UserRole < ApplicationRecord
 
   def in_permissions?(privilege)
     raise ArgumentError, "Unknown privilege: #{privilege}" unless FLAGS.key?(privilege)
+
     computed_permissions & FLAGS[privilege] == FLAGS[privilege]
   end
 
@@ -172,6 +173,7 @@ class UserRole < ApplicationRecord
 
   def validate_own_role_edition
     return unless defined?(@current_account) && @current_account.user_role.id == id
+
     errors.add(:permissions_as_keys, :own_role) if permissions_changed?
     errors.add(:position, :own_role) if position_changed?
   end
diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb
new file mode 100644
index 000000000..0be8c5fbc
--- /dev/null
+++ b/app/models/user_settings.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class UserSettings
+  class Error < StandardError; end
+  class KeyError < Error; end
+
+  include UserSettings::DSL
+  include UserSettings::Glue
+
+  setting :always_send_emails, default: false
+  setting :aggregate_reblogs, default: true
+  setting :flavour, default: -> { ::Setting.flavour }
+  setting :skin, default: -> { ::Setting.skin }
+  setting :noindex, default: -> { ::Setting.noindex }
+  setting :show_application, default: true
+  setting :default_language, default: nil
+  setting :default_sensitive, default: false
+  setting :default_privacy, default: nil
+  setting :default_content_type, default: 'text/plain'
+  setting :hide_followers_count, default: false
+
+  namespace :web do
+    setting :crop_images, default: true
+    setting :advanced_layout, default: false
+    setting :trends, default: true
+    setting :use_blurhash, default: true
+    setting :use_pending_items, default: false
+    setting :use_system_font, default: false
+    setting :disable_swiping, default: false
+    setting :delete_modal, default: true
+    setting :reblog_modal, default: false
+    setting :unfollow_modal, default: true
+    setting :favourite_modal, default: false
+    setting :reduce_motion, default: false
+    setting :expand_content_warnings, default: false
+    setting :display_media, default: 'default', in: %w(default show_all hide_all)
+    setting :auto_play, default: false
+    setting :use_system_emoji_font, default: false
+  end
+
+  namespace :notification_emails do
+    setting :follow, default: true
+    setting :reblog, default: false
+    setting :favourite, default: false
+    setting :mention, default: true
+    setting :follow_request, default: true
+    setting :report, default: true
+    setting :pending_account, default: true
+    setting :trends, default: true
+    setting :link_trends, default: false
+    setting :status_trends, default: false
+    setting :appeal, default: true
+  end
+
+  namespace :interactions do
+    setting :must_be_follower, default: false
+    setting :must_be_following, default: false
+    setting :must_be_following_dm, default: false
+  end
+
+  def initialize(original_hash)
+    @original_hash = original_hash || {}
+  end
+
+  def [](key)
+    key = key.to_sym
+
+    raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
+
+    if @original_hash.key?(key)
+      @original_hash[key]
+    else
+      self.class.definition_for(key).default_value
+    end
+  end
+
+  def []=(key, value)
+    key = key.to_sym
+
+    raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
+
+    typecast_value = self.class.definition_for(key).type_cast(value)
+
+    if typecast_value.nil?
+      @original_hash.delete(key)
+    else
+      @original_hash[key] = typecast_value
+    end
+  end
+
+  def update(params)
+    params.each do |k, v|
+      self[k] = v unless v.nil?
+    end
+  end
+
+  keys.each do |key|
+    define_method(key) do
+      self[key]
+    end
+  end
+
+  def as_json
+    @original_hash
+  end
+end
diff --git a/app/models/user_settings/dsl.rb b/app/models/user_settings/dsl.rb
new file mode 100644
index 000000000..26238bbbe
--- /dev/null
+++ b/app/models/user_settings/dsl.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module UserSettings::DSL
+  module ClassMethods
+    def setting(key, options = {})
+      @definitions ||= {}
+
+      UserSettings::Setting.new(key, options).tap do |s|
+        @definitions[s.key] = s
+      end
+    end
+
+    def namespace(key, &block)
+      @definitions ||= {}
+
+      UserSettings::Namespace.new(key).configure(&block).tap do |n|
+        @definitions.merge!(n.definitions)
+      end
+    end
+
+    def keys
+      @definitions.keys
+    end
+
+    def definition_for(key)
+      @definitions[key.to_sym]
+    end
+
+    def definition_for?(key)
+      @definitions.key?(key.to_sym)
+    end
+  end
+
+  def self.included(base)
+    base.extend ClassMethods
+  end
+end
diff --git a/app/models/user_settings/glue.rb b/app/models/user_settings/glue.rb
new file mode 100644
index 000000000..02066a411
--- /dev/null
+++ b/app/models/user_settings/glue.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module UserSettings::Glue
+  def to_model
+    self
+  end
+
+  def to_key
+    ''
+  end
+
+  def persisted?
+    false
+  end
+
+  def type_for_attribute(key)
+    self.class.definition_for(key)&.type
+  end
+
+  def has_attribute?(key) # rubocop:disable Naming/PredicateName
+    self.class.definition_for?(key)
+  end
+end
diff --git a/app/models/user_settings/namespace.rb b/app/models/user_settings/namespace.rb
new file mode 100644
index 000000000..b8f7e092e
--- /dev/null
+++ b/app/models/user_settings/namespace.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class UserSettings::Namespace
+  attr_reader :name, :definitions
+
+  def initialize(name)
+    @name        = name.to_sym
+    @definitions = {}
+  end
+
+  def configure(&block)
+    instance_eval(&block)
+    self
+  end
+
+  def setting(key, options = {})
+    UserSettings::Setting.new(key, options.merge(namespace: name)).tap do |s|
+      @definitions[s.key] = s
+    end
+  end
+end
diff --git a/app/models/user_settings/setting.rb b/app/models/user_settings/setting.rb
new file mode 100644
index 000000000..5f5504254
--- /dev/null
+++ b/app/models/user_settings/setting.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class UserSettings::Setting
+  attr_reader :name, :namespace, :in
+
+  def initialize(name, options = {})
+    @name          = name.to_sym
+    @default_value = options[:default]
+    @namespace     = options[:namespace]
+    @in            = options[:in]
+  end
+
+  def default_value
+    if @default_value.respond_to?(:call)
+      @default_value.call
+    else
+      @default_value
+    end
+  end
+
+  def type
+    case default_value
+    when TrueClass, FalseClass
+      ActiveModel::Type::Boolean.new
+    else
+      ActiveModel::Type::String.new
+    end
+  end
+
+  def type_cast(value)
+    if type.respond_to?(:cast)
+      type.cast(value)
+    else
+      value
+    end
+  end
+
+  def to_a
+    [key, default_value]
+  end
+
+  def key
+    if namespace
+      "#{namespace}.#{name}".to_sym
+    else
+      name
+    end
+  end
+end
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index 6e46573ae..0ffbe068e 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: web_push_subscriptions
@@ -53,25 +54,21 @@ class Web::PushSubscription < ApplicationRecord
   def associated_user
     return @associated_user if defined?(@associated_user)
 
-    @associated_user = begin
-      if user_id.nil?
-        session_activation.user
-      else
-        user
-      end
-    end
+    @associated_user = if user_id.nil?
+                         session_activation.user
+                       else
+                         user
+                       end
   end
 
   def associated_access_token
     return @associated_access_token if defined?(@associated_access_token)
 
-    @associated_access_token = begin
-      if access_token_id.nil?
-        find_or_create_access_token.token
-      else
-        access_token.token
-      end
-    end
+    @associated_access_token = if access_token_id.nil?
+                                 find_or_create_access_token.token
+                               else
+                                 access_token.token
+                               end
   end
 
   class << self
diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb
index 99588d26c..3d5efe664 100644
--- a/app/models/web/setting.rb
+++ b/app/models/web/setting.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: web_settings
diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb
index 7d423e38d..4fa31ece5 100644
--- a/app/models/webauthn_credential.rb
+++ b/app/models/webauthn_credential.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 # == Schema Information
 #
 # Table name: webauthn_credentials
@@ -18,5 +19,5 @@ class WebauthnCredential < ApplicationRecord
   validates :external_id, uniqueness: true
   validates :nickname, uniqueness: { scope: :user_id }
   validates :sign_count,
-            numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**63 - 1 }
+            numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: (2**63) - 1 }
 end
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
index 4aafb1257..9a056a386 100644
--- a/app/models/webhook.rb
+++ b/app/models/webhook.rb
@@ -17,7 +17,10 @@ class Webhook < ApplicationRecord
   EVENTS = %w(
     account.approved
     account.created
+    account.updated
     report.created
+    status.created
+    status.updated
   ).freeze
 
   scope :enabled, -> { where(enabled: true) }