about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2023-03-30 14:44:00 +0200
committerGitHub <noreply@github.com>2023-03-30 14:44:00 +0200
commita9b5598c97fc4d3302b61b260097ef41c2ebe377 (patch)
tree2568f87b80c64214f3d03bedb6e6f85a77e9ddb0
parente7c3e5587489a9e248973fa0719869541d34ba9f (diff)
Change user settings to be stored in a more optimal way (#23630)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
-rw-r--r--.rubocop.yml8
-rw-r--r--.rubocop_todo.yml1
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb12
-rw-r--r--app/controllers/settings/preferences_controller.rb40
-rw-r--r--app/lib/user_settings_decorator.rb155
-rw-r--r--app/lib/user_settings_serializer.rb19
-rw-r--r--app/models/concerns/has_user_settings.rb141
-rw-r--r--app/models/user.rb59
-rw-r--r--app/models/user_settings.rb99
-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.rb48
-rw-r--r--app/services/notify_service.rb13
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml65
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml36
-rw-r--r--app/views/settings/preferences/other/show.html.haml29
-rw-r--r--config/initializers/inflections.rb1
-rw-r--r--config/settings.yml34
-rw-r--r--db/migrate/20230215074327_add_settings_to_users.rb7
-rw-r--r--db/migrate/20230215074423_move_user_settings.rb84
-rw-r--r--db/schema.rb3
-rw-r--r--lib/tasks/tests.rake2
-rw-r--r--spec/controllers/api/v1/accounts/credentials_controller_spec.rb1
-rw-r--r--spec/controllers/application_controller_spec.rb10
-rw-r--r--spec/controllers/settings/preferences/notifications_controller_spec.rb14
-rw-r--r--spec/controllers/settings/preferences/other_controller_spec.rb14
-rw-r--r--spec/lib/settings/extend_spec.rb16
-rw-r--r--spec/lib/settings/scoped_settings_spec.rb35
-rw-r--r--spec/lib/user_settings_decorator_spec.rb84
-rw-r--r--spec/models/user_settings/namespace_spec.rb25
-rw-r--r--spec/models/user_settings/setting_spec.rb74
-rw-r--r--spec/models/user_settings_spec.rb110
-rw-r--r--spec/models/user_spec.rb14
-rw-r--r--spec/services/notify_service_spec.rb7
-rw-r--r--spec/services/report_service_spec.rb3
36 files changed, 818 insertions, 526 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index 1033db92d..b5598b65a 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -185,3 +185,11 @@ Style/TrailingCommaInHashLiteral:
 
 Style/SymbolArray:
   Enabled: false
+
+# Reason: Prefer less intendation in conditional assignments
+# https://docs.rubocop.org/rubocop/cops_style.html#styleredundantbegin
+Style/RedundantBegin:
+  Enabled: false
+
+RSpec/NamedSubject:
+  EnforcedStyle: named_only
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 64a6b6b33..4ad266c8c 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -259,6 +259,7 @@ Metrics/ModuleLength:
     - 'app/helpers/jsonld_helper.rb'
     - 'app/helpers/statuses_helper.rb'
     - 'app/models/concerns/account_interactions.rb'
+    - 'app/models/concerns/has_user_settings.rb'
 
 # Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters.
 Metrics/ParameterLists:
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index 94b707771..7c7d70fd3 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -13,7 +13,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   def update
     @account = current_account
     UpdateAccountService.new.call(@account, account_params, raise_error: true)
-    UserSettingsDecorator.new(current_user).update(user_settings_params) if user_settings_params
+    current_user.update(user_params) if user_params
     ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
     render json: @account, serializer: REST::CredentialAccountSerializer
   end
@@ -34,15 +34,17 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
     )
   end
 
-  def user_settings_params
+  def user_params
     return nil if params[:source].blank?
 
     source_params = params.require(:source)
 
     {
-      'setting_default_privacy' => source_params.fetch(:privacy, @account.user.setting_default_privacy),
-      'setting_default_sensitive' => source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
-      'setting_default_language' => source_params.fetch(:language, @account.user.setting_default_language),
+      settings_attributes: {
+        default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
+        default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
+        default_language: source_params.fetch(:language, @account.user.setting_default_language),
+      },
     }
   end
 end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index f5d5c1244..281deb64d 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -4,8 +4,6 @@ class Settings::PreferencesController < Settings::BaseController
   def show; end
 
   def update
-    user_settings.update(user_settings_params.to_h)
-
     if current_user.update(user_params)
       I18n.locale = current_user.locale
       redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg')
@@ -20,43 +18,7 @@ class Settings::PreferencesController < Settings::BaseController
     settings_preferences_path
   end
 
-  def user_settings
-    UserSettingsDecorator.new(current_user)
-  end
-
   def user_params
-    params.require(:user).permit(
-      :locale,
-      chosen_languages: []
-    )
-  end
-
-  def user_settings_params
-    params.require(:user).permit(
-      :setting_default_privacy,
-      :setting_default_sensitive,
-      :setting_default_language,
-      :setting_unfollow_modal,
-      :setting_boost_modal,
-      :setting_delete_modal,
-      :setting_auto_play_gif,
-      :setting_display_media,
-      :setting_expand_spoilers,
-      :setting_reduce_motion,
-      :setting_disable_swiping,
-      :setting_system_font_ui,
-      :setting_noindex,
-      :setting_theme,
-      :setting_aggregate_reblogs,
-      :setting_show_application,
-      :setting_advanced_layout,
-      :setting_use_blurhash,
-      :setting_use_pending_items,
-      :setting_trends,
-      :setting_crop_images,
-      :setting_always_send_emails,
-      notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag appeal),
-      interactions: %i(must_be_follower must_be_following must_be_following_dm)
-    )
+    params.require(:user).permit(:locale, chosen_languages: [], settings_attributes: UserSettings.keys)
   end
 end
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
deleted file mode 100644
index 5fb7655a9..000000000
--- a/app/lib/user_settings_decorator.rb
+++ /dev/null
@@ -1,155 +0,0 @@
-# frozen_string_literal: true
-
-class UserSettingsDecorator
-  attr_reader :user, :settings
-
-  def initialize(user)
-    @user = user
-  end
-
-  def update(settings)
-    @settings = settings
-    process_update
-  end
-
-  private
-
-  def process_update
-    user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails')
-    user.settings['interactions']        = merged_interactions if change?('interactions')
-    user.settings['default_privacy']     = default_privacy_preference if change?('setting_default_privacy')
-    user.settings['default_sensitive']   = default_sensitive_preference if change?('setting_default_sensitive')
-    user.settings['default_language']    = default_language_preference if change?('setting_default_language')
-    user.settings['unfollow_modal']      = unfollow_modal_preference if change?('setting_unfollow_modal')
-    user.settings['boost_modal']         = boost_modal_preference if change?('setting_boost_modal')
-    user.settings['delete_modal']        = delete_modal_preference if change?('setting_delete_modal')
-    user.settings['auto_play_gif']       = auto_play_gif_preference if change?('setting_auto_play_gif')
-    user.settings['display_media']       = display_media_preference if change?('setting_display_media')
-    user.settings['expand_spoilers']     = expand_spoilers_preference if change?('setting_expand_spoilers')
-    user.settings['reduce_motion']       = reduce_motion_preference if change?('setting_reduce_motion')
-    user.settings['disable_swiping']     = disable_swiping_preference if change?('setting_disable_swiping')
-    user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
-    user.settings['noindex']             = noindex_preference if change?('setting_noindex')
-    user.settings['theme']               = theme_preference if change?('setting_theme')
-    user.settings['aggregate_reblogs']   = aggregate_reblogs_preference if change?('setting_aggregate_reblogs')
-    user.settings['show_application']    = show_application_preference if change?('setting_show_application')
-    user.settings['advanced_layout']     = advanced_layout_preference if change?('setting_advanced_layout')
-    user.settings['use_blurhash']        = use_blurhash_preference if change?('setting_use_blurhash')
-    user.settings['use_pending_items']   = use_pending_items_preference if change?('setting_use_pending_items')
-    user.settings['trends']              = trends_preference if change?('setting_trends')
-    user.settings['crop_images']         = crop_images_preference if change?('setting_crop_images')
-    user.settings['always_send_emails']  = always_send_emails_preference if change?('setting_always_send_emails')
-  end
-
-  def merged_notification_emails
-    user.settings['notification_emails'].merge coerced_settings('notification_emails').to_h
-  end
-
-  def merged_interactions
-    user.settings['interactions'].merge coerced_settings('interactions').to_h
-  end
-
-  def default_privacy_preference
-    settings['setting_default_privacy']
-  end
-
-  def default_sensitive_preference
-    boolean_cast_setting 'setting_default_sensitive'
-  end
-
-  def unfollow_modal_preference
-    boolean_cast_setting 'setting_unfollow_modal'
-  end
-
-  def boost_modal_preference
-    boolean_cast_setting 'setting_boost_modal'
-  end
-
-  def delete_modal_preference
-    boolean_cast_setting 'setting_delete_modal'
-  end
-
-  def system_font_ui_preference
-    boolean_cast_setting 'setting_system_font_ui'
-  end
-
-  def auto_play_gif_preference
-    boolean_cast_setting 'setting_auto_play_gif'
-  end
-
-  def display_media_preference
-    settings['setting_display_media']
-  end
-
-  def expand_spoilers_preference
-    boolean_cast_setting 'setting_expand_spoilers'
-  end
-
-  def reduce_motion_preference
-    boolean_cast_setting 'setting_reduce_motion'
-  end
-
-  def disable_swiping_preference
-    boolean_cast_setting 'setting_disable_swiping'
-  end
-
-  def noindex_preference
-    boolean_cast_setting 'setting_noindex'
-  end
-
-  def show_application_preference
-    boolean_cast_setting 'setting_show_application'
-  end
-
-  def theme_preference
-    settings['setting_theme']
-  end
-
-  def default_language_preference
-    settings['setting_default_language']
-  end
-
-  def aggregate_reblogs_preference
-    boolean_cast_setting 'setting_aggregate_reblogs'
-  end
-
-  def advanced_layout_preference
-    boolean_cast_setting 'setting_advanced_layout'
-  end
-
-  def use_blurhash_preference
-    boolean_cast_setting 'setting_use_blurhash'
-  end
-
-  def use_pending_items_preference
-    boolean_cast_setting 'setting_use_pending_items'
-  end
-
-  def trends_preference
-    boolean_cast_setting 'setting_trends'
-  end
-
-  def crop_images_preference
-    boolean_cast_setting 'setting_crop_images'
-  end
-
-  def always_send_emails_preference
-    boolean_cast_setting 'setting_always_send_emails'
-  end
-
-  def boolean_cast_setting(key)
-    ActiveModel::Type::Boolean.new.cast(settings[key])
-  end
-
-  def coerced_settings(key)
-    coerce_values settings.fetch(key, {})
-  end
-
-  def coerce_values(params_hash)
-    params_hash.transform_values { |x| ActiveModel::Type::Boolean.new.cast(x) }
-  end
-
-  def change?(key)
-    !settings[key].nil?
-  end
-end
diff --git a/app/lib/user_settings_serializer.rb b/app/lib/user_settings_serializer.rb
new file mode 100644
index 000000000..10d1be04d
--- /dev/null
+++ b/app/lib/user_settings_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class UserSettingsSerializer
+  def self.load(value)
+    json = begin
+      if value.blank?
+        {}
+      else
+        Oj.load(value, symbol_keys: true)
+      end
+    end
+
+    UserSettings.new(json)
+  end
+
+  def self.dump(value)
+    Oj.dump(value.as_json)
+  end
+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..b3fa1f683
--- /dev/null
+++ b/app/models/concerns/has_user_settings.rb
@@ -0,0 +1,141 @@
+# 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_reduce_motion
+    settings['web.reduce_motion']
+  end
+
+  def setting_system_font_ui
+    settings['web.use_system_font']
+  end
+
+  def setting_noindex
+    settings['noindex']
+  end
+
+  def setting_theme
+    settings['theme']
+  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 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 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/user.rb b/app/models/user.rb
index d56a9b9ca..9b225d75f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -39,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
@@ -51,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
@@ -132,13 +133,6 @@ class User < ApplicationRecord
 
   has_many :session_activations, dependent: :destroy
 
-  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
-           :reduce_motion, :system_font_ui, :noindex, :theme, :display_media,
-           :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
-           :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
-           :disable_swiping, :always_send_emails,
-           to: :settings, prefix: :setting, allow_nil: false
-
   delegate :can?, to: :role
 
   attr_reader :invite_code
@@ -302,42 +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_trends_review_emails?
-    settings.notification_emails['trending_tag']
-  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
 
@@ -417,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)
@@ -494,7 +444,8 @@ 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
 
diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb
new file mode 100644
index 000000000..2c025d6c5
--- /dev/null
+++ b/app/models/user_settings.rb
@@ -0,0 +1,99 @@
+# 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 :theme, default: -> { ::Setting.theme }
+  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
+
+  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 :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
+  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 :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..c359c593b
--- /dev/null
+++ b/app/models/user_settings/setting.rb
@@ -0,0 +1,48 @@
+# 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
+    if @default_value.is_a?(TrueClass) || @default_value.is_a?(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/services/notify_service.rb b/app/services/notify_service.rb
index 4c7acbcac..994ca588a 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -3,6 +3,11 @@
 class NotifyService < BaseService
   include Redisable
 
+  NON_EMAIL_TYPES = %i(
+    admin.report
+    admin.sign_up
+  ).freeze
+
   def call(recipient, type, activity)
     @recipient    = recipient
     @activity     = activity
@@ -36,11 +41,11 @@ class NotifyService < BaseService
   end
 
   def optional_non_follower?
-    @recipient.user.settings.interactions['must_be_follower']  && !@notification.from_account.following?(@recipient)
+    @recipient.user.settings['interactions.must_be_follower']  && !@notification.from_account.following?(@recipient)
   end
 
   def optional_non_following?
-    @recipient.user.settings.interactions['must_be_following'] && !following_sender?
+    @recipient.user.settings['interactions.must_be_following'] && !following_sender?
   end
 
   def message?
@@ -82,7 +87,7 @@ class NotifyService < BaseService
 
   def optional_non_following_and_direct?
     direct_message? &&
-      @recipient.user.settings.interactions['must_be_following_dm'] &&
+      @recipient.user.settings['interactions.must_be_following_dm'] &&
       !following_sender? &&
       !response_to_recipient?
   end
@@ -171,6 +176,6 @@ class NotifyService < BaseService
   end
 
   def send_email_for_notification_type?
-    @recipient.user.settings.notification_emails[@notification.type.to_s]
+    NON_EMAIL_TYPES.exclude?(@notification.type) && @recipient.user.settings["notification_emails.#{@notification.type}"]
   end
 end
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index bc23df647..5358310e5 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -9,57 +9,58 @@
     .fields-group.fields-row__column.fields-row__column-6
       = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, selected: I18n.locale, hint: false
     .fields-group.fields-row__column.fields-row__column-6
-      = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false, hint: false
+      = f.simple_fields_for :settings, current_user.settings do |ff|
+        = ff.input :theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false, hint: false
 
   - unless I18n.locale == :en
     .flash-message.translation-prompt
       #{t 'appearance.localization.body'} #{content_tag(:a, t('appearance.localization.guide_link_text'), href: t('appearance.localization.guide_link'), target: '_blank', rel: 'noopener')}
 
-  %h4= t 'appearance.advanced_web_interface'
+  = f.simple_fields_for :settings, current_user.settings do |ff|
+    %h4= t 'appearance.advanced_web_interface'
 
-  %p.hint= t 'appearance.advanced_web_interface_hint'
+    %p.hint= t 'appearance.advanced_web_interface_hint'
 
-  .fields-group
-    = f.input :setting_advanced_layout, as: :boolean, wrapper: :with_label, hint: false
+    .fields-group
+      = ff.input :'web.advanced_layout', wrapper: :with_label, hint: false, label: I18n.t('simple_form.labels.defaults.setting_advanced_layout')
+    %h4= t 'appearance.animations_and_accessibility'
 
-  %h4= t 'appearance.animations_and_accessibility'
+    .fields-group
+      = ff.input :'web.use_pending_items', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_use_pending_items'), hint: I18n.t('simple_form.hints.defaults.setting_use_pending_items')
 
-  .fields-group
-    = f.input :setting_use_pending_items, as: :boolean, wrapper: :with_label
+    .fields-group
+      = ff.input :'web.auto_play', wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_auto_play_gif')
+      = ff.input :'web.reduce_motion', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reduce_motion')
+      = ff.input :'web.disable_swiping', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_disable_swiping')
+      = ff.input :'web.use_system_font', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_system_font_ui')
 
-  .fields-group
-    = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label, recommended: true
-    = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label
-    = f.input :setting_disable_swiping, as: :boolean, wrapper: :with_label
-    = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label
+    %h4= t 'appearance.toot_layout'
 
-  %h4= t 'appearance.toot_layout'
+    .fields-group
+      = ff.input :'web.crop_images', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_crop_images')
 
-  .fields-group
-    = f.input :setting_crop_images, as: :boolean, wrapper: :with_label
+    %h4= t 'appearance.discovery'
 
-  %h4= t 'appearance.discovery'
+    .fields-group
+      = ff.input :'web.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_trends')
 
-  .fields-group
-    = f.input :setting_trends, as: :boolean, wrapper: :with_label
+    %h4= t 'appearance.confirmation_dialogs'
 
-  %h4= t 'appearance.confirmation_dialogs'
+    .fields-group
+      = ff.input :'web.unfollow_modal', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_unfollow_modal')
+      = ff.input :'web.reblog_modal', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_boost_modal')
+      = ff.input :'web.delete_modal', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_delete_modal')
 
-  .fields-group
-    = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label
-    = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
-    = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
+    %h4= t 'appearance.sensitive_content'
 
-  %h4= t 'appearance.sensitive_content'
+    .fields-group
+      = ff.input :'web.display_media', collection: ['default', 'show_all', 'hide_all'],label_method: lambda { |item| t("simple_form.hints.defaults.setting_display_media_#{item}") }, hint: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', wrapper: :with_floating_label, label: I18n.t('simple_form.labels.defaults.setting_display_media')
 
-  .fields-group
-    = f.input :setting_display_media, collection: ['default', 'show_all', 'hide_all'], label_method: lambda { |item| t("simple_form.hints.defaults.setting_display_media_#{item}") }, hint: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', wrapper: :with_floating_label
+    .fields-group
+      = ff.input :'web.use_blurhash', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_use_blurhash'), hint: I18n.t('simple_form.hints.defaults.setting_use_blurhash')
 
-  .fields-group
-    = f.input :setting_use_blurhash, as: :boolean, wrapper: :with_label
-
-  .fields-group
-    = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label
+    .fields-group
+      = ff.input :'web.expand_content_warnings', wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_expand_spoilers')
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index f00dbadd4..cb1ad0886 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -11,25 +11,25 @@
 
   %p.hint= t 'notifications.email_events_hint'
 
-  .fields-group
-    = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
-      = ff.input :follow, as: :boolean, wrapper: :with_label
-      = ff.input :follow_request, as: :boolean, wrapper: :with_label
-      = ff.input :reblog, as: :boolean, wrapper: :with_label
-      = ff.input :favourite, as: :boolean, wrapper: :with_label
-      = ff.input :mention, as: :boolean, wrapper: :with_label
-      = ff.input :report, as: :boolean, wrapper: :with_label if current_user.can?(:manage_reports)
-      = ff.input :appeal, as: :boolean, wrapper: :with_label if current_user.can?(:manage_appeals)
-      = ff.input :pending_account, as: :boolean, wrapper: :with_label if current_user.can?(:manage_users)
-      = ff.input :trending_tag, as: :boolean, wrapper: :with_label if current_user.can?(:manage_taxonomies)
-
-  .fields-group
-    = f.input :setting_always_send_emails, as: :boolean, wrapper: :with_label
+  = f.simple_fields_for :settings, current_user.settings do |ff|
+    .fields-group
+      = ff.input :'notification_emails.follow', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.follow')
+      = ff.input :'notification_emails.follow_request', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.follow_request')
+      = ff.input :'notification_emails.reblog', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.reblog')
+      = ff.input :'notification_emails.favourite', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.favourite')
+      = ff.input :'notification_emails.mention', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.mention')
+      = ff.input :'notification_emails.report', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.report') if current_user.can?(:manage_reports)
+      = ff.input :'notification_emails.appeal', as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.appeal') if current_user.can?(:manage_appeals)
+      = ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users)
+      = ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies)
+
+    .fields-group
+      = ff.input :always_send_emails, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_always_send_emails'), hint: I18n.t('simple_form.hints.defaults.setting_always_send_emails')
 
   %h4= t 'notifications.other_settings'
 
   .fields-group
-    = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
-      = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
-      = ff.input :must_be_following, as: :boolean, wrapper: :with_label
-      = ff.input :must_be_following_dm, as: :boolean, wrapper: :with_label
+    = f.simple_fields_for :settings, current_user.settings do |ff|
+      = ff.input :'interactions.must_be_follower', wrapper: :with_label, label: I18n.t('simple_form.labels.interactions.must_be_follower')
+      = ff.input :'interactions.must_be_following', wrapper: :with_label, label: I18n.t('simple_form.labels.interactions.must_be_following')
+      = ff.input :'interactions.must_be_following_dm', wrapper: :with_label, label: I18n.t('simple_form.labels.interactions.must_be_following_dm')
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 44f4af2eb..6590ec7c2 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -7,26 +7,27 @@
 = simple_form_for current_user, url: settings_preferences_other_path, html: { method: :put, id: 'edit_preferences' } do |f|
   = render 'shared/error_messages', object: current_user
 
-  .fields-group
-    = f.input :setting_noindex, as: :boolean, wrapper: :with_label
+  = f.simple_fields_for :settings, current_user.settings do |ff|
+    .fields-group
+      = ff.input :noindex, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_noindex'), hint: I18n.t('simple_form.hints.defaults.setting_noindex')
 
-  .fields-group
-    = f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label, recommended: true
+    .fields-group
+      = ff.input :aggregate_reblogs, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_aggregate_reblogs'), hint: I18n.t('simple_form.hints.defaults.setting_aggregate_reblogs')
 
-  %h4= t 'preferences.posting_defaults'
+    %h4= t 'preferences.posting_defaults'
 
-  .fields-row
-    .fields-group.fields-row__column.fields-row__column-6
-      = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false
+    .fields-row
+      .fields-group.fields-row__column.fields-row__column-6
+        = ff.input :default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_privacy')
 
-    .fields-group.fields-row__column.fields-row__column-6
-      = f.input :setting_default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false
+      .fields-group.fields-row__column.fields-row__column-6
+        = ff.input :default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_language')
 
-  .fields-group
-    = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
+    .fields-group
+      = ff.input :default_sensitive, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_sensitive'), hint: I18n.t('simple_form.hints.defaults.setting_default_sensitive')
 
-  .fields-group
-    = f.input :setting_show_application, as: :boolean, wrapper: :with_label, recommended: true
+    .fields-group
+      = ff.input :show_application, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_show_application'), hint: I18n.t('simple_form.hints.defaults.setting_show_application')
 
   %h4= t 'preferences.public_timelines'
 
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index a361cb0ec..95f0b5788 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -26,6 +26,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
   inflect.acronym 'URL'
   inflect.acronym 'ASCII'
   inflect.acronym 'DeepL'
+  inflect.acronym 'DSL'
 
   inflect.singular 'data', 'data'
 end
diff --git a/config/settings.yml b/config/settings.yml
index f0b09dd5c..4ac521a4b 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -12,48 +12,14 @@ defaults: &defaults
   registrations_mode: 'open'
   profile_directory: true
   closed_registrations_message: ''
-  open_deletion: true
-  min_invite_role: 'admin'
   timeline_preview: true
   show_staff_badge: true
-  default_sensitive: false
-  unfollow_modal: false
-  boost_modal: false
-  delete_modal: true
-  auto_play_gif: false
-  display_media: 'default'
-  expand_spoilers: false
   preview_sensitive_media: false
-  reduce_motion: false
-  disable_swiping: false
-  show_application: true
-  system_font_ui: false
   noindex: false
   theme: 'default'
-  aggregate_reblogs: true
-  advanced_layout: false
-  use_blurhash: true
-  use_pending_items: false
   trends: true
   trends_as_landing_page: true
   trendable_by_default: false
-  crop_images: true
-  notification_emails:
-    follow: true
-    reblog: false
-    favourite: false
-    mention: true
-    follow_request: true
-    digest: true
-    report: true
-    pending_account: true
-    trending_tag: true
-    appeal: true
-  always_send_emails: false
-  interactions:
-    must_be_follower: false
-    must_be_following: false
-    must_be_following_dm: false
   reserved_usernames:
     - admin
     - support
diff --git a/db/migrate/20230215074327_add_settings_to_users.rb b/db/migrate/20230215074327_add_settings_to_users.rb
new file mode 100644
index 000000000..ff5308f42
--- /dev/null
+++ b/db/migrate/20230215074327_add_settings_to_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddSettingsToUsers < ActiveRecord::Migration[6.1]
+  def change
+    add_column :users, :settings, :text
+  end
+end
diff --git a/db/migrate/20230215074423_move_user_settings.rb b/db/migrate/20230215074423_move_user_settings.rb
new file mode 100644
index 000000000..351a8b61d
--- /dev/null
+++ b/db/migrate/20230215074423_move_user_settings.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+class MoveUserSettings < ActiveRecord::Migration[6.1]
+  class User < ApplicationRecord; end
+
+  MAPPING = {
+    default_privacy: 'default_privacy',
+    default_sensitive: 'web.default_sensitive',
+    default_language: 'default_language',
+    noindex: 'noindex',
+    theme: 'theme',
+    trends: 'web.trends',
+    unfollow_modal: 'web.unfollow_modal',
+    boost_modal: 'web.reblog_modal',
+    delete_modal: 'web.delete_modal',
+    auto_play_gif: 'web.auto_play',
+    display_media: 'web.display_media',
+    expand_spoilers: 'web.expand_content_warnings',
+    reduce_motion: 'web.reduce_motion',
+    disable_swiping: 'web.disable_swiping',
+    show_application: 'show_application',
+    system_font_ui: 'web.use_system_font',
+    aggregate_reblogs: 'aggregate_reblogs',
+    advanced_layout: 'web.advanced_layout',
+    use_blurhash: 'web.use_blurhash',
+    use_pending_items: 'web.use_pending_items',
+    crop_images: 'web.crop_images',
+    notification_emails: {
+      follow: 'notification_emails.follow',
+      reblog: 'notification_emails.reblog',
+      favourite: 'notification_emails.favourite',
+      mention: 'notification_emails.mention',
+      follow_request: 'notification_emails.follow_request',
+      report: 'notification_emails.report',
+      pending_account: 'notification_emails.pending_account',
+      trending_tag: 'notification_emails.trends',
+      appeal: 'notification_emails.appeal',
+    }.freeze,
+    always_send_emails: 'always_send_emails',
+    interactions: {
+      must_be_follower: 'interactions.must_be_follower',
+      must_be_following: 'interactions.must_be_following',
+      must_be_following_dm: 'interactions.must_be_following_dm',
+    }.freeze,
+  }.freeze
+
+  class LegacySetting < ApplicationRecord
+    self.table_name = 'settings'
+
+    def var
+      self[:var]&.to_sym
+    end
+
+    def value
+      YAML.safe_load(self[:value], permitted_classes: [ActiveSupport::HashWithIndifferentAccess]) if self[:value].present?
+    end
+  end
+
+  def up
+    User.find_each do |user|
+      previous_settings = LegacySetting.where(thing_type: 'User', thing_id: user.id).index_by(&:var)
+
+      user_settings = {}
+
+      MAPPING.each do |legacy_key, new_key|
+        value = previous_settings[legacy_key]&.value
+
+        next if value.blank?
+
+        if value.is_a?(Hash)
+          value.each do |nested_key, nested_value|
+            user_settings[MAPPING[legacy_key][nested_key.to_sym]] = nested_value
+          end
+        else
+          user_settings[new_key] = value
+        end
+      end
+
+      user.update_column('settings', Oj.dump(user_settings)) # rubocop:disable Rails/SkipsModelValidations
+    end
+  end
+
+  def down; end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 704cef122..620bed2bc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2022_12_06_114142) do
+ActiveRecord::Schema.define(version: 2023_02_15_074423) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -1060,6 +1060,7 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) do
     t.inet "sign_up_ip"
     t.boolean "skip_sign_in_token"
     t.bigint "role_id"
+    t.text "settings"
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)"
diff --git a/lib/tasks/tests.rake b/lib/tasks/tests.rake
index 51a6ee0d7..35073b78b 100644
--- a/lib/tasks/tests.rake
+++ b/lib/tasks/tests.rake
@@ -54,7 +54,7 @@ namespace :tests do
         exit(1)
       end
 
-      unless User.find(1).settings.notification_emails['favourite'] == true && User.find(1).settings.notification_emails['mention'] == false
+      unless User.find(1).settings['notification_emails.favourite'] == true && User.find(1).settings['notification_emails.mention'] == false
         puts 'User settings not kept as expected'
         exit(1)
       end
diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
index 57fe0aee6..b5d5c37a9 100644
--- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb
@@ -46,6 +46,7 @@ describe Api::V1::Accounts::CredentialsController do
         end
 
         it 'updates account info' do
+          user.reload
           user.account.reload
 
           expect(user.account.display_name).to eq("Alice Isn't Dead")
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 35c7326cb..bc6c6c0c5 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -88,21 +88,19 @@ describe ApplicationController, type: :controller do
 
     it 'returns instances\'s default theme when user didn\'t set theme' do
       current_user = Fabricate(:user)
+      current_user.settings.update(theme: 'contrast', noindex: false)
+      current_user.save
       sign_in current_user
 
-      allow(Setting).to receive(:[]).with('theme').and_return 'contrast'
-      allow(Setting).to receive(:[]).with('noindex').and_return false
-
       expect(controller.view_context.current_theme).to eq 'contrast'
     end
 
     it 'returns user\'s theme when it is set' do
       current_user = Fabricate(:user)
-      current_user.settings['theme'] = 'mastodon-light'
+      current_user.settings.update(theme: 'mastodon-light')
+      current_user.save
       sign_in current_user
 
-      allow(Setting).to receive(:[]).with('theme').and_return 'contrast'
-
       expect(controller.view_context.current_theme).to eq 'mastodon-light'
     end
   end
diff --git a/spec/controllers/settings/preferences/notifications_controller_spec.rb b/spec/controllers/settings/preferences/notifications_controller_spec.rb
index 66fb8c5eb..29b7b6aec 100644
--- a/spec/controllers/settings/preferences/notifications_controller_spec.rb
+++ b/spec/controllers/settings/preferences/notifications_controller_spec.rb
@@ -20,20 +20,22 @@ describe Settings::Preferences::NotificationsController do
 
   describe 'PUT #update' do
     it 'updates notifications settings' do
-      user.settings['notification_emails'] = user.settings['notification_emails'].merge('follow' => false)
-      user.settings['interactions'] = user.settings['interactions'].merge('must_be_follower' => true)
+      user.settings.update('notification_emails.follow': false, 'interactions.must_be_follower': true)
+      user.save
 
       put :update, params: {
         user: {
-          notification_emails: { follow: '1' },
-          interactions: { must_be_follower: '0' },
+          settings_attributes: {
+            'notification_emails.follow': '1',
+            'interactions.must_be_follower': '0',
+          },
         },
       }
 
       expect(response).to redirect_to(settings_preferences_notifications_path)
       user.reload
-      expect(user.settings['notification_emails']['follow']).to be true
-      expect(user.settings['interactions']['must_be_follower']).to be false
+      expect(user.settings['notification_emails.follow']).to be true
+      expect(user.settings['interactions.must_be_follower']).to be false
     end
   end
 end
diff --git a/spec/controllers/settings/preferences/other_controller_spec.rb b/spec/controllers/settings/preferences/other_controller_spec.rb
index 63eeefaf0..249d1b5b5 100644
--- a/spec/controllers/settings/preferences/other_controller_spec.rb
+++ b/spec/controllers/settings/preferences/other_controller_spec.rb
@@ -29,20 +29,22 @@ describe Settings::Preferences::OtherController do
     end
 
     it 'updates user settings' do
-      user.settings['boost_modal'] = false
-      user.settings['delete_modal'] = true
+      user.settings.update('web.reblog_modal': false, 'web.delete_modal': true)
+      user.save
 
       put :update, params: {
         user: {
-          setting_boost_modal: '1',
-          setting_delete_modal: '0',
+          settings_attributes: {
+            'web.reblog_modal': '1',
+            'web.delete_modal': '0',
+          },
         },
       }
 
       expect(response).to redirect_to(settings_preferences_other_path)
       user.reload
-      expect(user.settings['boost_modal']).to be true
-      expect(user.settings['delete_modal']).to be false
+      expect(user.settings['web.reblog_modal']).to be true
+      expect(user.settings['web.delete_modal']).to be false
     end
   end
 end
diff --git a/spec/lib/settings/extend_spec.rb b/spec/lib/settings/extend_spec.rb
deleted file mode 100644
index ea623137b..000000000
--- a/spec/lib/settings/extend_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Settings::Extend do
-  class User
-    include Settings::Extend
-  end
-
-  describe '#settings' do
-    it 'sets @settings as an instance of Settings::ScopedSettings' do
-      user = Fabricate(:user)
-      expect(user.settings).to be_a Settings::ScopedSettings
-    end
-  end
-end
diff --git a/spec/lib/settings/scoped_settings_spec.rb b/spec/lib/settings/scoped_settings_spec.rb
deleted file mode 100644
index 7566685b4..000000000
--- a/spec/lib/settings/scoped_settings_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Settings::ScopedSettings do
-  let(:object)         { Fabricate(:user) }
-  let(:scoped_setting) { described_class.new(object) }
-  let(:val)            { 'whatever' }
-  let(:methods)        { %i(auto_play_gif default_sensitive unfollow_modal boost_modal delete_modal reduce_motion system_font_ui noindex theme) }
-
-  describe '.initialize' do
-    it 'sets @object' do
-      scoped_setting = described_class.new(object)
-      expect(scoped_setting.instance_variable_get(:@object)).to be object
-    end
-  end
-
-  describe '#method_missing' do
-    it 'sets scoped_setting.method_name = val' do
-      methods.each do |key|
-        scoped_setting.send("#{key}=", val)
-        expect(scoped_setting.send(key)).to eq val
-      end
-    end
-  end
-
-  describe '#[]= and #[]' do
-    it 'sets [key] = val' do
-      methods.each do |key|
-        scoped_setting[key] = val
-        expect(scoped_setting[key]).to eq val
-      end
-    end
-  end
-end
diff --git a/spec/lib/user_settings_decorator_spec.rb b/spec/lib/user_settings_decorator_spec.rb
deleted file mode 100644
index 3b9b7ee2b..000000000
--- a/spec/lib/user_settings_decorator_spec.rb
+++ /dev/null
@@ -1,84 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe UserSettingsDecorator do
-  describe 'update' do
-    let(:user) { Fabricate(:user) }
-    let(:settings) { described_class.new(user) }
-
-    it 'updates the user settings value for email notifications' do
-      values = { 'notification_emails' => { 'follow' => '1' } }
-
-      settings.update(values)
-      expect(user.settings['notification_emails']['follow']).to be true
-    end
-
-    it 'updates the user settings value for interactions' do
-      values = { 'interactions' => { 'must_be_follower' => '0' } }
-
-      settings.update(values)
-      expect(user.settings['interactions']['must_be_follower']).to be false
-    end
-
-    it 'updates the user settings value for privacy' do
-      values = { 'setting_default_privacy' => 'public' }
-
-      settings.update(values)
-      expect(user.settings['default_privacy']).to eq 'public'
-    end
-
-    it 'updates the user settings value for sensitive' do
-      values = { 'setting_default_sensitive' => '1' }
-
-      settings.update(values)
-      expect(user.settings['default_sensitive']).to be true
-    end
-
-    it 'updates the user settings value for unfollow modal' do
-      values = { 'setting_unfollow_modal' => '0' }
-
-      settings.update(values)
-      expect(user.settings['unfollow_modal']).to be false
-    end
-
-    it 'updates the user settings value for boost modal' do
-      values = { 'setting_boost_modal' => '1' }
-
-      settings.update(values)
-      expect(user.settings['boost_modal']).to be true
-    end
-
-    it 'updates the user settings value for delete toot modal' do
-      values = { 'setting_delete_modal' => '0' }
-
-      settings.update(values)
-      expect(user.settings['delete_modal']).to be false
-    end
-
-    it 'updates the user settings value for gif auto play' do
-      values = { 'setting_auto_play_gif' => '0' }
-
-      settings.update(values)
-      expect(user.settings['auto_play_gif']).to be false
-    end
-
-    it 'updates the user settings value for system font in UI' do
-      values = { 'setting_system_font_ui' => '0' }
-
-      settings.update(values)
-      expect(user.settings['system_font_ui']).to be false
-    end
-
-    it 'decoerces setting values before applying' do
-      values = {
-        'setting_delete_modal' => 'false',
-        'setting_boost_modal' => 'true',
-      }
-
-      settings.update(values)
-      expect(user.settings['delete_modal']).to be false
-      expect(user.settings['boost_modal']).to be true
-    end
-  end
-end
diff --git a/spec/models/user_settings/namespace_spec.rb b/spec/models/user_settings/namespace_spec.rb
new file mode 100644
index 000000000..ae2fa7b48
--- /dev/null
+++ b/spec/models/user_settings/namespace_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe UserSettings::Namespace do
+  subject { described_class.new(name) }
+
+  let(:name) { :foo }
+
+  describe '#setting' do
+    before do
+      subject.setting :bar, default: 'baz'
+    end
+
+    it 'adds setting to definitions' do
+      expect(subject.definitions[:'foo.bar']).to have_attributes(name: :bar, namespace: :foo, default_value: 'baz')
+    end
+  end
+
+  describe '#definitions' do
+    it 'returns a hash' do
+      expect(subject.definitions).to be_a Hash
+    end
+  end
+end
diff --git a/spec/models/user_settings/setting_spec.rb b/spec/models/user_settings/setting_spec.rb
new file mode 100644
index 000000000..6e4ec6789
--- /dev/null
+++ b/spec/models/user_settings/setting_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe UserSettings::Setting do
+  subject { described_class.new(name, options) }
+
+  let(:name)      { :foo }
+  let(:options)   { { default: default, namespace: namespace } }
+  let(:default)   { false }
+  let(:namespace) { nil }
+
+  describe '#default_value' do
+    context 'when default value is a primitive value' do
+      it 'returns default value' do
+        expect(subject.default_value).to eq default
+      end
+    end
+
+    context 'when default value is a proc' do
+      let(:default) { -> { 'bar' } }
+
+      it 'returns value from proc' do
+        expect(subject.default_value).to eq 'bar'
+      end
+    end
+  end
+
+  describe '#type' do
+    it 'returns a type' do
+      expect(subject.type).to be_a ActiveModel::Type::Value
+    end
+  end
+
+  describe '#type_cast' do
+    context 'when default value is a boolean' do
+      let(:default) { false }
+
+      it 'returns boolean' do
+        expect(subject.type_cast('1')).to be true
+      end
+    end
+
+    context 'when default value is a string' do
+      let(:default) { '' }
+
+      it 'returns string' do
+        expect(subject.type_cast(1)).to eq '1'
+      end
+    end
+  end
+
+  describe '#to_a' do
+    it 'returns an array' do
+      expect(subject.to_a).to eq [name, default]
+    end
+  end
+
+  describe '#key' do
+    context 'when there is no namespace' do
+      it 'returnsn a symbol' do
+        expect(subject.key).to eq :foo
+      end
+    end
+
+    context 'when there is a namespace' do
+      let(:namespace) { :bar }
+
+      it 'returns a symbol' do
+        expect(subject.key).to eq :'bar.foo'
+      end
+    end
+  end
+end
diff --git a/spec/models/user_settings_spec.rb b/spec/models/user_settings_spec.rb
new file mode 100644
index 000000000..f0e4272fd
--- /dev/null
+++ b/spec/models/user_settings_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe UserSettings do
+  subject { described_class.new(json) }
+
+  let(:json) { {} }
+
+  describe '#[]' do
+    context 'when setting is not set' do
+      it 'returns default value' do
+        expect(subject[:always_send_emails]).to be false
+      end
+    end
+
+    context 'when setting is set' do
+      let(:json) { { default_language: 'fr' } }
+
+      it 'returns value' do
+        expect(subject[:default_language]).to eq 'fr'
+      end
+    end
+
+    context 'when setting was not defined' do
+      it 'raises error' do
+        expect { subject[:foo] }.to raise_error UserSettings::KeyError
+      end
+    end
+  end
+
+  describe '#[]=' do
+    context 'when value matches type' do
+      before do
+        subject[:always_send_emails] = true
+      end
+
+      it 'updates value' do
+        expect(subject[:always_send_emails]).to be true
+      end
+    end
+
+    context 'when value needs to be type-cast' do
+      before do
+        subject[:always_send_emails] = '1'
+      end
+
+      it 'updates value with a type-cast' do
+        expect(subject[:always_send_emails]).to be true
+      end
+    end
+  end
+
+  describe '#update' do
+    before do
+      subject.update(always_send_emails: true, default_language: 'fr', default_privacy: nil)
+    end
+
+    it 'updates values' do
+      expect(subject[:always_send_emails]).to be true
+      expect(subject[:default_language]).to eq 'fr'
+    end
+
+    it 'does not set values that are nil' do
+      expect(subject.as_json).to_not include(default_privacy: nil)
+    end
+  end
+
+  describe '#as_json' do
+    let(:json) { { default_language: 'fr' } }
+
+    it 'returns hash' do
+      expect(subject.as_json).to eq json
+    end
+  end
+
+  describe '.keys' do
+    it 'returns an array' do
+      expect(described_class.keys).to be_a Array
+    end
+  end
+
+  describe '.definition_for' do
+    context 'when key is defined' do
+      it 'returns a setting' do
+        expect(described_class.definition_for(:always_send_emails)).to be_a UserSettings::Setting
+      end
+    end
+
+    context 'when key is not defined' do
+      it 'returns nil' do
+        expect(described_class.definition_for(:foo)).to be_nil
+      end
+    end
+  end
+
+  describe '.definition_for?' do
+    context 'when key is defined' do
+      it 'returns true' do
+        expect(described_class.definition_for?(:always_send_emails)).to be true
+      end
+    end
+
+    context 'when key is not defined' do
+      it 'returns false' do
+        expect(described_class.definition_for?(:foo)).to be false
+      end
+    end
+  end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 3e7b59f17..ab883927a 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -313,9 +313,9 @@ RSpec.describe User, type: :model do
   end
 
   describe 'settings' do
-    it 'is instance of Settings::ScopedSettings' do
+    it 'is instance of UserSettings' do
       user = Fabricate(:user)
-      expect(user.settings).to be_a Settings::ScopedSettings
+      expect(user.settings).to be_a UserSettings
     end
   end
 
@@ -379,16 +379,6 @@ RSpec.describe User, type: :model do
     end
   end
 
-  it_behaves_like 'Settings-extended' do
-    def create!
-      User.create!(account: Fabricate(:account, user: nil), email: 'foo@mastodon.space', password: 'abcd1234', agreement: true)
-    end
-
-    def fabricate
-      Fabricate(:user)
-    end
-  end
-
   describe 'token_for_app' do
     let(:user) { Fabricate(:user) }
     let(:app) { Fabricate(:application, owner: user) }
diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb
index c58cebbfb..616a7aa20 100644
--- a/spec/services/notify_service_spec.rb
+++ b/spec/services/notify_service_spec.rb
@@ -54,7 +54,8 @@ RSpec.describe NotifyService, type: :service do
     let(:type)     { :mention }
 
     before do
-      user.settings.interactions = user.settings.interactions.merge('must_be_following_dm' => enabled)
+      user.settings.update('interactions.must_be_following_dm': enabled)
+      user.save
     end
 
     context 'if recipient is supposed to be following sender' do
@@ -155,8 +156,8 @@ RSpec.describe NotifyService, type: :service do
     before do
       ActionMailer::Base.deliveries.clear
 
-      notification_emails = user.settings.notification_emails
-      user.settings.notification_emails = notification_emails.merge('follow' => enabled)
+      user.settings.update('notification_emails.follow': enabled)
+      user.save
     end
 
     context 'when email notification is enabled' do
diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb
index 9d81bd971..452400f72 100644
--- a/spec/services/report_service_spec.rb
+++ b/spec/services/report_service_spec.rb
@@ -96,7 +96,8 @@ RSpec.describe ReportService, type: :service do
 
     before do
       ActionMailer::Base.deliveries.clear
-      source_account.user.settings.notification_emails['report'] = true
+      source_account.user.settings['notification_emails.report'] = true
+      source_account.user.save
     end
 
     it 'does not send an e-mail' do