about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb2
-rw-r--r--app/controllers/concerns/cache_concern.rb4
-rw-r--r--app/javascript/flavours/glitch/actions/store.js17
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js8
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js10
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js1
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js10
-rw-r--r--app/javascript/mastodon/features/notifications/index.js4
-rw-r--r--app/javascript/mastodon/reducers/settings.js1
-rw-r--r--app/lib/entity_cache.rb4
-rw-r--r--app/lib/exceptions.rb23
-rw-r--r--app/lib/formatter.rb5
-rw-r--r--app/lib/sanitize_config.rb139
-rw-r--r--app/mailers/notification_mailer.rb2
-rw-r--r--app/mailers/user_mailer.rb2
-rw-r--r--app/models/account_stat.rb42
-rw-r--r--app/models/concerns/account_counters.rb60
-rw-r--r--app/models/notification.rb12
-rw-r--r--app/validators/url_validator.rb2
21 files changed, 118 insertions, 235 deletions
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index ba927b04a..b140c454c 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -22,7 +22,7 @@ module Admin
       if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
         @domain_block.save
         flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
-        @domain_block.errors[:domain].clear
+        @domain_block.errors.delete(:domain)
         render :new
       else
         if existing_domain_block.present?
diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb
index 3fb4b962a..05e431b19 100644
--- a/app/controllers/concerns/cache_concern.rb
+++ b/app/controllers/concerns/cache_concern.rb
@@ -31,7 +31,9 @@ module CacheConcern
   def cache_collection(raw, klass)
     return raw unless klass.respond_to?(:with_includes)
 
-    raw                    = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
+    raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
+    return [] if raw.empty?
+
     cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id)
     uncached_ids           = raw.map(&:id) - cached_keys_with_value.keys
 
diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js
index 34dcafc51..9dbc0b214 100644
--- a/app/javascript/flavours/glitch/actions/store.js
+++ b/app/javascript/flavours/glitch/actions/store.js
@@ -1,6 +1,7 @@
 import { Iterable, fromJS } from 'immutable';
 import { hydrateCompose } from './compose';
 import { importFetchedAccounts } from './importer';
+import { saveSettings } from './settings';
 
 export const STORE_HYDRATE = 'STORE_HYDRATE';
 export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@@ -9,9 +10,22 @@ const convertState = rawState =>
   fromJS(rawState, (k, v) =>
     Iterable.isIndexed(v) ? v.toList() : v.toMap());
 
+const applyMigrations = (state) => {
+  return state.withMutations(state => {
+    // Migrate glitch-soc local-only “Show unread marker” setting to Mastodon's setting
+    if (state.getIn(['local_settings', 'notifications', 'show_unread']) !== undefined) {
+      // Only change if the Mastodon setting does not deviate from default
+      if (state.getIn(['settings', 'notifications', 'showUnread']) !== false) {
+        state.setIn(['settings', 'notifications', 'showUnread'], state.getIn(['local_settings', 'notifications', 'show_unread']));
+      }
+      state.removeIn(['local_settings', 'notifications', 'show_unread'])
+    }
+  });
+};
+
 export function hydrateStore(rawState) {
   return dispatch => {
-    const state = convertState(rawState);
+    const state = applyMigrations(convertState(rawState));
 
     dispatch({
       type: STORE_HYDRATE,
@@ -20,5 +34,6 @@ export function hydrateStore(rawState) {
 
     dispatch(hydrateCompose());
     dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
+    dispatch(saveSettings());
   };
 };
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 3af6cbdf6..45d10d154 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -113,14 +113,6 @@ class LocalSettingsPage extends React.PureComponent {
             <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' />
             <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span>
           </LocalSettingsPageItem>
-          <LocalSettingsPageItem
-            settings={settings}
-            item={['notifications', 'show_unread']}
-            id='mastodon-settings--notifications-show_unread'
-            onChange={onChange}
-          >
-            <FormattedMessage id='settings.notifications.show_unread' defaultMessage='Show unread marker' />
-          </LocalSettingsPageItem>
         </section>
         <section>
           <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index e502c3173..067696332 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -56,6 +56,16 @@ export default class ColumnSettings extends React.PureComponent {
           <ClearColumnButton onClick={onClear} />
         </div>
 
+        <div role='group' aria-labelledby='notifications-unread-markers'>
+          <span id='notifications-unread-markers' className='column-settings__section'>
+            <FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' />
+          </span>
+
+          <div className='column-settings__row'>
+            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} />
+          </div>
+        </div>
+
         <div role='group' aria-labelledby='notifications-filter-bar'>
           <span id='notifications-filter-bar' className='column-settings__section'>
             <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 5ceda9a91..842e02371 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -67,8 +67,8 @@ const mapStateToProps = state => ({
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
-  lastReadId: state.getIn(['local_settings', 'notifications', 'show_unread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
-  canMarkAsRead: state.getIn(['local_settings', 'notifications', 'show_unread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
+  canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
   needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
 });
 
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index ea37ae4aa..c115cad6b 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -55,7 +55,6 @@ const initialState = ImmutableMap({
   notifications : ImmutableMap({
     favicon_badge : false,
     tab_badge     : true,
-    show_unread   : true,
   }),
 });
 
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index 091b8feec..a53d34a83 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -49,6 +49,7 @@ const initialState = ImmutableMap({
     }),
 
     dismissPermissionBanner: false,
+    showUnread: true,
 
     shows: ImmutableMap({
       follow: true,
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 8339a367e..0c24c3294 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -55,6 +55,16 @@ export default class ColumnSettings extends React.PureComponent {
           <ClearColumnButton onClick={onClear} />
         </div>
 
+        <div role='group' aria-labelledby='notifications-unread-markers'>
+          <span id='notifications-unread-markers' className='column-settings__section'>
+            <FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' />
+          </span>
+
+          <div className='column-settings__row'>
+            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} />
+          </div>
+        </div>
+
         <div role='group' aria-labelledby='notifications-filter-bar'>
           <span id='notifications-filter-bar' className='column-settings__section'>
             <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 108470c9a..cf8fd7127 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -60,8 +60,8 @@ const mapStateToProps = state => ({
   isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
-  lastReadId: state.getIn(['notifications', 'readMarkerId']),
-  canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
+  canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
   needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
 });
 
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 357ab352a..2a89919e1 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -45,6 +45,7 @@ const initialState = ImmutableMap({
     }),
 
     dismissPermissionBanner: false,
+    showUnread: true,
 
     shows: ImmutableMap({
       follow: true,
diff --git a/app/lib/entity_cache.rb b/app/lib/entity_cache.rb
index 5d51e8585..80b0046ee 100644
--- a/app/lib/entity_cache.rb
+++ b/app/lib/entity_cache.rb
@@ -16,7 +16,9 @@ class EntityCache
   end
 
   def emoji(shortcodes, domain)
-    shortcodes   = Array(shortcodes)
+    shortcodes = Array(shortcodes)
+    return [] if shortcodes.empty?
+
     cached       = Rails.cache.read_multi(*shortcodes.map { |shortcode| to_key(:emoji, shortcode, domain) })
     uncached_ids = []
 
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
deleted file mode 100644
index 7c8e77871..000000000
--- a/app/lib/exceptions.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Mastodon
-  class Error < StandardError; end
-  class NotPermittedError < Error; end
-  class ValidationError < Error; end
-  class HostValidationError < ValidationError; end
-  class LengthValidationError < ValidationError; end
-  class DimensionsValidationError < ValidationError; end
-  class StreamValidationError < ValidationError; end
-  class RaceConditionError < Error; end
-  class RateLimitExceededError < Error; end
-
-  class UnexpectedResponseError < Error
-    def initialize(response = nil)
-      if response.respond_to? :uri
-        super("#{response.uri} returned code #{response.code}")
-      else
-        super
-      end
-    end
-  end
-end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 9a3e63d46..02ebe6f89 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -1,7 +1,6 @@
 # frozen_string_literal: true
 
 require 'singleton'
-require_relative './sanitize_config'
 
 class HTMLRenderer < Redcarpet::Render::HTML
   def block_code(code, language)
@@ -223,9 +222,9 @@ class Formatter
           original_url, static_url = emoji
           replacement = begin
             if animate
-              "<img draggable=\"false\" class=\"emojione\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(original_url)}\" />"
+              image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:")
             else
-              "<img draggable=\"false\" class=\"emojione custom-emoji\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(static_url)}\" data-original=\"#{original_url}\" data-static=\"#{static_url}\" />"
+              image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url })
             end
           end
           before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
deleted file mode 100644
index ecaec2f84..000000000
--- a/app/lib/sanitize_config.rb
+++ /dev/null
@@ -1,139 +0,0 @@
-# frozen_string_literal: true
-
-class Sanitize
-  module Config
-    HTTP_PROTOCOLS = %w(
-      http
-      https
-    ).freeze
-
-    LINK_PROTOCOLS = %w(
-      http
-      https
-      dat
-      dweb
-      ipfs
-      ipns
-      ssb
-      gopher
-      xmpp
-      magnet
-      gemini
-    ).freeze
-
-    CLASS_WHITELIST_TRANSFORMER = lambda do |env|
-      node = env[:node]
-      class_list = node['class']&.split(/[\t\n\f\r ]/)
-
-      return unless class_list
-
-      class_list.keep_if do |e|
-        next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes
-        next true if /^(mention|hashtag)$/.match?(e) # semantic classes
-        next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes
-      end
-
-      node['class'] = class_list.join(' ')
-    end
-
-    IMG_TAG_TRANSFORMER = lambda do |env|
-      node = env[:node]
-
-      return unless env[:node_name] == 'img'
-
-      node.name = 'a'
-
-      node['href'] = node['src']
-      if node['alt'].present?
-        node.content = "[🖼  #{node['alt']}]"
-      else
-        url = node['href']
-        prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s
-        text   = url[prefix.length, 30]
-        text   = text + "…" if url[prefix.length..-1].length > 30
-        node.content = "[🖼  #{text}]"
-      end
-    end
-
-    LINK_REL_TRANSFORMER = lambda do |env|
-      return unless env[:node_name] == 'a' and env[:node]['href']
-
-      node = env[:node]
-
-      rel = (node['rel'] || '').split(' ') & ['tag']
-      unless env[:config][:outgoing] && TagManager.instance.local_url?(node['href'])
-        rel += ['nofollow', 'noopener', 'noreferrer']
-      end
-      node['rel'] = rel.join(' ')
-    end
-
-    UNSUPPORTED_HREF_TRANSFORMER = lambda do |env|
-      return unless env[:node_name] == 'a'
-
-      current_node = env[:node]
-
-      scheme = begin
-        if current_node['href'] =~ Sanitize::REGEX_PROTOCOL
-          Regexp.last_match(1).downcase
-        else
-          :relative
-        end
-      end
-
-      current_node.replace(current_node.text) unless LINK_PROTOCOLS.include?(scheme)
-    end
-
-    MASTODON_STRICT ||= freeze_config(
-      elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li),
-
-      attributes: {
-        'a'          => %w(href rel class title),
-        'span'       => %w(class),
-        'abbr'       => %w(title),
-        'blockquote' => %w(cite),
-        'ol'         => %w(start reversed),
-        'li'         => %w(value),
-      },
-
-      add_attributes: {
-        'a' => {
-          'target' => '_blank',
-        },
-      },
-
-      protocols: {
-        'a'          => { 'href' => LINK_PROTOCOLS },
-        'blockquote' => { 'cite' => LINK_PROTOCOLS },
-      },
-
-      transformers: [
-        CLASS_WHITELIST_TRANSFORMER,
-        IMG_TAG_TRANSFORMER,
-        UNSUPPORTED_HREF_TRANSFORMER,
-        LINK_REL_TRANSFORMER,
-      ]
-    )
-
-    MASTODON_OEMBED ||= freeze_config merge(
-      RELAXED,
-      elements: RELAXED[:elements] + %w(audio embed iframe source video),
-
-      attributes: merge(
-        RELAXED[:attributes],
-        'audio'  => %w(controls),
-        'embed'  => %w(height src type width),
-        'iframe' => %w(allowfullscreen frameborder height scrolling src width),
-        'source' => %w(src type),
-        'video'  => %w(controls height loop width),
-        'div'    => [:data]
-      ),
-
-      protocols: merge(
-        RELAXED[:protocols],
-        'embed'  => { 'src' => HTTP_PROTOCOLS },
-        'iframe' => { 'src' => HTTP_PROTOCOLS },
-        'source' => { 'src' => HTTP_PROTOCOLS }
-      )
-    )
-  end
-end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 54db892cc..9e683b6a1 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -4,7 +4,7 @@ class NotificationMailer < ApplicationMailer
   helper :accounts
   helper :statuses
 
-  add_template_helper RoutingHelper
+  helper RoutingHelper
 
   def mention(recipient, notification)
     @me     = recipient
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 95996ba3f..68d1c4507 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -8,7 +8,7 @@ class UserMailer < Devise::Mailer
   helper :instance
   helper :statuses
 
-  add_template_helper RoutingHelper
+  helper RoutingHelper
 
   def confirmation_instructions(user, token, **)
     @resource = user
diff --git a/app/models/account_stat.rb b/app/models/account_stat.rb
index e70b54d79..a826a9af3 100644
--- a/app/models/account_stat.rb
+++ b/app/models/account_stat.rb
@@ -18,46 +18,4 @@ class AccountStat < ApplicationRecord
   belongs_to :account, inverse_of: :account_stat
 
   update_index('accounts#account', :account)
-
-  def increment_count!(key)
-    update(attributes_for_increment(key))
-  rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotUnique
-    begin
-      reload_with_id
-    rescue ActiveRecord::RecordNotFound
-      return
-    end
-
-    retry
-  end
-
-  def decrement_count!(key)
-    update(attributes_for_decrement(key))
-  rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotUnique
-    begin
-      reload_with_id
-    rescue ActiveRecord::RecordNotFound
-      return
-    end
-
-    retry
-  end
-
-  private
-
-  def attributes_for_increment(key)
-    attrs = { key => public_send(key) + 1 }
-    attrs[:last_status_at] = Time.now.utc if key == :statuses_count
-    attrs
-  end
-
-  def attributes_for_decrement(key)
-    attrs = { key => [public_send(key) - 1, 0].max }
-    attrs
-  end
-
-  def reload_with_id
-    self.id = self.class.find_by!(account: account).id if new_record?
-    reload
-  end
 end
diff --git a/app/models/concerns/account_counters.rb b/app/models/concerns/account_counters.rb
index 6e25e1905..fd3f161ad 100644
--- a/app/models/concerns/account_counters.rb
+++ b/app/models/concerns/account_counters.rb
@@ -3,6 +3,8 @@
 module AccountCounters
   extend ActiveSupport::Concern
 
+  ALLOWED_COUNTER_KEYS = %i(statuses_count following_count followers_count).freeze
+
   included do
     has_one :account_stat, inverse_of: :account
     after_save :save_account_stat
@@ -14,11 +16,65 @@ module AccountCounters
            :following_count=,
            :followers_count,
            :followers_count=,
-           :increment_count!,
-           :decrement_count!,
            :last_status_at,
            to: :account_stat
 
+  # @param [Symbol] key
+  def increment_count!(key)
+    update_count!(key, 1)
+  end
+
+  # @param [Symbol] key
+  def decrement_count!(key)
+    update_count!(key, -1)
+  end
+
+  # @param [Symbol] key
+  # @param [Integer] value
+  def update_count!(key, value)
+    raise ArgumentError, "Invalid key #{key}" unless ALLOWED_COUNTER_KEYS.include?(key)
+    raise ArgumentError, 'Do not call update_count! on dirty objects' if association(:account_stat).loaded? && account_stat&.changed? && account_stat.changed_attribute_names_to_save == %w(id)
+
+    value = value.to_i
+    default_value = value.positive? ? value : 0
+
+    # We do an upsert using manually written SQL, as Rails' upsert method does
+    # not seem to support writing expressions in the UPDATE clause, but only
+    # re-insert the provided values instead.
+    # Even ARel seem to be missing proper handling of upserts.
+    sql = if value.positive? && key == :statuses_count
+            <<-SQL.squish
+              INSERT INTO account_stats(account_id, #{key}, created_at, updated_at, last_status_at)
+                VALUES (:account_id, :default_value, now(), now(), now())
+              ON CONFLICT (account_id) DO UPDATE
+              SET #{key} = account_stats.#{key} + :value,
+                  last_status_at = now(),
+                  lock_version = account_stats.lock_version + 1,
+                  updated_at = now()
+              RETURNING id;
+            SQL
+          else
+            <<-SQL.squish
+              INSERT INTO account_stats(account_id, #{key}, created_at, updated_at)
+                VALUES (:account_id, :default_value, now(), now())
+              ON CONFLICT (account_id) DO UPDATE
+              SET #{key} = account_stats.#{key} + :value,
+                  lock_version = account_stats.lock_version + 1,
+                  updated_at = now()
+              RETURNING id;
+            SQL
+          end
+
+    sql = AccountStat.sanitize_sql([sql, account_id: id, default_value: default_value, value: value])
+    account_stat_id = AccountStat.connection.exec_query(sql)[0]['id']
+
+    # Reload account_stat if it was loaded, taking into account newly-created unsaved records
+    if association(:account_stat).loaded?
+      account_stat.id = account_stat_id if account_stat.new_record?
+      account_stat.reload
+    end
+  end
+
   def account_stat
     super || build_account_stat
   end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 98a6a618f..3bf9dd483 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -49,12 +49,12 @@ class Notification < ApplicationRecord
   belongs_to :from_account, class_name: 'Account', optional: true
   belongs_to :activity, polymorphic: true, optional: true
 
-  belongs_to :mention,        foreign_type: 'Mention',       foreign_key: 'activity_id', optional: true
-  belongs_to :status,         foreign_type: 'Status',        foreign_key: 'activity_id', optional: true
-  belongs_to :follow,         foreign_type: 'Follow',        foreign_key: 'activity_id', optional: true
-  belongs_to :follow_request, foreign_type: 'FollowRequest', foreign_key: 'activity_id', optional: true
-  belongs_to :favourite,      foreign_type: 'Favourite',     foreign_key: 'activity_id', optional: true
-  belongs_to :poll,           foreign_type: 'Poll',          foreign_key: 'activity_id', optional: true
+  belongs_to :mention,        foreign_key: 'activity_id', optional: true
+  belongs_to :status,         foreign_key: 'activity_id', optional: true
+  belongs_to :follow,         foreign_key: 'activity_id', optional: true
+  belongs_to :follow_request, foreign_key: 'activity_id', optional: true
+  belongs_to :favourite,      foreign_key: 'activity_id', optional: true
+  belongs_to :poll,           foreign_key: 'activity_id', optional: true
 
   validates :type, inclusion: { in: TYPES }
 
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
index d95a03fbf..f50abbe24 100644
--- a/app/validators/url_validator.rb
+++ b/app/validators/url_validator.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class UrlValidator < ActiveModel::EachValidator
+class URLValidator < ActiveModel::EachValidator
   def validate_each(record, attribute, value)
     record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value)
   end