about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2021-04-02 15:04:35 -0500
committerStarfall <us@starfall.systems>2021-04-02 15:04:35 -0500
commitaeb0f34cefd88caaaa51e8250e1f6ddde280c4bb (patch)
tree15dafdc2cdfd9e78e72e461440b593c3fc89788e /app
parent0f7be4b48947a9edcbb6fb84d5d0fd9150ee0870 (diff)
parentb7ec2a900251410c65ba214b50c1657209285b07 (diff)
Merge branch 'glitch'
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/dashboard_controller.rb6
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb2
-rw-r--r--app/controllers/admin/tags_controller.rb4
-rw-r--r--app/controllers/api/v1/emails/confirmations_controller.rb6
-rw-r--r--app/controllers/application_controller.rb6
-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/compose/components/compose_form.js10
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js2
-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.js82
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js41
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js21
-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/flavours/glitch/styles/components/columns.scss67
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js8
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js2
-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/features/ui/components/columns_area.js21
-rw-r--r--app/javascript/mastodon/reducers/settings.js1
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss1
-rw-r--r--app/javascript/styles/mastodon/components.scss1
-rw-r--r--app/lib/activitypub/activity/announce.rb4
-rw-r--r--app/lib/activitypub/activity/create.rb6
-rw-r--r--app/lib/activitypub/tag_manager.rb4
-rw-r--r--app/lib/delivery_failure_tracker.rb2
-rw-r--r--app/lib/entity_cache.rb4
-rw-r--r--app/lib/exceptions.rb23
-rw-r--r--app/lib/feed_manager.rb12
-rw-r--r--app/lib/formatter.rb5
-rw-r--r--app/lib/sanitize_config.rb140
-rw-r--r--app/lib/settings/scoped_settings.rb2
-rw-r--r--app/mailers/notification_mailer.rb2
-rw-r--r--app/mailers/user_mailer.rb2
-rw-r--r--app/models/account.rb8
-rw-r--r--app/models/account_stat.rb42
-rw-r--r--app/models/concerns/account_counters.rb60
-rw-r--r--app/models/concerns/account_interactions.rb2
-rw-r--r--app/models/concerns/expireable.rb2
-rw-r--r--app/models/concerns/omniauthable.rb1
-rw-r--r--app/models/direct_feed.rb2
-rw-r--r--app/models/notification.rb12
-rw-r--r--app/models/report.rb2
-rw-r--r--app/models/user.rb2
-rw-r--r--app/services/delete_account_service.rb3
-rw-r--r--app/services/import_service.rb4
-rw-r--r--app/validators/email_mx_validator.rb8
-rw-r--r--app/validators/url_validator.rb2
-rw-r--r--app/views/admin/action_logs/_action_log.html.haml2
-rw-r--r--app/views/admin/reports/_action_log.html.haml2
-rw-r--r--app/views/filters/_fields.html.haml2
-rwxr-xr-xapp/views/layouts/application.html.haml2
-rw-r--r--app/views/settings/exports/show.html.haml2
-rw-r--r--app/workers/scheduler/backup_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/doorkeeper_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/email_scheduler.rb2
-rw-r--r--app/workers/scheduler/feed_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/instance_refresh_scheduler.rb2
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/media_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/pghero_scheduler.rb2
-rw-r--r--app/workers/scheduler/scheduled_statuses_scheduler.rb2
-rw-r--r--app/workers/scheduler/trending_tags_scheduler.rb2
-rw-r--r--app/workers/scheduler/user_cleanup_scheduler.rb2
67 files changed, 383 insertions, 335 deletions
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 4116f99f4..e87dd076f 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -3,7 +3,13 @@ require 'sidekiq/api'
 
 module Admin
   class DashboardController < BaseController
+    SIDEKIQ_QUEUES = %w(default push mailers pull scheduler).freeze
+
     def index
+      missing_queues = Sidekiq::ProcessSet.new.reduce(SIDEKIQ_QUEUES) { |queues, process| queues - process['queues'] }
+
+      flash.now[:alert] = I18n.t('admin.dashboard.misconfigured_sidekiq_alert', queues: missing_queues.join(', ')) unless missing_queues.empty?
+
       @users_count           = User.count
       @pending_users_count   = User.pending.count
       @registrations_week    = Redis.current.get("activity:accounts:local:#{current_week}") || 0
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/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb
index 59df4470e..eed4feea2 100644
--- a/app/controllers/admin/tags_controller.rb
+++ b/app/controllers/admin/tags_controller.rb
@@ -59,8 +59,8 @@ module Admin
                              .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
                              .joins(:account)
                              .group('accounts.domain')
-                             .reorder('statuses_count desc')
-                             .pluck('accounts.domain, count(*) AS statuses_count')
+                             .reorder(statuses_count: :desc)
+                             .pluck(Arel.sql('accounts.domain, count(*) AS statuses_count'))
     end
 
     def set_counters
diff --git a/app/controllers/api/v1/emails/confirmations_controller.rb b/app/controllers/api/v1/emails/confirmations_controller.rb
index 03ab5de8c..4a7aa9c32 100644
--- a/app/controllers/api/v1/emails/confirmations_controller.rb
+++ b/app/controllers/api/v1/emails/confirmations_controller.rb
@@ -5,7 +5,11 @@ class Api::V1::Emails::ConfirmationsController < Api::BaseController
   before_action :require_user_owned_by_application!
 
   def create
-    current_user.resend_confirmation_instructions if current_user.unconfirmed_email.present?
+    if !current_user.confirmed? && current_user.unconfirmed_email.present?
+      current_user.update!(email: params[:email]) if params.key?(:email)
+      current_user.resend_confirmation_instructions
+    end
+
     render_empty
   end
 
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7e97009cf..7435d78bf 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -5,8 +5,6 @@ class ApplicationController < ActionController::Base
   # For APIs, you may want to use :null_session instead.
   protect_from_forgery with: :exception
 
-  force_ssl if: :https_enabled?
-
   include Localized
   include UserTrackingConcern
   include SessionTrackingConcern
@@ -43,10 +41,6 @@ class ApplicationController < ActionController::Base
 
   private
 
-  def https_enabled?
-    Rails.env.production? && !request.path.start_with?('/health') && !request.headers["Host"].end_with?(".onion")
-  end
-
   def authorized_fetch_mode?
     ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode
   end
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/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index 164f4a960..d4804a3c2 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -199,6 +199,14 @@ class ComposeForm extends ImmutablePureComponent {
     }
   }
 
+  componentDidMount () {
+    this._updateFocusAndSelection({ });
+  }
+
+  componentDidUpdate (prevProps) {
+    this._updateFocusAndSelection(prevProps);
+  }
+
   //  This statement does several things:
   //  - If we're beginning a reply, and,
   //      - Replying to zero or one users, places the cursor at the end
@@ -206,7 +214,7 @@ class ComposeForm extends ImmutablePureComponent {
   //      - Replying to more than one user, selects any usernames past
   //        the first; this provides a convenient shortcut to drop
   //        everyone else from the conversation.
-  componentDidUpdate (prevProps) {
+   _updateFocusAndSelection = (prevProps) => {
     const {
       textarea,
       spoilerText,
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
index 27300f020..de1127b0d 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
@@ -59,7 +59,7 @@ class ColumnSettings extends React.PureComponent {
           {this.modeLabel(mode)}
         </span>
 
-        <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}>
+        <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='tags'>
           <AsyncSelect
             isMulti
             autoFocus
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..c01a21e3b 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
 import ClearColumnButton from './clear_column_button';
 import GrantPermissionButton from './grant_permission_button';
 import SettingToggle from './setting_toggle';
+import PillBarButton from './pill_bar_button';
 
 export default class ColumnSettings extends React.PureComponent {
 
@@ -34,7 +35,6 @@ export default class ColumnSettings extends React.PureComponent {
 
     const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
     const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
-    const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
 
     return (
       <div>
@@ -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' />
@@ -70,77 +80,77 @@ export default class ColumnSettings extends React.PureComponent {
         <div role='group' aria-labelledby='notifications-follow'>
           <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
-          <div className='column-settings__row'>
-            <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
-            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
           </div>
         </div>
 
         <div role='group' aria-labelledby='notifications-follow-request'>
           <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
 
-          <div className='column-settings__row'>
-            <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
-            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
           </div>
         </div>
 
         <div role='group' aria-labelledby='notifications-favourite'>
           <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
-          <div className='column-settings__row'>
-            <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
-            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
           </div>
         </div>
 
         <div role='group' aria-labelledby='notifications-mention'>
           <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
-          <div className='column-settings__row'>
-            <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
-            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
           </div>
         </div>
 
         <div role='group' aria-labelledby='notifications-reblog'>
           <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
-          <div className='column-settings__row'>
-            <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
-            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
           </div>
         </div>
 
         <div role='group' aria-labelledby='notifications-poll'>
           <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
 
-          <div className='column-settings__row'>
-            <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
-            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
           </div>
         </div>
 
         <div role='group' aria-labelledby='notifications-status'>
           <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
 
-          <div className='column-settings__row'>
-            <SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
-            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
-            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__pillbar'>
+            <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />}
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
+            <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
           </div>
         </div>
       </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js b/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js
new file mode 100644
index 000000000..223b7f75f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import classNames from 'classnames'
+
+export default class PillBarButton extends React.PureComponent {
+
+  static propTypes = {
+    prefix: PropTypes.string,
+    settings: ImmutablePropTypes.map.isRequired,
+    settingPath: PropTypes.array.isRequired,
+    label: PropTypes.node.isRequired,
+    onChange: PropTypes.func.isRequired,
+    disabled: PropTypes.bool,
+  }
+
+  onChange = () => {
+    const { settings, settingPath } = this.props;
+    this.props.onChange(settingPath, !settings.getIn(settingPath));
+  }
+
+  render () {
+    const { prefix, settings, settingPath, label, disabled } = this.props;
+    const id = ['setting-pillbar-button', prefix, ...settingPath].filter(Boolean).join('-');
+    const active = settings.getIn(settingPath);
+
+    return (
+      <button
+        key={id}
+        id={id}
+        className={classNames('pillbar-button', { active })}
+        disabled={disabled}
+        onClick={this.onChange}
+        aria-pressed={active}
+      >
+        {label}
+      </button>
+    );
+  }
+
+}
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/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 640be19ab..b41de58d7 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -70,8 +70,12 @@ class ColumnsArea extends ImmutablePureComponent {
     openSettings: PropTypes.func,
   };
 
+   // Corresponds to (max-width: 600px + (285px * 1) + (10px * 1)) in SCSS
+   mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 895px)');
+
   state = {
     shouldAnimate: false,
+    renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
   }
 
   componentWillReceiveProps() {
@@ -85,6 +89,11 @@ class ColumnsArea extends ImmutablePureComponent {
       this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
 
+    if (this.mediaQuery) {
+      this.mediaQuery.addEventListener('change', this.handleLayoutChange);
+      this.setState({ renderComposePanel: !this.mediaQuery.matches });
+    }
+
     this.lastIndex   = getIndex(this.context.router.history.location.pathname);
     this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
 
@@ -114,6 +123,10 @@ class ColumnsArea extends ImmutablePureComponent {
     if (!this.props.singleColumn) {
       this.node.removeEventListener('wheel', this.handleWheel);
     }
+
+    if (this.mediaQuery) {
+      this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
+    }
   }
 
   handleChildrenContentChange() {
@@ -123,6 +136,10 @@ class ColumnsArea extends ImmutablePureComponent {
     }
   }
 
+  handleLayoutChange = (e) => {
+    this.setState({ renderComposePanel: !e.matches });
+  }
+
   handleSwipe = (index) => {
     this.pendingIndex = index;
 
@@ -186,7 +203,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   render () {
     const { columns, children, singleColumn, swipeToChangeColumns, intl, navbarUnder, openSettings } = this.props;
-    const { shouldAnimate } = this.state;
+    const { shouldAnimate, renderComposePanel } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
 
@@ -205,7 +222,7 @@ class ColumnsArea extends ImmutablePureComponent {
         <div className='columns-area__panels'>
           <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
             <div className='columns-area__panels__pane__inner'>
-              <ComposePanel />
+              {renderComposePanel && <ComposePanel />}
             </div>
           </div>
 
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/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 90aa5859d..42d64c135 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -483,6 +483,73 @@
   margin-right: 5px;
 }
 
+.column-settings__pillbar {
+  display: flex;
+  overflow: hidden;
+  background-color: transparent;
+  border: 0;
+  border-radius: 4px;
+  margin-bottom: 10px;
+  align-items: stretch;
+}
+
+.pillbar-button {
+  border: 0;
+  color: #fafafa;
+  padding: 2px;
+  margin: 0;
+  margin-left: 2px;
+  font-size: inherit;
+  flex: auto;
+  background-color: $ui-base-color;
+  transition-property: background-color, box-shadow;
+  transition: all 0.2s ease;
+
+  &[disabled] {
+    cursor: not-allowed;
+    opacity: 0.5;
+  }
+
+  &:not([disabled]) {
+    &:hover {
+      background-color: darken($ui-base-color, 10%);
+    }
+
+    &:focus {
+      background-color: darken($ui-base-color, 15%);
+    }
+
+    &:active {
+      background-color: darken($ui-base-color, 20%);
+    }
+
+    &.active {
+      background-color: $ui-highlight-color;
+      box-shadow: inset 0 5px darken($ui-highlight-color, 20%);
+
+      &:hover {
+        background-color: lighten($ui-highlight-color, 10%);
+        box-shadow: inset 0 5px darken($ui-highlight-color, 10%);
+      }
+
+      &:focus {
+        background-color: lighten($ui-highlight-color, 15%);
+        box-shadow: inset 0 5px darken($ui-highlight-color, 5%);
+      }
+
+      &:active {
+        background-color: lighten($ui-highlight-color, 20%);
+        box-shadow: inset 0 5px $ui-highlight-color;
+      }
+    }
+  }
+
+  /* TODO: check RTL? */
+  &:first-child {
+    margin-left: 0;
+  }
+}
+
 .empty-column-indicator,
 .error-column,
 .follow_requests-unlocked_explanation {
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index adec8dc8b..9d8732a8c 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -133,7 +133,15 @@ class ComposeForm extends ImmutablePureComponent {
     }
   }
 
+  componentDidMount () {
+    this._updateFocusAndSelection({ });
+  }
+
   componentDidUpdate (prevProps) {
+    this._updateFocusAndSelection(prevProps);
+  }
+
+  _updateFocusAndSelection = (prevProps) => {
     // This statement does several things:
     // - If we're beginning a reply, and,
     //     - Replying to zero or one users, places the cursor at the end of the textbox.
diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
index 27300f020..de1127b0d 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
@@ -59,7 +59,7 @@ class ColumnSettings extends React.PureComponent {
           {this.modeLabel(mode)}
         </span>
 
-        <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}>
+        <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='tags'>
           <AsyncSelect
             isMulti
             autoFocus
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/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 6837450eb..85a92fc3a 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -70,8 +70,12 @@ class ColumnsArea extends ImmutablePureComponent {
     children: PropTypes.node,
   };
 
+   // Corresponds to (max-width: 600px + (285px * 1) + (10px * 1)) in SCSS
+   mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 895px)');
+
   state = {
     shouldAnimate: false,
+    renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
   }
 
   componentWillReceiveProps() {
@@ -85,6 +89,11 @@ class ColumnsArea extends ImmutablePureComponent {
       this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
 
+    if (this.mediaQuery) {
+      this.mediaQuery.addEventListener('change', this.handleLayoutChange);
+      this.setState({ renderComposePanel: !this.mediaQuery.matches });
+    }
+
     this.lastIndex   = getIndex(this.context.router.history.location.pathname);
     this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
 
@@ -114,6 +123,10 @@ class ColumnsArea extends ImmutablePureComponent {
     if (!this.props.singleColumn) {
       this.node.removeEventListener('wheel', this.handleWheel);
     }
+
+    if (this.mediaQuery) {
+      this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
+    }
   }
 
   handleChildrenContentChange() {
@@ -123,6 +136,10 @@ class ColumnsArea extends ImmutablePureComponent {
     }
   }
 
+  handleLayoutChange = (e) => {
+    this.setState({ renderComposePanel: !e.matches });
+  }
+
   handleSwipe = (index) => {
     this.pendingIndex = index;
 
@@ -186,7 +203,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   render () {
     const { columns, children, singleColumn, isModalOpen, intl } = this.props;
-    const { shouldAnimate } = this.state;
+    const { shouldAnimate, renderComposePanel } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
 
@@ -205,7 +222,7 @@ class ColumnsArea extends ImmutablePureComponent {
         <div className='columns-area__panels'>
           <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
             <div className='columns-area__panels__pane__inner'>
-              <ComposePanel />
+              {renderComposePanel && <ComposePanel />}
             </div>
           </div>
 
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/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 18d1f7ad0..6a8a6f1de 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -701,7 +701,6 @@ html {
   .public-account-bio,
   .hero-widget__text {
     background: $account-background-color;
-    border: 1px solid lighten($ui-base-color, 8%);
   }
 
   .header {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index f3ce1595a..2059aa8f3 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -4472,6 +4472,7 @@ a.status-card.compact:hover {
   right: 0;
   bottom: 0;
   background: rgba($base-overlay-background, 0.7);
+  transition: background 0.5s;
 }
 
 .modal-root__container {
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 349e8f77e..ae8b2db75 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -43,9 +43,9 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
   end
 
   def visibility_from_audience
-    if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
+    if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
       :public
-    elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
+    elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
       :unlisted
     elsif audience_to.include?(@account.followers_url)
       :private
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index b9d43d74d..f10fc5f43 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -123,7 +123,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def process_audience
     (audience_to + audience_cc).uniq.each do |audience|
-      next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
+      next if ActivityPub::TagManager.instance.public_collection?(audience)
 
       # Unlike with tags, there is no point in resolving accounts we don't already
       # know here, because silent mentions would only be used for local access
@@ -356,9 +356,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def visibility_from_audience
-    if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
+    if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
       :public
-    elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
+    elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
       :unlisted
     elsif audience_to.include?(@account.followers_url)
       :private
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 3f2ae1106..f6b5e10d3 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -12,6 +12,10 @@ class ActivityPub::TagManager
     public: 'https://www.w3.org/ns/activitystreams#Public',
   }.freeze
 
+  def public_collection?(uri)
+    uri == COLLECTIONS[:public] || uri == 'as:Public' || uri == 'Public'
+  end
+
   def url_for(target)
     return target.url if target.respond_to?(:local?) && !target.local?
 
diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb
index 25fa694d2..2cd6ef7ad 100644
--- a/app/lib/delivery_failure_tracker.rb
+++ b/app/lib/delivery_failure_tracker.rb
@@ -29,7 +29,7 @@ class DeliveryFailureTracker
 
   class << self
     def without_unavailable(urls)
-      unavailable_domains_map = Rails.cache.fetch('unavailable_domains') { UnavailableDomain.pluck(:domain).each_with_object({}) { |domain, hash| hash[domain] = true } }
+      unavailable_domains_map = Rails.cache.fetch('unavailable_domains') { UnavailableDomain.pluck(:domain).index_with(true) }
 
       urls.reject do |url|
         host = Addressable::URI.parse(url).normalized_host
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/feed_manager.rb b/app/lib/feed_manager.rb
index 2e70c2ce9..90e6652a9 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -591,12 +591,12 @@ class FeedManager
       arr
     end
 
-    crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:in_reply_to_account_id).compact).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
-    crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.map { |s| s.account_id if s.reblog? }.compact, show_reblogs: false).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
-    crutches[:blocking]        = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
-    crutches[:muting]          = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
-    crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.map { |s| s.reblog&.account&.domain }.compact).pluck(:domain).each_with_object({}) { |domain, mapping| mapping[domain] = true }
-    crutches[:blocked_by]      = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| s.reblog&.account_id }.compact).pluck(:account_id).each_with_object({}) { |id, mapping| mapping[id] = true }
+    crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:in_reply_to_account_id).compact).pluck(:target_account_id).index_with(true)
+    crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.map { |s| s.account_id if s.reblog? }.compact, show_reblogs: false).pluck(:target_account_id).index_with(true)
+    crutches[:blocking]        = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
+    crutches[:muting]          = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)
+    crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.map { |s| s.reblog&.account&.domain }.compact).pluck(:domain).index_with(true)
+    crutches[:blocked_by]      = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| s.reblog&.account_id }.compact).pluck(:account_id).index_with(true)
 
     crutches
   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 fed504cf2..000000000
--- a/app/lib/sanitize_config.rb
+++ /dev/null
@@ -1,140 +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
-      gemini
-      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 details summary),
-
-      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/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb
index 95e195458..796de1113 100644
--- a/app/lib/settings/scoped_settings.rb
+++ b/app/lib/settings/scoped_settings.rb
@@ -64,7 +64,7 @@ module Settings
 
     class << self
       def default_settings
-        defaulting = DEFAULTING_TO_UNSCOPED.each_with_object({}) { |k, h| h[k] = Setting[k] }
+        defaulting = DEFAULTING_TO_UNSCOPED.index_with { |k| Setting[k] }
         Setting.default_settings.merge!(defaulting)
       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.rb b/app/models/account.rb
index b03cbbdf4..2e7d9f543 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -27,7 +27,6 @@
 #  header_file_size              :integer
 #  header_updated_at             :datetime
 #  avatar_remote_url             :string
-#  subscription_expires_at       :datetime
 #  locked                        :boolean          default(FALSE), not null
 #  header_remote_url             :string           default(""), not null
 #  last_webfingered_at           :datetime
@@ -55,6 +54,8 @@
 #
 
 class Account < ApplicationRecord
+  self.ignored_columns = %w(subscription_expires_at)
+
   USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
   MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]\.\-]+[a-z0-9]+)?)/i
 
@@ -97,7 +98,6 @@ class Account < ApplicationRecord
 
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
-  scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
   scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
   scope :silenced, -> { where.not(silenced_at: nil) }
   scope :suspended, -> { where.not(suspended_at: nil) }
@@ -194,10 +194,6 @@ class Account < ApplicationRecord
     "acct:#{local_username_and_domain}"
   end
 
-  def subscribed?
-    subscription_expires_at.present?
-  end
-
   def searchable?
     !(suspended? || moved?)
   end
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/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 974f57820..51e8e04a8 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -67,7 +67,7 @@ module AccountInteractions
     private
 
     def follow_mapping(query, field)
-      query.pluck(field).each_with_object({}) { |id, mapping| mapping[id] = true }
+      query.pluck(field).index_with(true)
     end
   end
 
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
index a66a4661b..4d902abcb 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.to_i.seconds.from_now if interval.present?
+      self.expires_at = interval.present? ? interval.to_i.seconds.from_now : nil 
       @expires_in     = interval
     end
 
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
index 79d671d10..791a94911 100644
--- a/app/models/concerns/omniauthable.rb
+++ b/app/models/concerns/omniauthable.rb
@@ -68,7 +68,6 @@ module Omniauthable
     def user_params_from_auth(email, auth)
       {
         email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
-        password: Devise.friendly_token[0, 20],
         agreement: true,
         external: true,
         account_attributes: {
diff --git a/app/models/direct_feed.rb b/app/models/direct_feed.rb
index c0b8a0a35..1f2448070 100644
--- a/app/models/direct_feed.rb
+++ b/app/models/direct_feed.rb
@@ -24,7 +24,7 @@ class DirectFeed < Feed
       statuses = Status.as_direct_timeline(@account, limit, max_id, since_id, min_id)
       return statuses if statuses.empty?
       max_id = statuses.last.id
-      statuses = statuses.reject { |status| FeedManager.instance.filter?(:direct, status, @account.id) }
+      statuses = statuses.reject { |status| FeedManager.instance.filter?(:direct, status, @account) }
       return statuses unless statuses.empty?
     end
   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/models/report.rb b/app/models/report.rb
index cd08120e4..ef41547d9 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -32,7 +32,7 @@ class Report < ApplicationRecord
 
   scope :unresolved, -> { where(action_taken: false) }
   scope :resolved,   -> { where(action_taken: true) }
-  scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].each_with_object({}) { |k, h| h[k] = { user: [:invite_request, :invite] } }) }
+  scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }
 
   validates :comment, length: { maximum: 1000 }
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 023dc3609..eb5b95c2b 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -468,7 +468,7 @@ class User < ApplicationRecord
   end
 
   def validate_email_dns?
-    email_changed? && !(Rails.env.test? || Rails.env.development?)
+    email_changed? && !external? && !(Rails.env.test? || Rails.env.development?)
   end
 
   def invite_text_required?
diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb
index 802799ccd..182f0e127 100644
--- a/app/services/delete_account_service.rb
+++ b/app/services/delete_account_service.rb
@@ -188,8 +188,7 @@ class DeleteAccountService < BaseService
       ids = favourites.pluck(:status_id)
       StatusStat.where(status_id: ids).update_all('favourites_count = GREATEST(0, favourites_count - 1)')
       Chewy.strategy.current.update(StatusesIndex::Status, ids) if Chewy.enabled?
-      # Rails.cache.delete_multi would be better, but we don't have it yet
-      ids.each { |id| Rails.cache.delete("statuses/#{id}") }
+      Rails.cache.delete_multi(ids.map { |id| "statuses/#{id}" })
       favourites.delete_all
     end
   end
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index b11532283..74ad5b79f 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -45,7 +45,7 @@ class ImportService < BaseService
     items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#domain'].strip }
 
     if @import.overwrite?
-      presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
+      presence_hash = items.index_with(true)
 
       @account.domain_blocks.find_each do |domain_block|
         if presence_hash[domain_block.domain]
@@ -96,7 +96,7 @@ class ImportService < BaseService
     items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#uri'].strip }
 
     if @import.overwrite?
-      presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
+      presence_hash = items.index_with(true)
 
       @account.bookmarks.find_each do |bookmark|
         if presence_hash[bookmark.status.uri]
diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
index 9f70a1469..dceef5029 100644
--- a/app/validators/email_mx_validator.rb
+++ b/app/validators/email_mx_validator.rb
@@ -10,7 +10,7 @@ class EmailMxValidator < ActiveModel::Validator
 
     if domain.blank?
       user.errors.add(:email, :invalid)
-    else
+    elsif !on_allowlist?(domain)
       ips, hostnames = resolve_mx(domain)
 
       if ips.empty?
@@ -33,6 +33,12 @@ class EmailMxValidator < ActiveModel::Validator
     nil
   end
 
+  def on_allowlist?(domain)
+    return false if Rails.configuration.x.email_domains_whitelist.blank?
+
+    Rails.configuration.x.email_domains_whitelist.include?(domain)
+  end
+
   def resolve_mx(domain)
     hostnames = []
     ips       = []
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
diff --git a/app/views/admin/action_logs/_action_log.html.haml b/app/views/admin/action_logs/_action_log.html.haml
index 59905f341..a2fce2d11 100644
--- a/app/views/admin/action_logs/_action_log.html.haml
+++ b/app/views/admin/action_logs/_action_log.html.haml
@@ -4,6 +4,6 @@
       = image_tag action_log.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
     .log-entry__content
       .log-entry__title
-        = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe
+        = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}_html", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target'))
       .log-entry__timestamp
         %time.formatted{ datetime: action_log.created_at.iso8601 }
diff --git a/app/views/admin/reports/_action_log.html.haml b/app/views/admin/reports/_action_log.html.haml
index 024078eb9..0f7d05867 100644
--- a/app/views/admin/reports/_action_log.html.haml
+++ b/app/views/admin/reports/_action_log.html.haml
@@ -1,6 +1,6 @@
 .speech-bubble.positive
   .speech-bubble__bubble
-    = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe
+    = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}_html", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target'))
   .speech-bubble__owner
     = admin_account_link_to(action_log.account)
     %time.formatted{ datetime: action_log.created_at.iso8601 }= l action_log.created_at
diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml
index fb94a07fc..84dcdcca5 100644
--- a/app/views/filters/_fields.html.haml
+++ b/app/views/filters/_fields.html.haml
@@ -2,7 +2,7 @@
   .fields-row__column.fields-row__column-6.fields-group
     = f.input :phrase, as: :string, wrapper: :with_label, hint: false
   .fields-row__column.fields-row__column-6.fields-group
-    = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
+    = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt')
 
 .fields-group
   = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 32681773f..3daaf93a9 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -39,7 +39,7 @@
     = render partial: 'layouts/theme', object: @theme
 
     - if Setting.custom_css.present?
-      = stylesheet_link_tag custom_css_path, media: 'all'
+      = stylesheet_link_tag custom_css_path, host: request.host, media: 'all'
 
   %body{ class: body_classes }
     = content_for?(:content) ? yield(:content) : yield
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index 18b52c0c2..c49613fdc 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -39,7 +39,7 @@
       %tr
         %th= t('exports.bookmarks')
         %td= number_with_delimiter @export.total_bookmarks
-        %td= table_link_to 'download', t('bookmarks.csv'), settings_exports_bookmarks_path(format: :csv)
+        %td= table_link_to 'download', t('exports.csv'), settings_exports_bookmarks_path(format: :csv)
 
 %hr.spacer/
 
diff --git a/app/workers/scheduler/backup_cleanup_scheduler.rb b/app/workers/scheduler/backup_cleanup_scheduler.rb
index d69ca2556..85d5312c0 100644
--- a/app/workers/scheduler/backup_cleanup_scheduler.rb
+++ b/app/workers/scheduler/backup_cleanup_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::BackupCleanupScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     old_backups.reorder(nil).find_each(&:destroy!)
diff --git a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
index bb9dd49ca..9303a352f 100644
--- a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
+++ b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::DoorkeeperCleanupScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     Doorkeeper::AccessToken.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
diff --git a/app/workers/scheduler/email_scheduler.rb b/app/workers/scheduler/email_scheduler.rb
index 9a7355524..c052f2fce 100644
--- a/app/workers/scheduler/email_scheduler.rb
+++ b/app/workers/scheduler/email_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::EmailScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   FREQUENCY      = 7.days.freeze
   SIGN_IN_OFFSET = 1.day.freeze
diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb
index 96c17c578..78adc97e2 100644
--- a/app/workers/scheduler/feed_cleanup_scheduler.rb
+++ b/app/workers/scheduler/feed_cleanup_scheduler.rb
@@ -4,7 +4,7 @@ class Scheduler::FeedCleanupScheduler
   include Sidekiq::Worker
   include Redisable
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     clean_home_feeds!
diff --git a/app/workers/scheduler/instance_refresh_scheduler.rb b/app/workers/scheduler/instance_refresh_scheduler.rb
index 917404bec..2af5f3855 100644
--- a/app/workers/scheduler/instance_refresh_scheduler.rb
+++ b/app/workers/scheduler/instance_refresh_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::InstanceRefreshScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     Instance.refresh
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
index 853f20e25..df7e6ad56 100644
--- a/app/workers/scheduler/ip_cleanup_scheduler.rb
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -5,7 +5,7 @@ class Scheduler::IpCleanupScheduler
 
   IP_RETENTION_PERIOD = 1.year.freeze
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     clean_ip_columns!
diff --git a/app/workers/scheduler/media_cleanup_scheduler.rb b/app/workers/scheduler/media_cleanup_scheduler.rb
index 671ebf6e0..24d30a6be 100644
--- a/app/workers/scheduler/media_cleanup_scheduler.rb
+++ b/app/workers/scheduler/media_cleanup_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::MediaCleanupScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     unattached_media.find_each(&:destroy)
diff --git a/app/workers/scheduler/pghero_scheduler.rb b/app/workers/scheduler/pghero_scheduler.rb
index cf5570048..a756b13b9 100644
--- a/app/workers/scheduler/pghero_scheduler.rb
+++ b/app/workers/scheduler/pghero_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::PgheroScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     PgHero.capture_space_stats
diff --git a/app/workers/scheduler/scheduled_statuses_scheduler.rb b/app/workers/scheduler/scheduled_statuses_scheduler.rb
index 25df3c07d..3bf6300b3 100644
--- a/app/workers/scheduler/scheduled_statuses_scheduler.rb
+++ b/app/workers/scheduler/scheduled_statuses_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::ScheduledStatusesScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     publish_scheduled_statuses!
diff --git a/app/workers/scheduler/trending_tags_scheduler.rb b/app/workers/scheduler/trending_tags_scheduler.rb
index e9891424e..94d76d010 100644
--- a/app/workers/scheduler/trending_tags_scheduler.rb
+++ b/app/workers/scheduler/trending_tags_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::TrendingTagsScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     TrendingTags.update! if Setting.trends
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index 8571b59e1..be0c4277d 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -3,7 +3,7 @@
 class Scheduler::UserCleanupScheduler
   include Sidekiq::Worker
 
-  sidekiq_options lock: :until_executed, retry: 0
+  sidekiq_options retry: 0
 
   def perform
     clean_unconfirmed_accounts!